From 22b9be829d9d9a51102efa3745cf1ea4f9df95a0 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Tue, 20 Jun 2023 20:38:31 +0000 Subject: [PATCH 01/25] feat: add user maintenance schedule for max_ttl autostop --- cli/server.go | 49 ++++----- coderd/coderd.go | 69 +++++++----- coderd/database/dbauthz/dbauthz.go | 7 ++ coderd/database/dbfake/dbfake.go | 19 ++++ coderd/database/dbmetrics/dbmetrics.go | 7 ++ coderd/database/dbmock/dbmock.go | 15 +++ coderd/database/dump.sql | 3 +- .../000130_user_maintenance_schedule.down.sql | 1 + .../000130_user_maintenance_schedule.up.sql | 2 + coderd/database/models.go | 25 ++--- coderd/database/querier.go | 1 + coderd/database/queries.sql.go | 93 ++++++++++++---- coderd/database/queries/users.sql | 9 ++ .../provisionerdserver/provisionerdserver.go | 45 +++++--- .../provisionerdserver_test.go | 57 +++++----- coderd/schedule/cron.go | 48 ++++++++- coderd/schedule/cron_test.go | 4 +- coderd/schedule/template.go | 17 +-- coderd/schedule/user.go | 60 +++++++++++ codersdk/deployment.go | 2 + codersdk/users.go | 30 ++++++ docs/admin/audit-logs.md | 2 +- enterprise/audit/table.go | 25 ++--- enterprise/coderd/coderd.go | 32 +++++- enterprise/coderd/coderd_test.go | 1 + enterprise/coderd/provisionerdaemons.go | 102 +++--------------- enterprise/coderd/schedule/template.go | 91 ++++++++++++++++ enterprise/coderd/schedule/user.go | 81 ++++++++++++++ enterprise/coderd/users.go | 99 +++++++++++++++++ site/src/api/typesGenerated.ts | 18 ++++ 30 files changed, 772 insertions(+), 242 deletions(-) create mode 100644 coderd/database/migrations/000130_user_maintenance_schedule.down.sql create mode 100644 coderd/database/migrations/000130_user_maintenance_schedule.up.sql create mode 100644 coderd/schedule/user.go create mode 100644 enterprise/coderd/schedule/template.go create mode 100644 enterprise/coderd/schedule/user.go create mode 100644 enterprise/coderd/users.go diff --git a/cli/server.go b/cli/server.go index e30bf5fd71fde..21e1e31526628 100644 --- a/cli/server.go +++ b/cli/server.go @@ -458,30 +458,31 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. } options := &coderd.Options{ - AccessURL: cfg.AccessURL.Value(), - AppHostname: appHostname, - AppHostnameRegex: appHostnameRegex, - Logger: logger.Named("coderd"), - Database: dbfake.New(), - DERPMap: derpMap, - Pubsub: pubsub.NewInMemory(), - CacheDir: cacheDir, - GoogleTokenValidator: googleTokenValidator, - GitAuthConfigs: gitAuthConfigs, - RealIPConfig: realIPConfig, - SecureAuthCookie: cfg.SecureAuthCookie.Value(), - SSHKeygenAlgorithm: sshKeygenAlgorithm, - TracerProvider: tracerProvider, - Telemetry: telemetry.NewNoop(), - MetricsCacheRefreshInterval: cfg.MetricsCacheRefreshInterval.Value(), - AgentStatsRefreshInterval: cfg.AgentStatRefreshInterval.Value(), - DeploymentValues: cfg, - PrometheusRegistry: prometheus.NewRegistry(), - APIRateLimit: int(cfg.RateLimit.API.Value()), - LoginRateLimit: loginRateLimit, - FilesRateLimit: filesRateLimit, - HTTPClient: httpClient, - TemplateScheduleStore: &atomic.Pointer[schedule.TemplateScheduleStore]{}, + AccessURL: cfg.AccessURL.Value(), + AppHostname: appHostname, + AppHostnameRegex: appHostnameRegex, + Logger: logger.Named("coderd"), + Database: dbfake.New(), + DERPMap: derpMap, + Pubsub: pubsub.NewInMemory(), + CacheDir: cacheDir, + GoogleTokenValidator: googleTokenValidator, + GitAuthConfigs: gitAuthConfigs, + RealIPConfig: realIPConfig, + SecureAuthCookie: cfg.SecureAuthCookie.Value(), + SSHKeygenAlgorithm: sshKeygenAlgorithm, + TracerProvider: tracerProvider, + Telemetry: telemetry.NewNoop(), + MetricsCacheRefreshInterval: cfg.MetricsCacheRefreshInterval.Value(), + AgentStatsRefreshInterval: cfg.AgentStatRefreshInterval.Value(), + DeploymentValues: cfg, + PrometheusRegistry: prometheus.NewRegistry(), + APIRateLimit: int(cfg.RateLimit.API.Value()), + LoginRateLimit: loginRateLimit, + FilesRateLimit: filesRateLimit, + HTTPClient: httpClient, + TemplateScheduleStore: &atomic.Pointer[schedule.TemplateScheduleStore]{}, + UserMaintenanceScheduleStore: &atomic.Pointer[schedule.UserMaintenanceScheduleStore]{}, SSHConfig: codersdk.SSHConfigResponse{ HostnamePrefix: cfg.SSHConfig.DeploymentName.String(), SSHConfigOptions: configSSHOptions, diff --git a/coderd/coderd.go b/coderd/coderd.go index 9a306d1db6279..9ffdfa2c3e3c7 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -118,13 +118,14 @@ type Options struct { RealIPConfig *httpmw.RealIPConfig TrialGenerator func(ctx context.Context, email string) error // TLSCertificates is used to mesh DERP servers securely. - TLSCertificates []tls.Certificate - TailnetCoordinator tailnet.Coordinator - DERPServer *derp.Server - DERPMap *tailcfg.DERPMap - SwaggerEndpoint bool - SetUserGroups func(ctx context.Context, tx database.Store, userID uuid.UUID, groupNames []string) error - TemplateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore] + TLSCertificates []tls.Certificate + TailnetCoordinator tailnet.Coordinator + DERPServer *derp.Server + DERPMap *tailcfg.DERPMap + SwaggerEndpoint bool + SetUserGroups func(ctx context.Context, tx database.Store, userID uuid.UUID, groupNames []string) error + TemplateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore] + UserMaintenanceScheduleStore *atomic.Pointer[schedule.UserMaintenanceScheduleStore] // AppSecurityKey is the crypto key used to sign and encrypt tokens related to // workspace applications. It consists of both a signing and encryption key. AppSecurityKey workspaceapps.SecurityKey @@ -259,6 +260,13 @@ func New(options *Options) *API { v := schedule.NewAGPLTemplateScheduleStore() options.TemplateScheduleStore.Store(&v) } + if options.UserMaintenanceScheduleStore == nil { + options.UserMaintenanceScheduleStore = &atomic.Pointer[schedule.UserMaintenanceScheduleStore]{} + } + if options.UserMaintenanceScheduleStore.Load() == nil { + v := schedule.NewAGPLUserMaintenanceScheduleStore() + options.UserMaintenanceScheduleStore.Store(&v) + } if options.HealthcheckFunc == nil { options.HealthcheckFunc = func(ctx context.Context, apiKey string) *healthcheck.Report { return healthcheck.Run(ctx, &healthcheck.ReportOptions{ @@ -330,11 +338,12 @@ func New(options *Options) *API { options.AgentInactiveDisconnectTimeout, options.AppSecurityKey, ), - metricsCache: metricsCache, - Auditor: atomic.Pointer[audit.Auditor]{}, - TemplateScheduleStore: options.TemplateScheduleStore, - Experiments: experiments, - healthCheckGroup: &singleflight.Group[string, *healthcheck.Report]{}, + metricsCache: metricsCache, + Auditor: atomic.Pointer[audit.Auditor]{}, + TemplateScheduleStore: options.TemplateScheduleStore, + UserMaintenanceScheduleStore: options.UserMaintenanceScheduleStore, + Experiments: experiments, + healthCheckGroup: &singleflight.Group[string, *healthcheck.Report]{}, } if options.UpdateCheckOptions != nil { api.updateChecker = updatecheck.New( @@ -856,6 +865,9 @@ type API struct { // TemplateScheduleStore is a pointer to an atomic pointer because this is // passed to another struct, and we want them all to be the same reference. TemplateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore] + // UserMaintenanceScheduleStore is a pointer to an atomic pointer for the + // same reason as TemplateScheduleStore. + UserMaintenanceScheduleStore *atomic.Pointer[schedule.UserMaintenanceScheduleStore] HTTPAuth *HTTPAuthorizer @@ -965,22 +977,23 @@ func (api *API) CreateInMemoryProvisionerDaemon(ctx context.Context, debounce ti mux := drpcmux.New() err = proto.DRPCRegisterProvisionerDaemon(mux, &provisionerdserver.Server{ - AccessURL: api.AccessURL, - ID: daemon.ID, - OIDCConfig: api.OIDCConfig, - Database: api.Database, - Pubsub: api.Pubsub, - Provisioners: daemon.Provisioners, - GitAuthConfigs: api.GitAuthConfigs, - Telemetry: api.Telemetry, - Tracer: tracer, - Tags: tags, - QuotaCommitter: &api.QuotaCommitter, - Auditor: &api.Auditor, - TemplateScheduleStore: api.TemplateScheduleStore, - AcquireJobDebounce: debounce, - Logger: api.Logger.Named(fmt.Sprintf("provisionerd-%s", daemon.Name)), - DeploymentValues: api.DeploymentValues, + AccessURL: api.AccessURL, + ID: daemon.ID, + OIDCConfig: api.OIDCConfig, + Database: api.Database, + Pubsub: api.Pubsub, + Provisioners: daemon.Provisioners, + GitAuthConfigs: api.GitAuthConfigs, + Telemetry: api.Telemetry, + Tracer: tracer, + Tags: tags, + QuotaCommitter: &api.QuotaCommitter, + Auditor: &api.Auditor, + TemplateScheduleStore: api.TemplateScheduleStore, + UserMaintenanceScheduleStore: api.UserMaintenanceScheduleStore, + AcquireJobDebounce: debounce, + Logger: api.Logger.Named(fmt.Sprintf("provisionerd-%s", daemon.Name)), + DeploymentValues: api.DeploymentValues, }) if err != nil { return nil, err diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index dc71b4e3738d3..617cc6e8f8491 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -2268,6 +2268,13 @@ func (q *querier) UpdateUserLinkedID(ctx context.Context, arg database.UpdateUse return q.db.UpdateUserLinkedID(ctx, arg) } +func (q *querier) UpdateUserMaintenanceSchedule(ctx context.Context, arg database.UpdateUserMaintenanceScheduleParams) (database.User, error) { + fetch := func(ctx context.Context, arg database.UpdateUserMaintenanceScheduleParams) (database.User, error) { + return q.db.GetUserByID(ctx, arg.ID) + } + return updateWithReturn(q.log, q.auth, fetch, q.db.UpdateUserMaintenanceSchedule)(ctx, arg) +} + func (q *querier) UpdateUserProfile(ctx context.Context, arg database.UpdateUserProfileParams) (database.User, error) { u, err := q.db.GetUserByID(ctx, arg.ID) if err != nil { diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go index fd527764654b7..65e54d5f16c31 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -4762,6 +4762,25 @@ func (q *fakeQuerier) UpdateUserLinkedID(_ context.Context, params database.Upda return database.UserLink{}, sql.ErrNoRows } +func (q *fakeQuerier) UpdateUserMaintenanceSchedule(_ context.Context, arg database.UpdateUserMaintenanceScheduleParams) (database.User, error) { + if err := validateDatabaseType(arg); err != nil { + return database.User{}, err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + for index, user := range q.users { + if user.ID != arg.ID { + continue + } + user.MaintenanceSchedule = arg.MaintenanceSchedule + q.users[index] = user + return user, nil + } + return database.User{}, sql.ErrNoRows +} + func (q *fakeQuerier) UpdateUserProfile(_ context.Context, arg database.UpdateUserProfileParams) (database.User, error) { if err := validateDatabaseType(arg); err != nil { return database.User{}, err diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index c6d15359f4022..6c103e5cb1b51 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -1375,6 +1375,13 @@ func (m metricsStore) UpdateUserLinkedID(ctx context.Context, arg database.Updat return link, err } +func (m metricsStore) UpdateUserMaintenanceSchedule(ctx context.Context, arg database.UpdateUserMaintenanceScheduleParams) (database.User, error) { + start := time.Now() + r0, r1 := m.s.UpdateUserMaintenanceSchedule(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateUserMaintenanceSchedule").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m metricsStore) UpdateUserProfile(ctx context.Context, arg database.UpdateUserProfileParams) (database.User, error) { start := time.Now() user, err := m.s.UpdateUserProfile(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 5fd5cd6c1b02b..318960690b887 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -2831,6 +2831,21 @@ func (mr *MockStoreMockRecorder) UpdateUserLinkedID(arg0, arg1 interface{}) *gom return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserLinkedID", reflect.TypeOf((*MockStore)(nil).UpdateUserLinkedID), arg0, arg1) } +// UpdateUserMaintenanceSchedule mocks base method. +func (m *MockStore) UpdateUserMaintenanceSchedule(arg0 context.Context, arg1 database.UpdateUserMaintenanceScheduleParams) (database.User, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateUserMaintenanceSchedule", arg0, arg1) + ret0, _ := ret[0].(database.User) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateUserMaintenanceSchedule indicates an expected call of UpdateUserMaintenanceSchedule. +func (mr *MockStoreMockRecorder) UpdateUserMaintenanceSchedule(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserMaintenanceSchedule", reflect.TypeOf((*MockStore)(nil).UpdateUserMaintenanceSchedule), arg0, arg1) +} + // UpdateUserProfile mocks base method. func (m *MockStore) UpdateUserProfile(arg0 context.Context, arg1 database.UpdateUserProfileParams) (database.User, error) { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 25cf2107baa49..053eaa58b71e2 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -529,7 +529,8 @@ CREATE TABLE users ( login_type login_type DEFAULT 'password'::login_type NOT NULL, avatar_url text, deleted boolean DEFAULT false NOT NULL, - last_seen_at timestamp without time zone DEFAULT '0001-01-01 00:00:00'::timestamp without time zone NOT NULL + last_seen_at timestamp without time zone DEFAULT '0001-01-01 00:00:00'::timestamp without time zone NOT NULL, + maintenance_schedule text DEFAULT ''::text NOT NULL ); CREATE UNLOGGED TABLE workspace_agent_metadata ( diff --git a/coderd/database/migrations/000130_user_maintenance_schedule.down.sql b/coderd/database/migrations/000130_user_maintenance_schedule.down.sql new file mode 100644 index 0000000000000..91d3f65e5a647 --- /dev/null +++ b/coderd/database/migrations/000130_user_maintenance_schedule.down.sql @@ -0,0 +1 @@ +ALTER TABLE users DROP COLUMN maintenance_schedule; diff --git a/coderd/database/migrations/000130_user_maintenance_schedule.up.sql b/coderd/database/migrations/000130_user_maintenance_schedule.up.sql new file mode 100644 index 0000000000000..95a4c984ad1f3 --- /dev/null +++ b/coderd/database/migrations/000130_user_maintenance_schedule.up.sql @@ -0,0 +1,2 @@ +-- empty schedule means use the default if entitled +ALTER TABLE users ADD COLUMN maintenance_schedule text NOT NULL DEFAULT ''; diff --git a/coderd/database/models.go b/coderd/database/models.go index c71e5b79c9f84..bfb0929e6a141 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1631,18 +1631,19 @@ type TemplateVersionVariable struct { } type User struct { - ID uuid.UUID `db:"id" json:"id"` - Email string `db:"email" json:"email"` - Username string `db:"username" json:"username"` - HashedPassword []byte `db:"hashed_password" json:"hashed_password"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` - Status UserStatus `db:"status" json:"status"` - RBACRoles pq.StringArray `db:"rbac_roles" json:"rbac_roles"` - LoginType LoginType `db:"login_type" json:"login_type"` - AvatarURL sql.NullString `db:"avatar_url" json:"avatar_url"` - Deleted bool `db:"deleted" json:"deleted"` - LastSeenAt time.Time `db:"last_seen_at" json:"last_seen_at"` + ID uuid.UUID `db:"id" json:"id"` + Email string `db:"email" json:"email"` + Username string `db:"username" json:"username"` + HashedPassword []byte `db:"hashed_password" json:"hashed_password"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + Status UserStatus `db:"status" json:"status"` + RBACRoles pq.StringArray `db:"rbac_roles" json:"rbac_roles"` + LoginType LoginType `db:"login_type" json:"login_type"` + AvatarURL sql.NullString `db:"avatar_url" json:"avatar_url"` + Deleted bool `db:"deleted" json:"deleted"` + LastSeenAt time.Time `db:"last_seen_at" json:"last_seen_at"` + MaintenanceSchedule string `db:"maintenance_schedule" json:"maintenance_schedule"` } type UserLink struct { diff --git a/coderd/database/querier.go b/coderd/database/querier.go index edbb8205b3042..2b9a1eb22f241 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -235,6 +235,7 @@ type sqlcQuerier interface { UpdateUserLastSeenAt(ctx context.Context, arg UpdateUserLastSeenAtParams) (User, error) UpdateUserLink(ctx context.Context, arg UpdateUserLinkParams) (UserLink, error) UpdateUserLinkedID(ctx context.Context, arg UpdateUserLinkedIDParams) (UserLink, error) + UpdateUserMaintenanceSchedule(ctx context.Context, arg UpdateUserMaintenanceScheduleParams) (User, error) UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) (User, error) UpdateUserRoles(ctx context.Context, arg UpdateUserRolesParams) (User, error) UpdateUserStatus(ctx context.Context, arg UpdateUserStatusParams) (User, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 540367e58410e..85760722bdc34 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -1066,7 +1066,7 @@ func (q *sqlQuerier) DeleteGroupMembersByOrgAndUser(ctx context.Context, arg Del const getGroupMembers = `-- name: GetGroupMembers :many SELECT - users.id, users.email, users.username, users.hashed_password, users.created_at, users.updated_at, users.status, users.rbac_roles, users.login_type, users.avatar_url, users.deleted, users.last_seen_at + users.id, users.email, users.username, users.hashed_password, users.created_at, users.updated_at, users.status, users.rbac_roles, users.login_type, users.avatar_url, users.deleted, users.last_seen_at, users.maintenance_schedule FROM users JOIN @@ -1103,6 +1103,7 @@ func (q *sqlQuerier) GetGroupMembers(ctx context.Context, groupID uuid.UUID) ([] &i.AvatarURL, &i.Deleted, &i.LastSeenAt, + &i.MaintenanceSchedule, ); err != nil { return nil, err } @@ -4741,7 +4742,7 @@ func (q *sqlQuerier) GetFilteredUserCount(ctx context.Context, arg GetFilteredUs const getUserByEmailOrUsername = `-- name: GetUserByEmailOrUsername :one SELECT - id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at + id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, maintenance_schedule FROM users WHERE @@ -4772,13 +4773,14 @@ func (q *sqlQuerier) GetUserByEmailOrUsername(ctx context.Context, arg GetUserBy &i.AvatarURL, &i.Deleted, &i.LastSeenAt, + &i.MaintenanceSchedule, ) return i, err } const getUserByID = `-- name: GetUserByID :one SELECT - id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at + id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, maintenance_schedule FROM users WHERE @@ -4803,6 +4805,7 @@ func (q *sqlQuerier) GetUserByID(ctx context.Context, id uuid.UUID) (User, error &i.AvatarURL, &i.Deleted, &i.LastSeenAt, + &i.MaintenanceSchedule, ) return i, err } @@ -4825,7 +4828,7 @@ func (q *sqlQuerier) GetUserCount(ctx context.Context) (int64, error) { const getUsers = `-- name: GetUsers :many SELECT - id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, COUNT(*) OVER() AS count + id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, maintenance_schedule, COUNT(*) OVER() AS count FROM users WHERE @@ -4893,19 +4896,20 @@ type GetUsersParams struct { } type GetUsersRow struct { - ID uuid.UUID `db:"id" json:"id"` - Email string `db:"email" json:"email"` - Username string `db:"username" json:"username"` - HashedPassword []byte `db:"hashed_password" json:"hashed_password"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` - Status UserStatus `db:"status" json:"status"` - RBACRoles pq.StringArray `db:"rbac_roles" json:"rbac_roles"` - LoginType LoginType `db:"login_type" json:"login_type"` - AvatarURL sql.NullString `db:"avatar_url" json:"avatar_url"` - Deleted bool `db:"deleted" json:"deleted"` - LastSeenAt time.Time `db:"last_seen_at" json:"last_seen_at"` - Count int64 `db:"count" json:"count"` + ID uuid.UUID `db:"id" json:"id"` + Email string `db:"email" json:"email"` + Username string `db:"username" json:"username"` + HashedPassword []byte `db:"hashed_password" json:"hashed_password"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + Status UserStatus `db:"status" json:"status"` + RBACRoles pq.StringArray `db:"rbac_roles" json:"rbac_roles"` + LoginType LoginType `db:"login_type" json:"login_type"` + AvatarURL sql.NullString `db:"avatar_url" json:"avatar_url"` + Deleted bool `db:"deleted" json:"deleted"` + LastSeenAt time.Time `db:"last_seen_at" json:"last_seen_at"` + MaintenanceSchedule string `db:"maintenance_schedule" json:"maintenance_schedule"` + Count int64 `db:"count" json:"count"` } // This will never return deleted users. @@ -4938,6 +4942,7 @@ func (q *sqlQuerier) GetUsers(ctx context.Context, arg GetUsersParams) ([]GetUse &i.AvatarURL, &i.Deleted, &i.LastSeenAt, + &i.MaintenanceSchedule, &i.Count, ); err != nil { return nil, err @@ -4954,7 +4959,7 @@ func (q *sqlQuerier) GetUsers(ctx context.Context, arg GetUsersParams) ([]GetUse } const getUsersByIDs = `-- name: GetUsersByIDs :many -SELECT id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at FROM users WHERE id = ANY($1 :: uuid [ ]) +SELECT id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, maintenance_schedule FROM users WHERE id = ANY($1 :: uuid [ ]) ` // This shouldn't check for deleted, because it's frequently used @@ -4982,6 +4987,7 @@ func (q *sqlQuerier) GetUsersByIDs(ctx context.Context, ids []uuid.UUID) ([]User &i.AvatarURL, &i.Deleted, &i.LastSeenAt, + &i.MaintenanceSchedule, ); err != nil { return nil, err } @@ -5009,7 +5015,7 @@ INSERT INTO login_type ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at + ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, maintenance_schedule ` type InsertUserParams struct { @@ -5048,6 +5054,7 @@ func (q *sqlQuerier) InsertUser(ctx context.Context, arg InsertUserParams) (User &i.AvatarURL, &i.Deleted, &i.LastSeenAt, + &i.MaintenanceSchedule, ) return i, err } @@ -5097,7 +5104,7 @@ SET last_seen_at = $2, updated_at = $3 WHERE - id = $1 RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at + id = $1 RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, maintenance_schedule ` type UpdateUserLastSeenAtParams struct { @@ -5122,6 +5129,43 @@ func (q *sqlQuerier) UpdateUserLastSeenAt(ctx context.Context, arg UpdateUserLas &i.AvatarURL, &i.Deleted, &i.LastSeenAt, + &i.MaintenanceSchedule, + ) + return i, err +} + +const updateUserMaintenanceSchedule = `-- name: UpdateUserMaintenanceSchedule :one +UPDATE + users +SET + maintenance_schedule = $2 +WHERE + id = $1 +RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, maintenance_schedule +` + +type UpdateUserMaintenanceScheduleParams struct { + ID uuid.UUID `db:"id" json:"id"` + MaintenanceSchedule string `db:"maintenance_schedule" json:"maintenance_schedule"` +} + +func (q *sqlQuerier) UpdateUserMaintenanceSchedule(ctx context.Context, arg UpdateUserMaintenanceScheduleParams) (User, error) { + row := q.db.QueryRowContext(ctx, updateUserMaintenanceSchedule, arg.ID, arg.MaintenanceSchedule) + var i User + err := row.Scan( + &i.ID, + &i.Email, + &i.Username, + &i.HashedPassword, + &i.CreatedAt, + &i.UpdatedAt, + &i.Status, + &i.RBACRoles, + &i.LoginType, + &i.AvatarURL, + &i.Deleted, + &i.LastSeenAt, + &i.MaintenanceSchedule, ) return i, err } @@ -5135,7 +5179,7 @@ SET avatar_url = $4, updated_at = $5 WHERE - id = $1 RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at + id = $1 RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, maintenance_schedule ` type UpdateUserProfileParams struct { @@ -5168,6 +5212,7 @@ func (q *sqlQuerier) UpdateUserProfile(ctx context.Context, arg UpdateUserProfil &i.AvatarURL, &i.Deleted, &i.LastSeenAt, + &i.MaintenanceSchedule, ) return i, err } @@ -5180,7 +5225,7 @@ SET rbac_roles = ARRAY(SELECT DISTINCT UNNEST($1 :: text[])) WHERE id = $2 -RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at +RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, maintenance_schedule ` type UpdateUserRolesParams struct { @@ -5204,6 +5249,7 @@ func (q *sqlQuerier) UpdateUserRoles(ctx context.Context, arg UpdateUserRolesPar &i.AvatarURL, &i.Deleted, &i.LastSeenAt, + &i.MaintenanceSchedule, ) return i, err } @@ -5215,7 +5261,7 @@ SET status = $2, updated_at = $3 WHERE - id = $1 RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at + id = $1 RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, maintenance_schedule ` type UpdateUserStatusParams struct { @@ -5240,6 +5286,7 @@ func (q *sqlQuerier) UpdateUserStatus(ctx context.Context, arg UpdateUserStatusP &i.AvatarURL, &i.Deleted, &i.LastSeenAt, + &i.MaintenanceSchedule, ) return i, err } diff --git a/coderd/database/queries/users.sql b/coderd/database/queries/users.sql index 22b3c9c1d6584..5b2555536c51b 100644 --- a/coderd/database/queries/users.sql +++ b/coderd/database/queries/users.sql @@ -248,3 +248,12 @@ FROM users WHERE id = @user_id; + +-- name: UpdateUserMaintenanceSchedule :one +UPDATE + users +SET + maintenance_schedule = $2 +WHERE + id = $1 +RETURNING *; diff --git a/coderd/provisionerdserver/provisionerdserver.go b/coderd/provisionerdserver/provisionerdserver.go index 191143fdcde39..4d9ef143ebece 100644 --- a/coderd/provisionerdserver/provisionerdserver.go +++ b/coderd/provisionerdserver/provisionerdserver.go @@ -26,7 +26,6 @@ import ( protobuf "google.golang.org/protobuf/proto" "cdr.dev/slog" - "github.com/coder/coder/coderd/apikey" "github.com/coder/coder/coderd/audit" "github.com/coder/coder/coderd/database" @@ -50,20 +49,21 @@ var ( ) type Server struct { - AccessURL *url.URL - ID uuid.UUID - Logger slog.Logger - Provisioners []database.ProvisionerType - GitAuthConfigs []*gitauth.Config - Tags json.RawMessage - Database database.Store - Pubsub pubsub.Pubsub - Telemetry telemetry.Reporter - Tracer trace.Tracer - QuotaCommitter *atomic.Pointer[proto.QuotaCommitter] - Auditor *atomic.Pointer[audit.Auditor] - TemplateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore] - DeploymentValues *codersdk.DeploymentValues + AccessURL *url.URL + ID uuid.UUID + Logger slog.Logger + Provisioners []database.ProvisionerType + GitAuthConfigs []*gitauth.Config + Tags json.RawMessage + Database database.Store + Pubsub pubsub.Pubsub + Telemetry telemetry.Reporter + Tracer trace.Tracer + QuotaCommitter *atomic.Pointer[proto.QuotaCommitter] + Auditor *atomic.Pointer[audit.Auditor] + TemplateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore] + UserMaintenanceScheduleStore *atomic.Pointer[schedule.UserMaintenanceScheduleStore] + DeploymentValues *codersdk.DeploymentValues AcquireJobDebounce time.Duration OIDCConfig httpmw.OAuth2Config @@ -934,10 +934,25 @@ func (server *Server) CompleteJob(ctx context.Context, completed *proto.Complete // If the workspace doesn't have a deadline or the max // deadline is sooner than the workspace deadline, use the // max deadline as the actual deadline. + // + // Notably, this isn't affected by the user's maintenance + // schedule below because we'd still like to use the max TTL + // as the TTL for the workspace if it's not set. deadline = maxDeadline } } + userMaintenanceSchedule, err := (*server.UserMaintenanceScheduleStore.Load()).GetUserMaintenanceScheduleOptions(ctx, db, workspace.OwnerID) + if err != nil { + return xerrors.Errorf("get user maintenance schedule options: %w", err) + } + if userMaintenanceSchedule.Schedule != nil { + // Round the max deadline up to the nearest occurrence of the + // user's maintenance schedule. This ensures that workspaces + // can't be force-stopped due to max TTL during business hours. + maxDeadline = userMaintenanceSchedule.Schedule.Next(maxDeadline) + } + err = db.UpdateProvisionerJobWithCompleteByID(ctx, database.UpdateProvisionerJobWithCompleteByIDParams{ ID: jobID, UpdatedAt: database.Now(), diff --git a/coderd/provisionerdserver/provisionerdserver_test.go b/coderd/provisionerdserver/provisionerdserver_test.go index 6b881210b3f6a..f1868adce5964 100644 --- a/coderd/provisionerdserver/provisionerdserver_test.go +++ b/coderd/provisionerdserver/provisionerdserver_test.go @@ -47,6 +47,13 @@ func testTemplateScheduleStore() *atomic.Pointer[schedule.TemplateScheduleStore] return ptr } +func testUserMaintenanceScheduleStore() *atomic.Pointer[schedule.UserMaintenanceScheduleStore] { + ptr := &atomic.Pointer[schedule.UserMaintenanceScheduleStore]{} + store := schedule.NewAGPLUserMaintenanceScheduleStore() + ptr.Store(&store) + return ptr +} + func TestAcquireJob(t *testing.T) { t.Parallel() t.Run("Debounce", func(t *testing.T) { @@ -54,18 +61,19 @@ func TestAcquireJob(t *testing.T) { db := dbfake.New() ps := pubsub.NewInMemory() srv := &provisionerdserver.Server{ - ID: uuid.New(), - Logger: slogtest.Make(t, nil), - AccessURL: &url.URL{}, - Provisioners: []database.ProvisionerType{database.ProvisionerTypeEcho}, - Database: db, - Pubsub: ps, - Telemetry: telemetry.NewNoop(), - AcquireJobDebounce: time.Hour, - Auditor: mockAuditor(), - TemplateScheduleStore: testTemplateScheduleStore(), - Tracer: trace.NewNoopTracerProvider().Tracer("noop"), - DeploymentValues: &codersdk.DeploymentValues{}, + ID: uuid.New(), + Logger: slogtest.Make(t, nil), + AccessURL: &url.URL{}, + Provisioners: []database.ProvisionerType{database.ProvisionerTypeEcho}, + Database: db, + Pubsub: ps, + Telemetry: telemetry.NewNoop(), + AcquireJobDebounce: time.Hour, + Auditor: mockAuditor(), + TemplateScheduleStore: testTemplateScheduleStore(), + UserMaintenanceScheduleStore: testUserMaintenanceScheduleStore(), + Tracer: trace.NewNoopTracerProvider().Tracer("noop"), + DeploymentValues: &codersdk.DeploymentValues{}, } job, err := srv.AcquireJob(context.Background(), nil) require.NoError(t, err) @@ -1260,18 +1268,19 @@ func setup(t *testing.T, ignoreLogErrors bool) *provisionerdserver.Server { ps := pubsub.NewInMemory() return &provisionerdserver.Server{ - ID: uuid.New(), - Logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: ignoreLogErrors}), - OIDCConfig: &oauth2.Config{}, - AccessURL: &url.URL{}, - Provisioners: []database.ProvisionerType{database.ProvisionerTypeEcho}, - Database: db, - Pubsub: ps, - Telemetry: telemetry.NewNoop(), - Auditor: mockAuditor(), - TemplateScheduleStore: testTemplateScheduleStore(), - Tracer: trace.NewNoopTracerProvider().Tracer("noop"), - DeploymentValues: &codersdk.DeploymentValues{}, + ID: uuid.New(), + Logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: ignoreLogErrors}), + OIDCConfig: &oauth2.Config{}, + AccessURL: &url.URL{}, + Provisioners: []database.ProvisionerType{database.ProvisionerTypeEcho}, + Database: db, + Pubsub: ps, + Telemetry: telemetry.NewNoop(), + Auditor: mockAuditor(), + TemplateScheduleStore: testTemplateScheduleStore(), + UserMaintenanceScheduleStore: testUserMaintenanceScheduleStore(), + Tracer: trace.NewNoopTracerProvider().Tracer("noop"), + DeploymentValues: &codersdk.DeploymentValues{}, } } diff --git a/coderd/schedule/cron.go b/coderd/schedule/cron.go index affe9d63db27d..7a86b130d24bc 100644 --- a/coderd/schedule/cron.go +++ b/coderd/schedule/cron.go @@ -42,6 +42,36 @@ func Weekly(raw string) (*Schedule, error) { return nil, xerrors.Errorf("validate weekly schedule: %w", err) } + return parse(raw) +} + +// Daily parses a Schedule from spec scoped to a recurring daily event. +// Spec consists of the following space-delimited fields, in the following order: +// - timezone e.g. CRON_TZ=US/Central (optional) +// - minutes of hour e.g. 30 (required) +// - hour of day e.g. 9 (required) +// - day of month (must be *) +// - month (must be *) +// - day of week (must be *) +// +// Example Usage: +// +// local_sched, _ := schedule.Weekly("59 23 * * *") +// fmt.Println(sched.Next(time.Now().Format(time.RFC3339))) +// // Output: 2022-04-04T23:59:00Z +// +// us_sched, _ := schedule.Weekly("CRON_TZ=US/Central 30 9 * * *") +// fmt.Println(sched.Next(time.Now()).Format(time.RFC3339)) +// // Output: 2022-04-04T14:30:00Z +func Daily(raw string) (*Schedule, error) { + if err := validateDailySpec(raw); err != nil { + return nil, xerrors.Errorf("validate daily schedule: %w", err) + } + + return parse(raw) +} + +func parse(raw string) (*Schedule, error) { // If schedule does not specify a timezone, default to UTC. Otherwise, // the library will default to time.Local which we want to avoid. if !strings.HasPrefix(raw, "CRON_TZ=") { @@ -187,7 +217,23 @@ func validateWeeklySpec(spec string) error { parts = parts[1:] } if parts[2] != "*" || parts[3] != "*" { - return xerrors.Errorf("expected month and dom to be *") + return xerrors.Errorf("expected day-of-month and month to be *") + } + return nil +} + +// validateDailySpec ensures that the day-of-month, month and day-of-week +// options of spec are all set to * +func validateDailySpec(spec string) error { + parts := strings.Fields(spec) + if len(parts) < 5 { + return xerrors.Errorf("expected schedule to consist of 5 fields with an optional CRON_TZ= prefix") + } + if len(parts) == 6 { + parts = parts[1:] + } + if parts[2] != "*" || parts[3] != "*" || parts[4] != "*" { + return xerrors.Errorf("expected day-of-month, month and day-of-week to be *") } return nil } diff --git a/coderd/schedule/cron_test.go b/coderd/schedule/cron_test.go index 9437cff4eed04..d09feb5578b20 100644 --- a/coderd/schedule/cron_test.go +++ b/coderd/schedule/cron_test.go @@ -129,14 +129,14 @@ func Test_Weekly(t *testing.T) { spec: "30 9 1 1 1-5", at: time.Time{}, expectedNext: time.Time{}, - expectedError: "validate weekly schedule: expected month and dom to be *", + expectedError: "validate weekly schedule: expected day-of-month and month to be *", }, { name: "valid schedule with 5 fields and timezone but month and dom not set to *", spec: "CRON_TZ=Europe/Dublin 30 9 1 1 1-5", at: time.Time{}, expectedNext: time.Time{}, - expectedError: "validate weekly schedule: expected month and dom to be *", + expectedError: "validate weekly schedule: expected day-of-month and month to be *", }, } diff --git a/coderd/schedule/template.go b/coderd/schedule/template.go index 12e87aac16527..0876a49e7a01a 100644 --- a/coderd/schedule/template.go +++ b/coderd/schedule/template.go @@ -18,11 +18,14 @@ type TemplateScheduleOptions struct { // // If set, users cannot disable automatic workspace shutdown. MaxTTL time.Duration `json:"max_ttl"` - // FailureTTL dictates the duration after which failed workspaces will be stopped automatically. + // FailureTTL dictates the duration after which failed workspaces will be + // stopped automatically. FailureTTL time.Duration `json:"failure_ttl"` - // InactivityTTL dictates the duration after which inactive workspaces will be locked. + // InactivityTTL dictates the duration after which inactive workspaces will + // be locked. InactivityTTL time.Duration `json:"inactivity_ttl"` - // LockedTTL dictates the duration after which locked workspaces will be permanently deleted. + // LockedTTL dictates the duration after which locked workspaces will be + // permanently deleted. LockedTTL time.Duration `json:"locked_ttl"` } @@ -53,8 +56,8 @@ func (*agplTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.Context UserAutostartEnabled: true, UserAutostopEnabled: true, DefaultTTL: time.Duration(tpl.DefaultTTL), - // Disregard the values in the database, since MaxTTL, FailureTTL, InactivityTTL, and LockedTTL are enterprise - // features. + // Disregard the values in the database, since MaxTTL, FailureTTL, + // InactivityTTL, and LockedTTL are enterprise features. MaxTTL: 0, FailureTTL: 0, InactivityTTL: 0, @@ -72,8 +75,8 @@ func (*agplTemplateScheduleStore) SetTemplateScheduleOptions(ctx context.Context ID: tpl.ID, UpdatedAt: database.Now(), DefaultTTL: int64(opts.DefaultTTL), - // Don't allow changing it, but keep the value in the DB (to avoid - // clearing settings if the license has an issue). + // Don't allow changing these settings, but keep the value in the DB (to + // avoid clearing settings if the license has an issue). AllowUserAutostart: tpl.AllowUserAutostart, AllowUserAutostop: tpl.AllowUserAutostop, MaxTTL: tpl.MaxTTL, diff --git a/coderd/schedule/user.go b/coderd/schedule/user.go new file mode 100644 index 0000000000000..4dc11880f90ed --- /dev/null +++ b/coderd/schedule/user.go @@ -0,0 +1,60 @@ +package schedule + +import ( + "context" + + "github.com/google/uuid" + + "github.com/coder/coder/coderd/database" +) + +type UserMaintenanceScheduleOptions struct { + // Schedule is the cron schedule to use for maintenance windows for all + // workspaces owned by the user. + // + // This value will be set to the parsed custom schedule of the user. If the + // user doesn't have a custom schedule set, it will be set to the default + // schedule (and UserSet will be false). If maintenance schedules are not + // entitled or disabled instance-wide, this value will be nil to denote that + // maintenance windows should not be used. + Schedule *Schedule + UserSet bool +} + +type UserMaintenanceScheduleStore interface { + // GetUserMaintenanceScheduleOptions retrieves the maintenance schedule for + // the given user. If the user has not set a custom schedule, the default + // schedule will be returned. If maintenance schedules are not entitled or + // disabled instance-wide, this will return a nil schedule. + GetUserMaintenanceScheduleOptions(ctx context.Context, db database.Store, userID uuid.UUID) (UserMaintenanceScheduleOptions, error) + // SetUserMaintenanceScheduleOptions sets the maintenance schedule for the + // given user. If the given schedule is an empty string, the user's custom schedule will + // be cleared and the default schedule will be used from now on. If + // maintenance schedules are not entitled or disabled instance-wide, this + // will do nothing and return a nil schedule. + SetUserMaintenanceScheduleOptions(ctx context.Context, db database.Store, userID uuid.UUID, rawSchedule string) (UserMaintenanceScheduleOptions, error) +} + +type agplUserMaintenanceScheduleStore struct{} + +var _ UserMaintenanceScheduleStore = &agplUserMaintenanceScheduleStore{} + +func NewAGPLUserMaintenanceScheduleStore() UserMaintenanceScheduleStore { + return &agplUserMaintenanceScheduleStore{} +} + +func (*agplUserMaintenanceScheduleStore) GetUserMaintenanceScheduleOptions(_ context.Context, _ database.Store, _ uuid.UUID) (UserMaintenanceScheduleOptions, error) { + // User maintenance windows are not supported in AGPL. + return UserMaintenanceScheduleOptions{ + Schedule: nil, + UserSet: false, + }, nil +} + +func (*agplUserMaintenanceScheduleStore) SetUserMaintenanceScheduleOptions(_ context.Context, _ database.Store, _ uuid.UUID, _ string) (UserMaintenanceScheduleOptions, error) { + // User maintenance windows are not supported in AGPL. + return UserMaintenanceScheduleOptions{ + Schedule: nil, + UserSet: false, + }, nil +} diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 6d293bde5f317..41da1dc07a9db 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -45,6 +45,7 @@ const ( FeatureExternalProvisionerDaemons FeatureName = "external_provisioner_daemons" FeatureAppearance FeatureName = "appearance" FeatureAdvancedTemplateScheduling FeatureName = "advanced_template_scheduling" + FeatureUserMaintenanceSchedule FeatureName = "user_maintenance_schedule" FeatureWorkspaceProxy FeatureName = "workspace_proxy" ) @@ -60,6 +61,7 @@ var FeatureNames = []FeatureName{ FeatureExternalProvisionerDaemons, FeatureAppearance, FeatureAdvancedTemplateScheduling, + FeatureUserMaintenanceSchedule, FeatureWorkspaceProxy, } diff --git a/codersdk/users.go b/codersdk/users.go index 05838a792c370..985ab73b0b749 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -84,6 +84,36 @@ type UpdateUserPasswordRequest struct { Password string `json:"password" validate:"required"` } +type UserMaintenanceScheduleResponse struct { + RawSchedule string `json:"raw_schedule"` + // UserSet is true if the user has set their own maintenance schedule. If + // false, the user is using the default schedule. + UserSet bool `json:"user_set"` + // Time is the time of day that the maintenance window starts in the given + // Timezone each day. + Time string `json:"time"` // HH:mm (24-hour) + Timezone string `json:"timezone"` // raw format from the cron expression, UTC if unspecified + // Duration is the duration of the maintenance window. + Duration time.Duration `json:"duration"` + // Next is the next time that the maintenance window will start. + Next time.Time `json:"next" format:"date-time"` +} + +type UpdateUserMaintenanceScheduleRequest struct { + // Schedule is a cron expression that defines when the user's maintenance + // window is. Schedule must not be empty. For new users, the schedule is set + // to 2am in their browser or computer's timezone. The schedule denotes the + // beginning of a 4 hour window where the workspace is allowed to + // automatically stop or restart due to maintenance or template max TTL. + // + // The schedule must be daily with a single time, and should have a timezone + // specified via a CRON_TZ prefix (otherwise UTC will be used). + // + // If the schedule is empty, the user will be updated to use the default + // schedule. + Schedule string `json:"schedule" validate:"required"` +} + type UpdateRoles struct { Roles []string `json:"roles" validate:""` } diff --git a/docs/admin/audit-logs.md b/docs/admin/audit-logs.md index 437354b240eff..af1f8e3bdf0de 100644 --- a/docs/admin/audit-logs.md +++ b/docs/admin/audit-logs.md @@ -17,7 +17,7 @@ We track the following resources: | License
create, delete |
FieldTracked
exptrue
idfalse
jwtfalse
uploaded_attrue
uuidtrue
| | Template
write, delete |
FieldTracked
active_version_idtrue
allow_user_autostarttrue
allow_user_autostoptrue
allow_user_cancel_workspace_jobstrue
created_atfalse
created_bytrue
default_ttltrue
deletedfalse
descriptiontrue
display_nametrue
failure_ttltrue
group_acltrue
icontrue
idtrue
inactivity_ttltrue
locked_ttltrue
max_ttltrue
nametrue
organization_idfalse
provisionertrue
updated_atfalse
user_acltrue
| | TemplateVersion
create, write |
FieldTracked
created_atfalse
created_bytrue
git_auth_providersfalse
idtrue
job_idfalse
nametrue
organization_idfalse
readmetrue
template_idtrue
updated_atfalse
| -| User
create, write, delete |
FieldTracked
avatar_urlfalse
created_atfalse
deletedtrue
emailtrue
hashed_passwordtrue
idtrue
last_seen_atfalse
login_typefalse
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| +| User
create, write, delete |
FieldTracked
avatar_urlfalse
created_atfalse
deletedtrue
emailtrue
hashed_passwordtrue
idtrue
last_seen_atfalse
login_typefalse
maintenance_scheduletrue
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| | Workspace
create, write, delete |
FieldTracked
autostart_scheduletrue
created_atfalse
deletedfalse
idtrue
last_used_atfalse
nametrue
organization_idfalse
owner_idtrue
template_idtrue
ttltrue
updated_atfalse
| | WorkspaceBuild
start, stop |
FieldTracked
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
idfalse
initiator_idfalse
job_idfalse
max_deadlinefalse
provisioner_statefalse
reasonfalse
template_version_idtrue
transitionfalse
updated_atfalse
workspace_idfalse
| | WorkspaceProxy
|
FieldTracked
created_attrue
deletedfalse
display_nametrue
icontrue
idtrue
nametrue
token_hashed_secrettrue
updated_atfalse
urltrue
wildcard_hostnametrue
| diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index ed1eabf115247..b6a90c36c06b6 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -92,18 +92,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": ActionIgnore, - "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": ActionIgnore, + "avatar_url": ActionIgnore, + "last_seen_at": ActionIgnore, + "deleted": ActionTrack, + "maintenance_schedule": ActionTrack, }, &database.Workspace{}: { "id": ActionTrack, diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 2cc54bd3fa705..9d077f8d132b5 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -21,10 +21,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" @@ -198,6 +199,16 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { r.Get("/", api.appearance) r.Put("/", api.putAppearance) }) + r.Route("/users/{user}/maintenance-schedule", func(r chi.Router) { + r.Use( + // TODO: enabled MW? + apiKeyMiddleware, + httpmw.ExtractUserParam(options.Database, false), + ) + + r.Get("/", api.userMaintenanceSchedule) + r.Put("/", api.putUserMaintenanceSchedule) + }) }) if len(options.SCIMAPIKey) != 0 { @@ -343,6 +354,7 @@ func (api *API) updateEntitlements(ctx context.Context) error { codersdk.FeatureTemplateRBAC: api.RBAC, codersdk.FeatureExternalProvisionerDaemons: true, codersdk.FeatureAdvancedTemplateScheduling: true, + codersdk.FeatureUserMaintenanceSchedule: true, codersdk.FeatureWorkspaceProxy: true, }) if err != nil { @@ -402,15 +414,25 @@ func (api *API) updateEntitlements(ctx context.Context) error { if changed, enabled := featureChanged(codersdk.FeatureAdvancedTemplateScheduling); changed { if enabled { - store := &enterpriseTemplateScheduleStore{} - ptr := schedule.TemplateScheduleStore(store) - api.AGPL.TemplateScheduleStore.Store(&ptr) + store := schedule.NewEnterpriseTemplateScheduleStore() + api.AGPL.TemplateScheduleStore.Store(&store) } else { - store := schedule.NewAGPLTemplateScheduleStore() + store := agplschedule.NewAGPLTemplateScheduleStore() api.AGPL.TemplateScheduleStore.Store(&store) } } + if changed, enabled := featureChanged(codersdk.FeatureUserMaintenanceSchedule); changed { + if enabled { + // TODO: configurable default schedule + store := schedule.NewEnterpriseUserMaintenanceScheduleStore("CRON_TZ=UTC 0 0 * * *") + api.AGPL.UserMaintenanceScheduleStore.Store(&store) + } else { + store := agplschedule.NewAGPLUserMaintenanceScheduleStore() + api.AGPL.UserMaintenanceScheduleStore.Store(&store) + } + } + if changed, enabled := featureChanged(codersdk.FeatureHighAvailability); changed { coordinator := agpltailnet.NewCoordinator(api.Logger) if enabled { diff --git a/enterprise/coderd/coderd_test.go b/enterprise/coderd/coderd_test.go index 27aa2cb4c33eb..c12c7f9e9c9b8 100644 --- a/enterprise/coderd/coderd_test.go +++ b/enterprise/coderd/coderd_test.go @@ -53,6 +53,7 @@ func TestEntitlements(t *testing.T) { codersdk.FeatureTemplateRBAC: 1, codersdk.FeatureExternalProvisionerDaemons: 1, codersdk.FeatureAdvancedTemplateScheduling: 1, + codersdk.FeatureUserMaintenanceSchedule: 1, codersdk.FeatureWorkspaceProxy: 1, }, GraceAt: time.Now().Add(59 * 24 * time.Hour), diff --git a/enterprise/coderd/provisionerdaemons.go b/enterprise/coderd/provisionerdaemons.go index 36b98cde73004..f7fd3ce372b5e 100644 --- a/enterprise/coderd/provisionerdaemons.go +++ b/enterprise/coderd/provisionerdaemons.go @@ -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, + UserMaintenanceScheduleStore: api.AGPL.UserMaintenanceScheduleStore, + 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,74 +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 - } - - template, 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 database.Template{}, 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: template.ID, - TemplateMaxTTL: int64(opts.MaxTTL), - }) - if err != nil { - return database.Template{}, xerrors.Errorf("update TTL of all workspaces on template to be within new template max TTL: %w", err) - } - } - - return template, nil -} diff --git a/enterprise/coderd/schedule/template.go b/enterprise/coderd/schedule/template.go new file mode 100644 index 0000000000000..4be3087144e02 --- /dev/null +++ b/enterprise/coderd/schedule/template.go @@ -0,0 +1,91 @@ +package schedule + +import ( + "context" + "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{} + +var _ agpl.TemplateScheduleStore = &EnterpriseTemplateScheduleStore{} + +func NewEnterpriseTemplateScheduleStore() agpl.TemplateScheduleStore { + return &EnterpriseTemplateScheduleStore{} +} + +// GetTemplateScheduleOptions implements agpl.TemplateScheduleStore. +func (*EnterpriseTemplateScheduleStore) GetTemplateScheduleOptions(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 + } + + return agpl.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 +} + +// SetTemplateScheduleOptions implements agpl.TemplateScheduleStore. +func (*EnterpriseTemplateScheduleStore) SetTemplateScheduleOptions(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 && + 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 + } + + template, 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 database.Template{}, 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.max_ttl != 0) { + // workspace.max_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: template.ID, + TemplateMaxTTL: int64(opts.MaxTTL), + }) + if err != nil { + return database.Template{}, xerrors.Errorf("update TTL of all workspaces on template to be within new template max TTL: %w", err) + } + } + + return template, nil +} diff --git a/enterprise/coderd/schedule/user.go b/enterprise/coderd/schedule/user.go new file mode 100644 index 0000000000000..1f94bd08e8816 --- /dev/null +++ b/enterprise/coderd/schedule/user.go @@ -0,0 +1,81 @@ +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" +) + +// enterpriseUserMaintenanceScheduleStore provides an +// agpl.UserMaintenanceScheduleStore that has all fields implemented for +// enterprise customers. +type enterpriseUserMaintenanceScheduleStore struct { + defaultSchedule string +} + +var _ agpl.UserMaintenanceScheduleStore = &enterpriseUserMaintenanceScheduleStore{} + +func NewEnterpriseUserMaintenanceScheduleStore(defaultSchedule string) agpl.UserMaintenanceScheduleStore { + return &enterpriseUserMaintenanceScheduleStore{ + defaultSchedule: defaultSchedule, + } +} + +func (s *enterpriseUserMaintenanceScheduleStore) parseSchedule(rawSchedule string) (agpl.UserMaintenanceScheduleOptions, 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.UserMaintenanceScheduleOptions{}, xerrors.Errorf("parse daily schedule %q: %w", rawSchedule, err) + } + if strings.HasPrefix(sched.Time(), "cron(") { + // This shouldn't get hit during Gets, only Sets. + return agpl.UserMaintenanceScheduleOptions{}, xerrors.Errorf("daily schedule %q has more than one time: %v", rawSchedule, sched.Time()) + } + + return agpl.UserMaintenanceScheduleOptions{ + Schedule: sched, + UserSet: userSet, + }, nil +} + +func (s *enterpriseUserMaintenanceScheduleStore) GetUserMaintenanceScheduleOptions(ctx context.Context, db database.Store, userID uuid.UUID) (agpl.UserMaintenanceScheduleOptions, error) { + user, err := db.GetUserByID(ctx, userID) + if err != nil { + return agpl.UserMaintenanceScheduleOptions{}, xerrors.Errorf("get user by ID: %w", err) + } + + return s.parseSchedule(user.MaintenanceSchedule) +} + +func (s *enterpriseUserMaintenanceScheduleStore) SetUserMaintenanceScheduleOptions(ctx context.Context, db database.Store, userID uuid.UUID, rawSchedule string) (agpl.UserMaintenanceScheduleOptions, 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.UpdateUserMaintenanceSchedule(ctx, database.UpdateUserMaintenanceScheduleParams{ + ID: userID, + MaintenanceSchedule: rawSchedule, + }) + if err != nil { + return agpl.UserMaintenanceScheduleOptions{}, xerrors.Errorf("update user maintenance schedule: %w", err) + } + + return opts, nil +} diff --git a/enterprise/coderd/users.go b/enterprise/coderd/users.go new file mode 100644 index 0000000000000..500351c6cbeab --- /dev/null +++ b/enterprise/coderd/users.go @@ -0,0 +1,99 @@ +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" +) + +// TODO: move this constant +const userMaintenanceWindowDuration = 4 * time.Hour + +// @Summary Get user maintenance schedule +// @ID get-user-maintenance-schedule +// @Security CoderSessionToken +// @Produce json +// @Tags Enterprise +// @Param user path string true "User ID" format(uuid) +// @Success 200 {array} codersdk.UserMaintenanceScheduleResponse +// @Router /users/{user}/maintenance-schedule [get] +func (api *API) userMaintenanceSchedule(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + user = httpmw.UserParam(r) + ) + + // Double query here cuz of the user param + opts, err := (*api.UserMaintenanceScheduleStore.Load()).GetUserMaintenanceScheduleOptions(ctx, api.Database, user.ID) + if err != nil { + // TODO: some of these errors are related to bad syntax, would be nice + // to 400 + httpapi.InternalServerError(rw, err) + return + } + if opts.Schedule == nil { + httpapi.ResourceNotFound(rw) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, codersdk.UserMaintenanceScheduleResponse{ + RawSchedule: opts.Schedule.String(), + UserSet: opts.UserSet, + Time: opts.Schedule.Time(), + Timezone: opts.Schedule.Location().String(), + Duration: userMaintenanceWindowDuration, + Next: opts.Schedule.Next(time.Now()), + }) +} + +// @Summary Update user maintenance schedule +// @ID update-user-maintenance-schedule +// @Security CoderSessionToken +// @Accept json +// @Produce json +// @Tags Enterprise +// @Param user path string true "User ID" format(uuid) +// @Param request body codersdk.UpdateUserMaintenanceScheduleRequest true "Update schedule request" +// @Success 200 {array} codersdk.UserMaintenanceScheduleResponse +// @Router /users/{user}/maintenance-schedule [put] +func (api *API) putUserMaintenanceSchedule(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + user = httpmw.UserParam(r) + params codersdk.UpdateUserMaintenanceScheduleRequest + 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.UserMaintenanceScheduleStore.Load()).SetUserMaintenanceScheduleOptions(ctx, api.Database, user.ID, params.Schedule) + if err != nil { + // TODO: some of these errors are related to bad syntax, would be nice + // to 400 + httpapi.InternalServerError(rw, err) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, codersdk.UserMaintenanceScheduleResponse{ + RawSchedule: opts.Schedule.String(), + UserSet: opts.UserSet, + Time: opts.Schedule.Time(), + Timezone: opts.Schedule.Location().String(), + Duration: userMaintenanceWindowDuration, + Next: opts.Schedule.Next(time.Now()), + }) +} diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index d8fa671603378..3f6d7d7a3dce0 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -977,6 +977,11 @@ export interface UpdateTemplateMeta { readonly locked_ttl_ms?: number } +// From codersdk/users.go +export interface UpdateUserMaintenanceScheduleRequest { + readonly schedule: string +} + // From codersdk/users.go export interface UpdateUserPasswordRequest { readonly old_password: string @@ -1027,6 +1032,17 @@ export interface User { readonly avatar_url: string } +// From codersdk/users.go +export interface UserMaintenanceScheduleResponse { + readonly raw_schedule: string + readonly user_set: boolean + readonly time: string + readonly timezone: string + // This is likely an enum in an external package ("time.Duration") + readonly duration: number + readonly next: string +} + // From codersdk/users.go export interface UserRoles { readonly roles: string[] @@ -1361,6 +1377,7 @@ export type FeatureName = | "scim" | "template_rbac" | "user_limit" + | "user_maintenance_schedule" | "workspace_proxy" export const FeatureNames: FeatureName[] = [ "advanced_template_scheduling", @@ -1373,6 +1390,7 @@ export const FeatureNames: FeatureName[] = [ "scim", "template_rbac", "user_limit", + "user_maintenance_schedule", "workspace_proxy", ] From 92c05b366702344d3b12d20309e0cc7084146d2e Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Tue, 20 Jun 2023 20:41:32 +0000 Subject: [PATCH 02/25] fixup! feat: add user maintenance schedule for max_ttl autostop --- coderd/schedule/user.go | 5 +++++ enterprise/coderd/schedule/user.go | 7 +++++++ enterprise/coderd/users.go | 7 ++----- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/coderd/schedule/user.go b/coderd/schedule/user.go index 4dc11880f90ed..98e6888d701d7 100644 --- a/coderd/schedule/user.go +++ b/coderd/schedule/user.go @@ -2,6 +2,7 @@ package schedule import ( "context" + "time" "github.com/google/uuid" @@ -19,6 +20,10 @@ type UserMaintenanceScheduleOptions struct { // maintenance windows should not be used. Schedule *Schedule UserSet bool + // Duration is the duration of the maintenance window starting when the cron + // triggers. Workspaces can be stopped for maintenance or due to max_ttl + // during this window. + Duration time.Duration } type UserMaintenanceScheduleStore interface { diff --git a/enterprise/coderd/schedule/user.go b/enterprise/coderd/schedule/user.go index 1f94bd08e8816..dfab317fa699f 100644 --- a/enterprise/coderd/schedule/user.go +++ b/enterprise/coderd/schedule/user.go @@ -3,6 +3,7 @@ package schedule import ( "context" "strings" + "time" "github.com/google/uuid" "golang.org/x/xerrors" @@ -11,6 +12,8 @@ import ( agpl "github.com/coder/coder/coderd/schedule" ) +const userMaintenanceWindowDuration = 4 * time.Hour + // enterpriseUserMaintenanceScheduleStore provides an // agpl.UserMaintenanceScheduleStore that has all fields implemented for // enterprise customers. @@ -46,6 +49,7 @@ func (s *enterpriseUserMaintenanceScheduleStore) parseSchedule(rawSchedule strin return agpl.UserMaintenanceScheduleOptions{ Schedule: sched, UserSet: userSet, + Duration: userMaintenanceWindowDuration, }, nil } @@ -77,5 +81,8 @@ func (s *enterpriseUserMaintenanceScheduleStore) SetUserMaintenanceScheduleOptio return agpl.UserMaintenanceScheduleOptions{}, xerrors.Errorf("update user maintenance schedule: %w", err) } + // TODO: update max_ttl for all active builds for this user to clamp to the + // new schedule. + return opts, nil } diff --git a/enterprise/coderd/users.go b/enterprise/coderd/users.go index 500351c6cbeab..383e0767b85ec 100644 --- a/enterprise/coderd/users.go +++ b/enterprise/coderd/users.go @@ -11,9 +11,6 @@ import ( "github.com/coder/coder/codersdk" ) -// TODO: move this constant -const userMaintenanceWindowDuration = 4 * time.Hour - // @Summary Get user maintenance schedule // @ID get-user-maintenance-schedule // @Security CoderSessionToken @@ -46,7 +43,7 @@ func (api *API) userMaintenanceSchedule(rw http.ResponseWriter, r *http.Request) UserSet: opts.UserSet, Time: opts.Schedule.Time(), Timezone: opts.Schedule.Location().String(), - Duration: userMaintenanceWindowDuration, + Duration: opts.Duration, Next: opts.Schedule.Next(time.Now()), }) } @@ -93,7 +90,7 @@ func (api *API) putUserMaintenanceSchedule(rw http.ResponseWriter, r *http.Reque UserSet: opts.UserSet, Time: opts.Schedule.Time(), Timezone: opts.Schedule.Location().String(), - Duration: userMaintenanceWindowDuration, + Duration: opts.Duration, Next: opts.Schedule.Next(time.Now()), }) } From 54d939adb2e3213a8da101342bd4bc2b31ee85a7 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Wed, 21 Jun 2023 16:51:23 +0000 Subject: [PATCH 03/25] fixup! feat: add user maintenance schedule for max_ttl autostop --- .../provisionerdserver/provisionerdserver.go | 32 ++++++++++++++++++- coderd/schedule/user.go | 2 ++ codersdk/deployment.go | 31 ++++++++++++++++++ enterprise/cli/server.go | 18 ++++++----- enterprise/coderd/coderd.go | 21 +++++++++--- enterprise/coderd/schedule/user.go | 24 +++++++++++--- enterprise/coderd/users.go | 2 +- 7 files changed, 110 insertions(+), 20 deletions(-) diff --git a/coderd/provisionerdserver/provisionerdserver.go b/coderd/provisionerdserver/provisionerdserver.go index 4d9ef143ebece..d3944aa034023 100644 --- a/coderd/provisionerdserver/provisionerdserver.go +++ b/coderd/provisionerdserver/provisionerdserver.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "math/rand" "net/http" "net/url" "reflect" @@ -950,7 +951,36 @@ func (server *Server) CompleteJob(ctx context.Context, completed *proto.Complete // Round the max deadline up to the nearest occurrence of the // user's maintenance schedule. This ensures that workspaces // can't be force-stopped due to max TTL during business hours. - maxDeadline = userMaintenanceSchedule.Schedule.Next(maxDeadline) + + // Get the schedule occurrence that happens right before, during + // or after the max deadline. + // TODO: change to the maintenance window BEFORE max TTL + scheduleDur := userMaintenanceSchedule.Duration + if scheduleDur > 1*time.Hour { + // Allow a 15 minute buffer when possible so we're not too + // constrained with the autostop time. + scheduleDur -= 15 * time.Minute + } + windowStart := userMaintenanceSchedule.Schedule.Next(maxDeadline.Add(scheduleDur)) + + // Get the window of time that the workspace can be stopped in. + // This must be between windowStart and windowEnd, and also must + // be after the current max deadline. + minTime := maxDeadline + if windowStart.After(minTime) { + minTime = windowStart + } + maxTime := windowStart.Add(scheduleDur) + if minTime.After(maxTime) { + // TODO: remove this panic once we have good tests, and add + // a sensible fallback instead + panic("minTime is after maxTime") + } + + // Pick a random time between minTime and maxTime. + actualDur := maxTime.Sub(minTime) + jitter := time.Duration(rand.Int63n(int64(actualDur))) + maxDeadline = minTime.Add(jitter) } err = db.UpdateProvisionerJobWithCompleteByID(ctx, database.UpdateProvisionerJobWithCompleteByIDParams{ diff --git a/coderd/schedule/user.go b/coderd/schedule/user.go index 98e6888d701d7..3e4c73bb0109c 100644 --- a/coderd/schedule/user.go +++ b/coderd/schedule/user.go @@ -53,6 +53,7 @@ func (*agplUserMaintenanceScheduleStore) GetUserMaintenanceScheduleOptions(_ con return UserMaintenanceScheduleOptions{ Schedule: nil, UserSet: false, + Duration: 0, }, nil } @@ -61,5 +62,6 @@ func (*agplUserMaintenanceScheduleStore) SetUserMaintenanceScheduleOptions(_ con return UserMaintenanceScheduleOptions{ Schedule: nil, UserSet: false, + Duration: 0, }, nil } diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 41da1dc07a9db..fd2384e502dc4 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -166,6 +166,7 @@ type DeploymentValues struct { WgtunnelHost clibase.String `json:"wgtunnel_host,omitempty" typescript:",notnull"` DisableOwnerWorkspaceExec clibase.Bool `json:"disable_owner_workspace_exec,omitempty" typescript:",notnull"` ProxyHealthStatusInterval clibase.Duration `json:"proxy_health_status_interval,omitempty" typescript:",notnull"` + UserMaintenanceSchedule UserMaintenanceScheduleConfig `json:"user_maintenance_schedule,omitempty" typescript:",notnull"` Config clibase.YAMLConfigPath `json:"config,omitempty" typescript:",notnull"` WriteConfig clibase.Bool `json:"write_config,omitempty" typescript:",notnull"` @@ -337,6 +338,11 @@ type DangerousConfig struct { AllowAllCors clibase.Bool `json:"allow_all_cors" typescript:",notnull"` } +type UserMaintenanceScheduleConfig struct { + DefaultSchedule clibase.String `json:"default_schedule" typescript:",notnull"` + WindowDuration clibase.Duration `json:"window_duration" typescript:",notnull"` +} + const ( annotationEnterpriseKey = "enterprise" annotationSecretKey = "secret" @@ -460,6 +466,11 @@ when required by your organization's security policy.`, Description: `Tune the behavior of the provisioner, which is responsible for creating, updating, and deleting workspace resources.`, YAML: "provisioning", } + deploymentGroupUserMaintenanceSchedule = clibase.Group{ + Name: "User Maintenance Schedule", + Description: "Allow users to set maintenance schedules each day for workspaces to avoid workspaces stopping during the day due to template max TTL.", + YAML: "user-maintenance-schedule", + } deploymentGroupDangerous = clibase.Group{ Name: "⚠️ Dangerous", YAML: "dangerous", @@ -1521,6 +1532,26 @@ Write out the current server config as YAML to stdout.`, Group: &deploymentGroupNetworkingHTTP, YAML: "proxyHealthInterval", }, + { + Name: "Default Maintenance Schedule", + Description: "The default daily cron schedule applied to users that haven't set a custom maintenance schedule themselves. The maintenance 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 maintenance 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).", + Flag: "default-maintenance-schedule", + Env: "CODER_MAINTENANCE_DEFAULT_SCHEDULE", + Default: "", + Value: &c.UserMaintenanceSchedule.DefaultSchedule, + Group: &deploymentGroupUserMaintenanceSchedule, + YAML: "defaultMaintenanceSchedule", + }, + { + Name: "Maintenance Window Duration", + Description: "The duration of maintenance windows when triggered by cron. Workspaces can only be stopped due to max TTL during this window. Must be at least 1 hour.", + Flag: "maintenance-window-duration", + Env: "CODER_MAINTENANCE_WINDOW_DURATION", + Default: (4 * time.Hour).String(), + Value: &c.UserMaintenanceSchedule.DefaultSchedule, + Group: &deploymentGroupUserMaintenanceSchedule, + YAML: "maintenanceWindowDuration", + }, } return opts } diff --git a/enterprise/cli/server.go b/enterprise/cli/server.go index 215e9026cfb8b..bd20de97c82db 100644 --- a/enterprise/cli/server.go +++ b/enterprise/cli/server.go @@ -57,14 +57,16 @@ 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(), + DefaultUserMaintenanceSchedule: options.DeploymentValues.UserMaintenanceSchedule.DefaultSchedule.Value(), + UserMaintenanceWindowDuration: options.DeploymentValues.UserMaintenanceSchedule.WindowDuration.Value(), } api, err := coderd.New(ctx, o) diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 9d077f8d132b5..5758f70afd43f 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -52,6 +52,10 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { if options.Options.Authorizer == nil { options.Options.Authorizer = rbac.NewCachingAuthorizer(options.PrometheusRegistry) } + if options.UserMaintenanceWindowDuration < time.Hour { + return nil, xerrors.Errorf("user maintenance window duration must be at least 1 hour") + } + ctx, cancelFunc := context.WithCancel(ctx) api := &API{ ctx: ctx, @@ -303,6 +307,10 @@ type Options struct { DERPServerRelayAddress string DERPServerRegionID int + // Used for user maintenance schedules. + DefaultUserMaintenanceSchedule string // cron schedule, if empty user maintenance schedules are disabled + UserMaintenanceWindowDuration time.Duration // how long each window should last + EntitlementsUpdateInterval time.Duration ProxyHealthInterval time.Duration Keys map[string]ed25519.PublicKey @@ -354,7 +362,7 @@ func (api *API) updateEntitlements(ctx context.Context) error { codersdk.FeatureTemplateRBAC: api.RBAC, codersdk.FeatureExternalProvisionerDaemons: true, codersdk.FeatureAdvancedTemplateScheduling: true, - codersdk.FeatureUserMaintenanceSchedule: true, + codersdk.FeatureUserMaintenanceSchedule: api.DefaultUserMaintenanceSchedule != "", codersdk.FeatureWorkspaceProxy: true, }) if err != nil { @@ -423,10 +431,13 @@ func (api *API) updateEntitlements(ctx context.Context) error { } if changed, enabled := featureChanged(codersdk.FeatureUserMaintenanceSchedule); changed { - if enabled { - // TODO: configurable default schedule - store := schedule.NewEnterpriseUserMaintenanceScheduleStore("CRON_TZ=UTC 0 0 * * *") - api.AGPL.UserMaintenanceScheduleStore.Store(&store) + if enabled && api.DefaultUserMaintenanceSchedule != "" { + store, err := schedule.NewEnterpriseUserMaintenanceScheduleStore(api.DefaultUserMaintenanceSchedule, api.UserMaintenanceWindowDuration) + if err != nil { + api.Logger.Error(ctx, "unable to set up enterprise user maintenance schedule store, maintenance schedules will not be applied", slog.Error(err)) + } else { + api.AGPL.UserMaintenanceScheduleStore.Store(&store) + } } else { store := agplschedule.NewAGPLUserMaintenanceScheduleStore() api.AGPL.UserMaintenanceScheduleStore.Store(&store) diff --git a/enterprise/coderd/schedule/user.go b/enterprise/coderd/schedule/user.go index dfab317fa699f..99f44fdd4e108 100644 --- a/enterprise/coderd/schedule/user.go +++ b/enterprise/coderd/schedule/user.go @@ -12,21 +12,35 @@ import ( agpl "github.com/coder/coder/coderd/schedule" ) -const userMaintenanceWindowDuration = 4 * time.Hour - // enterpriseUserMaintenanceScheduleStore provides an // agpl.UserMaintenanceScheduleStore that has all fields implemented for // enterprise customers. type enterpriseUserMaintenanceScheduleStore struct { defaultSchedule string + windowDuration time.Duration } var _ agpl.UserMaintenanceScheduleStore = &enterpriseUserMaintenanceScheduleStore{} -func NewEnterpriseUserMaintenanceScheduleStore(defaultSchedule string) agpl.UserMaintenanceScheduleStore { - return &enterpriseUserMaintenanceScheduleStore{ +func NewEnterpriseUserMaintenanceScheduleStore(defaultSchedule string, windowDuration time.Duration) (agpl.UserMaintenanceScheduleStore, error) { + if defaultSchedule == "" { + return nil, xerrors.Errorf("default schedule must be set") + } + if windowDuration < 1*time.Hour { + return nil, xerrors.Errorf("window duration must be greater than 1 hour") + } + + s := &enterpriseUserMaintenanceScheduleStore{ defaultSchedule: defaultSchedule, + windowDuration: windowDuration, } + + _, err := s.parseSchedule(defaultSchedule) + if err != nil { + return nil, xerrors.Errorf("parse default schedule: %w", err) + } + + return s, nil } func (s *enterpriseUserMaintenanceScheduleStore) parseSchedule(rawSchedule string) (agpl.UserMaintenanceScheduleOptions, error) { @@ -49,7 +63,7 @@ func (s *enterpriseUserMaintenanceScheduleStore) parseSchedule(rawSchedule strin return agpl.UserMaintenanceScheduleOptions{ Schedule: sched, UserSet: userSet, - Duration: userMaintenanceWindowDuration, + Duration: s.windowDuration, }, nil } diff --git a/enterprise/coderd/users.go b/enterprise/coderd/users.go index 383e0767b85ec..475a297b0dd02 100644 --- a/enterprise/coderd/users.go +++ b/enterprise/coderd/users.go @@ -25,7 +25,7 @@ func (api *API) userMaintenanceSchedule(rw http.ResponseWriter, r *http.Request) user = httpmw.UserParam(r) ) - // Double query here cuz of the user param + // TODO: Double query here cuz of the user param opts, err := (*api.UserMaintenanceScheduleStore.Load()).GetUserMaintenanceScheduleOptions(ctx, api.Database, user.ID) if err != nil { // TODO: some of these errors are related to bad syntax, would be nice From b274c6704815b76574897bff6df86730becb39a6 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Wed, 28 Jun 2023 15:06:39 +0000 Subject: [PATCH 04/25] rename maintenance schedule to quiet hours schedule --- cli/server.go | 50 +++---- coderd/apidoc/docs.go | 141 ++++++++++++++++++ coderd/apidoc/swagger.json | 129 ++++++++++++++++ coderd/coderd.go | 76 +++++----- coderd/database/dbauthz/dbauthz.go | 6 +- coderd/database/dbfake/dbfake.go | 4 +- coderd/database/dbmetrics/dbmetrics.go | 6 +- coderd/database/dbmock/dbmock.go | 12 +- coderd/database/dump.sql | 2 +- .../000130_user_maintenance_schedule.down.sql | 1 - .../000130_user_quiet_hours_schedule.down.sql | 1 + ...> 000130_user_quiet_hours_schedule.up.sql} | 2 +- coderd/database/models.go | 26 ++-- coderd/database/querier.go | 2 +- coderd/database/queries.sql.go | 32 ++-- coderd/database/queries/users.sql | 4 +- .../provisionerdserver/provisionerdserver.go | 67 +++------ .../provisionerdserver_test.go | 58 +++---- coderd/schedule/user.go | 50 +++---- codersdk/deployment.go | 44 +++--- codersdk/users.go | 14 +- docs/admin/audit-logs.md | 2 +- docs/api/enterprise.md | 126 ++++++++++++++++ docs/api/general.md | 4 + docs/api/schemas.md | 66 ++++++++ docs/cli/server.md | 21 +++ enterprise/audit/table.go | 2 +- enterprise/cli/server.go | 20 +-- enterprise/coderd/coderd.go | 32 ++-- enterprise/coderd/coderd_test.go | 2 +- enterprise/coderd/provisionerdaemons.go | 30 ++-- enterprise/coderd/schedule/user.go | 36 ++--- enterprise/coderd/users.go | 32 ++-- site/src/api/typesGenerated.ts | 23 ++- 34 files changed, 799 insertions(+), 324 deletions(-) delete mode 100644 coderd/database/migrations/000130_user_maintenance_schedule.down.sql create mode 100644 coderd/database/migrations/000130_user_quiet_hours_schedule.down.sql rename coderd/database/migrations/{000130_user_maintenance_schedule.up.sql => 000130_user_quiet_hours_schedule.up.sql} (50%) diff --git a/cli/server.go b/cli/server.go index 21e1e31526628..793f13c5ec2da 100644 --- a/cli/server.go +++ b/cli/server.go @@ -458,31 +458,31 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. } options := &coderd.Options{ - AccessURL: cfg.AccessURL.Value(), - AppHostname: appHostname, - AppHostnameRegex: appHostnameRegex, - Logger: logger.Named("coderd"), - Database: dbfake.New(), - DERPMap: derpMap, - Pubsub: pubsub.NewInMemory(), - CacheDir: cacheDir, - GoogleTokenValidator: googleTokenValidator, - GitAuthConfigs: gitAuthConfigs, - RealIPConfig: realIPConfig, - SecureAuthCookie: cfg.SecureAuthCookie.Value(), - SSHKeygenAlgorithm: sshKeygenAlgorithm, - TracerProvider: tracerProvider, - Telemetry: telemetry.NewNoop(), - MetricsCacheRefreshInterval: cfg.MetricsCacheRefreshInterval.Value(), - AgentStatsRefreshInterval: cfg.AgentStatRefreshInterval.Value(), - DeploymentValues: cfg, - PrometheusRegistry: prometheus.NewRegistry(), - APIRateLimit: int(cfg.RateLimit.API.Value()), - LoginRateLimit: loginRateLimit, - FilesRateLimit: filesRateLimit, - HTTPClient: httpClient, - TemplateScheduleStore: &atomic.Pointer[schedule.TemplateScheduleStore]{}, - UserMaintenanceScheduleStore: &atomic.Pointer[schedule.UserMaintenanceScheduleStore]{}, + AccessURL: cfg.AccessURL.Value(), + AppHostname: appHostname, + AppHostnameRegex: appHostnameRegex, + Logger: logger.Named("coderd"), + Database: dbfake.New(), + DERPMap: derpMap, + Pubsub: pubsub.NewInMemory(), + CacheDir: cacheDir, + GoogleTokenValidator: googleTokenValidator, + GitAuthConfigs: gitAuthConfigs, + RealIPConfig: realIPConfig, + SecureAuthCookie: cfg.SecureAuthCookie.Value(), + SSHKeygenAlgorithm: sshKeygenAlgorithm, + TracerProvider: tracerProvider, + Telemetry: telemetry.NewNoop(), + MetricsCacheRefreshInterval: cfg.MetricsCacheRefreshInterval.Value(), + AgentStatsRefreshInterval: cfg.AgentStatRefreshInterval.Value(), + DeploymentValues: cfg, + PrometheusRegistry: prometheus.NewRegistry(), + APIRateLimit: int(cfg.RateLimit.API.Value()), + LoginRateLimit: loginRateLimit, + FilesRateLimit: filesRateLimit, + HTTPClient: httpClient, + TemplateScheduleStore: &atomic.Pointer[schedule.TemplateScheduleStore]{}, + UserQuietHoursScheduleStore: &atomic.Pointer[schedule.UserQuietHoursScheduleStore]{}, SSHConfig: codersdk.SSHConfigResponse{ HostnamePrefix: cfg.SSHConfig.DeploymentName.String(), SSHConfigOptions: configSSHOptions, diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index b39237432ef88..cef8f31297916 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -3652,6 +3652,92 @@ const docTemplate = `{ } } }, + "/users/{user}/quiet-hours-schedule": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Enterprise" + ], + "summary": "Get user quiet hours schedule", + "operationId": "get-user-quiet-hours-schedule", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "User ID", + "name": "user", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.UserQuietHoursScheduleResponse" + } + } + } + } + }, + "put": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Enterprise" + ], + "summary": "Update user quiet hours schedule", + "operationId": "update-user-quiet-hours-schedule", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "User ID", + "name": "user", + "in": "path", + "required": true + }, + { + "description": "Update schedule request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.UpdateUserQuietHoursScheduleRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.UserQuietHoursScheduleResponse" + } + } + } + } + } + }, "/users/{user}/roles": { "get": { "security": [ @@ -7292,6 +7378,9 @@ const docTemplate = `{ "update_check": { "type": "boolean" }, + "user_quiet_hours_schedule": { + "$ref": "#/definitions/codersdk.UserQuietHoursScheduleConfig" + }, "verbose": { "type": "boolean" }, @@ -8985,6 +9074,18 @@ const docTemplate = `{ } } }, + "codersdk.UpdateUserQuietHoursScheduleRequest": { + "type": "object", + "required": [ + "schedule" + ], + "properties": { + "schedule": { + "description": "Schedule is a cron expression that defines when the user's quiet hours\nwindow is. Schedule must not be empty. For new users, the schedule is set\nto 2am in their browser or computer's timezone. The schedule denotes the\nbeginning of a 4 hour window where the workspace is allowed to\nautomatically stop or restart due to maintenance or template max TTL.\n\nThe schedule must be daily with a single time, and should have a timezone\nspecified via a CRON_TZ prefix (otherwise UTC will be used).\n\nIf the schedule is empty, the user will be updated to use the default\nschedule.", + "type": "string" + } + } + }, "codersdk.UpdateWorkspaceAutostartRequest": { "type": "object", "properties": { @@ -9076,6 +9177,46 @@ const docTemplate = `{ } } }, + "codersdk.UserQuietHoursScheduleConfig": { + "type": "object", + "properties": { + "default_schedule": { + "type": "string" + }, + "window_duration": { + "type": "integer" + } + } + }, + "codersdk.UserQuietHoursScheduleResponse": { + "type": "object", + "properties": { + "duration": { + "description": "Duration is the duration of the quiet hours window.", + "type": "integer" + }, + "next": { + "description": "Next is the next time that the quiet hours window will start.", + "type": "string", + "format": "date-time" + }, + "raw_schedule": { + "type": "string" + }, + "time": { + "description": "Time is the time of day that the quiet hours window starts in the given\nTimezone each day.", + "type": "string" + }, + "timezone": { + "description": "raw format from the cron expression, UTC if unspecified", + "type": "string" + }, + "user_set": { + "description": "UserSet is true if the user has set their own quiet hours schedule. If\nfalse, the user is using the default schedule.", + "type": "boolean" + } + } + }, "codersdk.UserStatus": { "type": "string", "enum": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 2449c7fbb9937..9591df606d0d1 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -3216,6 +3216,82 @@ } } }, + "/users/{user}/quiet-hours-schedule": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Enterprise"], + "summary": "Get user quiet hours schedule", + "operationId": "get-user-quiet-hours-schedule", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "User ID", + "name": "user", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.UserQuietHoursScheduleResponse" + } + } + } + } + }, + "put": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Enterprise"], + "summary": "Update user quiet hours schedule", + "operationId": "update-user-quiet-hours-schedule", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "User ID", + "name": "user", + "in": "path", + "required": true + }, + { + "description": "Update schedule request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.UpdateUserQuietHoursScheduleRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.UserQuietHoursScheduleResponse" + } + } + } + } + } + }, "/users/{user}/roles": { "get": { "security": [ @@ -6525,6 +6601,9 @@ "update_check": { "type": "boolean" }, + "user_quiet_hours_schedule": { + "$ref": "#/definitions/codersdk.UserQuietHoursScheduleConfig" + }, "verbose": { "type": "boolean" }, @@ -8101,6 +8180,16 @@ } } }, + "codersdk.UpdateUserQuietHoursScheduleRequest": { + "type": "object", + "required": ["schedule"], + "properties": { + "schedule": { + "description": "Schedule is a cron expression that defines when the user's quiet hours\nwindow is. Schedule must not be empty. For new users, the schedule is set\nto 2am in their browser or computer's timezone. The schedule denotes the\nbeginning of a 4 hour window where the workspace is allowed to\nautomatically stop or restart due to maintenance or template max TTL.\n\nThe schedule must be daily with a single time, and should have a timezone\nspecified via a CRON_TZ prefix (otherwise UTC will be used).\n\nIf the schedule is empty, the user will be updated to use the default\nschedule.", + "type": "string" + } + } + }, "codersdk.UpdateWorkspaceAutostartRequest": { "type": "object", "properties": { @@ -8184,6 +8273,46 @@ } } }, + "codersdk.UserQuietHoursScheduleConfig": { + "type": "object", + "properties": { + "default_schedule": { + "type": "string" + }, + "window_duration": { + "type": "integer" + } + } + }, + "codersdk.UserQuietHoursScheduleResponse": { + "type": "object", + "properties": { + "duration": { + "description": "Duration is the duration of the quiet hours window.", + "type": "integer" + }, + "next": { + "description": "Next is the next time that the quiet hours window will start.", + "type": "string", + "format": "date-time" + }, + "raw_schedule": { + "type": "string" + }, + "time": { + "description": "Time is the time of day that the quiet hours window starts in the given\nTimezone each day.", + "type": "string" + }, + "timezone": { + "description": "raw format from the cron expression, UTC if unspecified", + "type": "string" + }, + "user_set": { + "description": "UserSet is true if the user has set their own quiet hours schedule. If\nfalse, the user is using the default schedule.", + "type": "boolean" + } + } + }, "codersdk.UserStatus": { "type": "string", "enum": ["active", "suspended"], diff --git a/coderd/coderd.go b/coderd/coderd.go index 9ffdfa2c3e3c7..40e34f27c8101 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -118,14 +118,14 @@ type Options struct { RealIPConfig *httpmw.RealIPConfig TrialGenerator func(ctx context.Context, email string) error // TLSCertificates is used to mesh DERP servers securely. - TLSCertificates []tls.Certificate - TailnetCoordinator tailnet.Coordinator - DERPServer *derp.Server - DERPMap *tailcfg.DERPMap - SwaggerEndpoint bool - SetUserGroups func(ctx context.Context, tx database.Store, userID uuid.UUID, groupNames []string) error - TemplateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore] - UserMaintenanceScheduleStore *atomic.Pointer[schedule.UserMaintenanceScheduleStore] + TLSCertificates []tls.Certificate + TailnetCoordinator tailnet.Coordinator + DERPServer *derp.Server + DERPMap *tailcfg.DERPMap + SwaggerEndpoint bool + SetUserGroups func(ctx context.Context, tx database.Store, userID uuid.UUID, groupNames []string) error + TemplateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore] + UserQuietHoursScheduleStore *atomic.Pointer[schedule.UserQuietHoursScheduleStore] // AppSecurityKey is the crypto key used to sign and encrypt tokens related to // workspace applications. It consists of both a signing and encryption key. AppSecurityKey workspaceapps.SecurityKey @@ -260,12 +260,12 @@ func New(options *Options) *API { v := schedule.NewAGPLTemplateScheduleStore() options.TemplateScheduleStore.Store(&v) } - if options.UserMaintenanceScheduleStore == nil { - options.UserMaintenanceScheduleStore = &atomic.Pointer[schedule.UserMaintenanceScheduleStore]{} + if options.UserQuietHoursScheduleStore == nil { + options.UserQuietHoursScheduleStore = &atomic.Pointer[schedule.UserQuietHoursScheduleStore]{} } - if options.UserMaintenanceScheduleStore.Load() == nil { - v := schedule.NewAGPLUserMaintenanceScheduleStore() - options.UserMaintenanceScheduleStore.Store(&v) + if options.UserQuietHoursScheduleStore.Load() == nil { + v := schedule.NewAGPLUserQuietHoursScheduleStore() + options.UserQuietHoursScheduleStore.Store(&v) } if options.HealthcheckFunc == nil { options.HealthcheckFunc = func(ctx context.Context, apiKey string) *healthcheck.Report { @@ -338,12 +338,12 @@ func New(options *Options) *API { options.AgentInactiveDisconnectTimeout, options.AppSecurityKey, ), - metricsCache: metricsCache, - Auditor: atomic.Pointer[audit.Auditor]{}, - TemplateScheduleStore: options.TemplateScheduleStore, - UserMaintenanceScheduleStore: options.UserMaintenanceScheduleStore, - Experiments: experiments, - healthCheckGroup: &singleflight.Group[string, *healthcheck.Report]{}, + metricsCache: metricsCache, + Auditor: atomic.Pointer[audit.Auditor]{}, + TemplateScheduleStore: options.TemplateScheduleStore, + UserQuietHoursScheduleStore: options.UserQuietHoursScheduleStore, + Experiments: experiments, + healthCheckGroup: &singleflight.Group[string, *healthcheck.Report]{}, } if options.UpdateCheckOptions != nil { api.updateChecker = updatecheck.New( @@ -865,9 +865,9 @@ type API struct { // TemplateScheduleStore is a pointer to an atomic pointer because this is // passed to another struct, and we want them all to be the same reference. TemplateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore] - // UserMaintenanceScheduleStore is a pointer to an atomic pointer for the + // UserQuietHoursScheduleStore is a pointer to an atomic pointer for the // same reason as TemplateScheduleStore. - UserMaintenanceScheduleStore *atomic.Pointer[schedule.UserMaintenanceScheduleStore] + UserQuietHoursScheduleStore *atomic.Pointer[schedule.UserQuietHoursScheduleStore] HTTPAuth *HTTPAuthorizer @@ -977,23 +977,23 @@ func (api *API) CreateInMemoryProvisionerDaemon(ctx context.Context, debounce ti mux := drpcmux.New() err = proto.DRPCRegisterProvisionerDaemon(mux, &provisionerdserver.Server{ - AccessURL: api.AccessURL, - ID: daemon.ID, - OIDCConfig: api.OIDCConfig, - Database: api.Database, - Pubsub: api.Pubsub, - Provisioners: daemon.Provisioners, - GitAuthConfigs: api.GitAuthConfigs, - Telemetry: api.Telemetry, - Tracer: tracer, - Tags: tags, - QuotaCommitter: &api.QuotaCommitter, - Auditor: &api.Auditor, - TemplateScheduleStore: api.TemplateScheduleStore, - UserMaintenanceScheduleStore: api.UserMaintenanceScheduleStore, - AcquireJobDebounce: debounce, - Logger: api.Logger.Named(fmt.Sprintf("provisionerd-%s", daemon.Name)), - DeploymentValues: api.DeploymentValues, + AccessURL: api.AccessURL, + ID: daemon.ID, + OIDCConfig: api.OIDCConfig, + Database: api.Database, + Pubsub: api.Pubsub, + Provisioners: daemon.Provisioners, + GitAuthConfigs: api.GitAuthConfigs, + Telemetry: api.Telemetry, + Tracer: tracer, + Tags: tags, + QuotaCommitter: &api.QuotaCommitter, + Auditor: &api.Auditor, + TemplateScheduleStore: api.TemplateScheduleStore, + UserQuietHoursScheduleStore: api.UserQuietHoursScheduleStore, + AcquireJobDebounce: debounce, + Logger: api.Logger.Named(fmt.Sprintf("provisionerd-%s", daemon.Name)), + DeploymentValues: api.DeploymentValues, }) if err != nil { return nil, err diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 617cc6e8f8491..d7e5bafa64484 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -2268,11 +2268,11 @@ func (q *querier) UpdateUserLinkedID(ctx context.Context, arg database.UpdateUse return q.db.UpdateUserLinkedID(ctx, arg) } -func (q *querier) UpdateUserMaintenanceSchedule(ctx context.Context, arg database.UpdateUserMaintenanceScheduleParams) (database.User, error) { - fetch := func(ctx context.Context, arg database.UpdateUserMaintenanceScheduleParams) (database.User, error) { +func (q *querier) UpdateUserQuietHoursSchedule(ctx context.Context, arg database.UpdateUserQuietHoursScheduleParams) (database.User, error) { + fetch := func(ctx context.Context, arg database.UpdateUserQuietHoursScheduleParams) (database.User, error) { return q.db.GetUserByID(ctx, arg.ID) } - return updateWithReturn(q.log, q.auth, fetch, q.db.UpdateUserMaintenanceSchedule)(ctx, arg) + return updateWithReturn(q.log, q.auth, fetch, q.db.UpdateUserQuietHoursSchedule)(ctx, arg) } func (q *querier) UpdateUserProfile(ctx context.Context, arg database.UpdateUserProfileParams) (database.User, error) { diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go index 65e54d5f16c31..9a547e48bca3c 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -4762,7 +4762,7 @@ func (q *fakeQuerier) UpdateUserLinkedID(_ context.Context, params database.Upda return database.UserLink{}, sql.ErrNoRows } -func (q *fakeQuerier) UpdateUserMaintenanceSchedule(_ context.Context, arg database.UpdateUserMaintenanceScheduleParams) (database.User, error) { +func (q *fakeQuerier) UpdateUserQuietHoursSchedule(_ context.Context, arg database.UpdateUserQuietHoursScheduleParams) (database.User, error) { if err := validateDatabaseType(arg); err != nil { return database.User{}, err } @@ -4774,7 +4774,7 @@ func (q *fakeQuerier) UpdateUserMaintenanceSchedule(_ context.Context, arg datab if user.ID != arg.ID { continue } - user.MaintenanceSchedule = arg.MaintenanceSchedule + user.QuietHoursSchedule = arg.QuietHoursSchedule q.users[index] = user return user, nil } diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index 6c103e5cb1b51..e66ccac30c8f4 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -1375,10 +1375,10 @@ func (m metricsStore) UpdateUserLinkedID(ctx context.Context, arg database.Updat return link, err } -func (m metricsStore) UpdateUserMaintenanceSchedule(ctx context.Context, arg database.UpdateUserMaintenanceScheduleParams) (database.User, error) { +func (m metricsStore) UpdateUserQuietHoursSchedule(ctx context.Context, arg database.UpdateUserQuietHoursScheduleParams) (database.User, error) { start := time.Now() - r0, r1 := m.s.UpdateUserMaintenanceSchedule(ctx, arg) - m.queryLatencies.WithLabelValues("UpdateUserMaintenanceSchedule").Observe(time.Since(start).Seconds()) + r0, r1 := m.s.UpdateUserQuietHoursSchedule(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateUserQuietHoursSchedule").Observe(time.Since(start).Seconds()) return r0, r1 } diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 318960690b887..6dfcd01678661 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -2831,19 +2831,19 @@ func (mr *MockStoreMockRecorder) UpdateUserLinkedID(arg0, arg1 interface{}) *gom return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserLinkedID", reflect.TypeOf((*MockStore)(nil).UpdateUserLinkedID), arg0, arg1) } -// UpdateUserMaintenanceSchedule mocks base method. -func (m *MockStore) UpdateUserMaintenanceSchedule(arg0 context.Context, arg1 database.UpdateUserMaintenanceScheduleParams) (database.User, error) { +// UpdateUserQuietHoursSchedule mocks base method. +func (m *MockStore) UpdateUserQuietHoursSchedule(arg0 context.Context, arg1 database.UpdateUserQuietHoursScheduleParams) (database.User, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateUserMaintenanceSchedule", arg0, arg1) + ret := m.ctrl.Call(m, "UpdateUserQuietHoursSchedule", arg0, arg1) ret0, _ := ret[0].(database.User) ret1, _ := ret[1].(error) return ret0, ret1 } -// UpdateUserMaintenanceSchedule indicates an expected call of UpdateUserMaintenanceSchedule. -func (mr *MockStoreMockRecorder) UpdateUserMaintenanceSchedule(arg0, arg1 interface{}) *gomock.Call { +// UpdateUserQuietHoursSchedule indicates an expected call of UpdateUserQuietHoursSchedule. +func (mr *MockStoreMockRecorder) UpdateUserQuietHoursSchedule(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserMaintenanceSchedule", reflect.TypeOf((*MockStore)(nil).UpdateUserMaintenanceSchedule), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserQuietHoursSchedule", reflect.TypeOf((*MockStore)(nil).UpdateUserQuietHoursSchedule), arg0, arg1) } // UpdateUserProfile mocks base method. diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 053eaa58b71e2..38ba29a0513b6 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -530,7 +530,7 @@ CREATE TABLE users ( avatar_url text, deleted boolean DEFAULT false NOT NULL, last_seen_at timestamp without time zone DEFAULT '0001-01-01 00:00:00'::timestamp without time zone NOT NULL, - maintenance_schedule text DEFAULT ''::text NOT NULL + quiet_hours_schedule text DEFAULT ''::text NOT NULL ); CREATE UNLOGGED TABLE workspace_agent_metadata ( diff --git a/coderd/database/migrations/000130_user_maintenance_schedule.down.sql b/coderd/database/migrations/000130_user_maintenance_schedule.down.sql deleted file mode 100644 index 91d3f65e5a647..0000000000000 --- a/coderd/database/migrations/000130_user_maintenance_schedule.down.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE users DROP COLUMN maintenance_schedule; diff --git a/coderd/database/migrations/000130_user_quiet_hours_schedule.down.sql b/coderd/database/migrations/000130_user_quiet_hours_schedule.down.sql new file mode 100644 index 0000000000000..491e2fa41997c --- /dev/null +++ b/coderd/database/migrations/000130_user_quiet_hours_schedule.down.sql @@ -0,0 +1 @@ +ALTER TABLE users DROP COLUMN quiet_hours_schedule; diff --git a/coderd/database/migrations/000130_user_maintenance_schedule.up.sql b/coderd/database/migrations/000130_user_quiet_hours_schedule.up.sql similarity index 50% rename from coderd/database/migrations/000130_user_maintenance_schedule.up.sql rename to coderd/database/migrations/000130_user_quiet_hours_schedule.up.sql index 95a4c984ad1f3..f544e73e320fb 100644 --- a/coderd/database/migrations/000130_user_maintenance_schedule.up.sql +++ b/coderd/database/migrations/000130_user_quiet_hours_schedule.up.sql @@ -1,2 +1,2 @@ -- empty schedule means use the default if entitled -ALTER TABLE users ADD COLUMN maintenance_schedule text NOT NULL DEFAULT ''; +ALTER TABLE users ADD COLUMN quiet_hours_schedule text NOT NULL DEFAULT ''; diff --git a/coderd/database/models.go b/coderd/database/models.go index bfb0929e6a141..ca84cfc618161 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1631,19 +1631,19 @@ type TemplateVersionVariable struct { } type User struct { - ID uuid.UUID `db:"id" json:"id"` - Email string `db:"email" json:"email"` - Username string `db:"username" json:"username"` - HashedPassword []byte `db:"hashed_password" json:"hashed_password"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` - Status UserStatus `db:"status" json:"status"` - RBACRoles pq.StringArray `db:"rbac_roles" json:"rbac_roles"` - LoginType LoginType `db:"login_type" json:"login_type"` - AvatarURL sql.NullString `db:"avatar_url" json:"avatar_url"` - Deleted bool `db:"deleted" json:"deleted"` - LastSeenAt time.Time `db:"last_seen_at" json:"last_seen_at"` - MaintenanceSchedule string `db:"maintenance_schedule" json:"maintenance_schedule"` + ID uuid.UUID `db:"id" json:"id"` + Email string `db:"email" json:"email"` + Username string `db:"username" json:"username"` + HashedPassword []byte `db:"hashed_password" json:"hashed_password"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + Status UserStatus `db:"status" json:"status"` + RBACRoles pq.StringArray `db:"rbac_roles" json:"rbac_roles"` + LoginType LoginType `db:"login_type" json:"login_type"` + AvatarURL sql.NullString `db:"avatar_url" json:"avatar_url"` + Deleted bool `db:"deleted" json:"deleted"` + LastSeenAt time.Time `db:"last_seen_at" json:"last_seen_at"` + QuietHoursSchedule string `db:"quiet_hours_schedule" json:"quiet_hours_schedule"` } type UserLink struct { diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 2b9a1eb22f241..57dd0e2c94afd 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -235,7 +235,7 @@ type sqlcQuerier interface { UpdateUserLastSeenAt(ctx context.Context, arg UpdateUserLastSeenAtParams) (User, error) UpdateUserLink(ctx context.Context, arg UpdateUserLinkParams) (UserLink, error) UpdateUserLinkedID(ctx context.Context, arg UpdateUserLinkedIDParams) (UserLink, error) - UpdateUserMaintenanceSchedule(ctx context.Context, arg UpdateUserMaintenanceScheduleParams) (User, error) + UpdateUserQuietHoursSchedule(ctx context.Context, arg UpdateUserQuietHoursScheduleParams) (User, error) UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) (User, error) UpdateUserRoles(ctx context.Context, arg UpdateUserRolesParams) (User, error) UpdateUserStatus(ctx context.Context, arg UpdateUserStatusParams) (User, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 85760722bdc34..44198d328f730 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -1103,7 +1103,7 @@ func (q *sqlQuerier) GetGroupMembers(ctx context.Context, groupID uuid.UUID) ([] &i.AvatarURL, &i.Deleted, &i.LastSeenAt, - &i.MaintenanceSchedule, + &i.QuietHoursSchedule, ); err != nil { return nil, err } @@ -4773,7 +4773,7 @@ func (q *sqlQuerier) GetUserByEmailOrUsername(ctx context.Context, arg GetUserBy &i.AvatarURL, &i.Deleted, &i.LastSeenAt, - &i.MaintenanceSchedule, + &i.QuietHoursSchedule, ) return i, err } @@ -4805,7 +4805,7 @@ func (q *sqlQuerier) GetUserByID(ctx context.Context, id uuid.UUID) (User, error &i.AvatarURL, &i.Deleted, &i.LastSeenAt, - &i.MaintenanceSchedule, + &i.QuietHoursSchedule, ) return i, err } @@ -4987,7 +4987,7 @@ func (q *sqlQuerier) GetUsersByIDs(ctx context.Context, ids []uuid.UUID) ([]User &i.AvatarURL, &i.Deleted, &i.LastSeenAt, - &i.MaintenanceSchedule, + &i.QuietHoursSchedule, ); err != nil { return nil, err } @@ -5054,7 +5054,7 @@ func (q *sqlQuerier) InsertUser(ctx context.Context, arg InsertUserParams) (User &i.AvatarURL, &i.Deleted, &i.LastSeenAt, - &i.MaintenanceSchedule, + &i.QuietHoursSchedule, ) return i, err } @@ -5129,12 +5129,12 @@ func (q *sqlQuerier) UpdateUserLastSeenAt(ctx context.Context, arg UpdateUserLas &i.AvatarURL, &i.Deleted, &i.LastSeenAt, - &i.MaintenanceSchedule, + &i.QuietHoursSchedule, ) return i, err } -const updateUserMaintenanceSchedule = `-- name: UpdateUserMaintenanceSchedule :one +const UpdateUserQuietHoursSchedule = `-- name: UpdateUserQuietHoursSchedule :one UPDATE users SET @@ -5144,13 +5144,13 @@ WHERE RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, maintenance_schedule ` -type UpdateUserMaintenanceScheduleParams struct { - ID uuid.UUID `db:"id" json:"id"` - MaintenanceSchedule string `db:"maintenance_schedule" json:"maintenance_schedule"` +type UpdateUserQuietHoursScheduleParams struct { + ID uuid.UUID `db:"id" json:"id"` + QuietHoursSchedule string `db:"quiet_hours_schedule" json:"quiet_hours_schedule"` } -func (q *sqlQuerier) UpdateUserMaintenanceSchedule(ctx context.Context, arg UpdateUserMaintenanceScheduleParams) (User, error) { - row := q.db.QueryRowContext(ctx, updateUserMaintenanceSchedule, arg.ID, arg.MaintenanceSchedule) +func (q *sqlQuerier) UpdateUserQuietHoursSchedule(ctx context.Context, arg UpdateUserQuietHoursScheduleParams) (User, error) { + row := q.db.QueryRowContext(ctx, UpdateUserQuietHoursSchedule, arg.ID, arg.QuietHoursSchedule) var i User err := row.Scan( &i.ID, @@ -5165,7 +5165,7 @@ func (q *sqlQuerier) UpdateUserMaintenanceSchedule(ctx context.Context, arg Upda &i.AvatarURL, &i.Deleted, &i.LastSeenAt, - &i.MaintenanceSchedule, + &i.QuietHoursSchedule, ) return i, err } @@ -5212,7 +5212,7 @@ func (q *sqlQuerier) UpdateUserProfile(ctx context.Context, arg UpdateUserProfil &i.AvatarURL, &i.Deleted, &i.LastSeenAt, - &i.MaintenanceSchedule, + &i.QuietHoursSchedule, ) return i, err } @@ -5249,7 +5249,7 @@ func (q *sqlQuerier) UpdateUserRoles(ctx context.Context, arg UpdateUserRolesPar &i.AvatarURL, &i.Deleted, &i.LastSeenAt, - &i.MaintenanceSchedule, + &i.QuietHoursSchedule, ) return i, err } @@ -5286,7 +5286,7 @@ func (q *sqlQuerier) UpdateUserStatus(ctx context.Context, arg UpdateUserStatusP &i.AvatarURL, &i.Deleted, &i.LastSeenAt, - &i.MaintenanceSchedule, + &i.QuietHoursSchedule, ) return i, err } diff --git a/coderd/database/queries/users.sql b/coderd/database/queries/users.sql index 5b2555536c51b..5c299e0cd0e18 100644 --- a/coderd/database/queries/users.sql +++ b/coderd/database/queries/users.sql @@ -249,11 +249,11 @@ FROM WHERE id = @user_id; --- name: UpdateUserMaintenanceSchedule :one +-- name: UpdateUserQuietHoursSchedule :one UPDATE users SET - maintenance_schedule = $2 + quiet_hours_schedule = $2 WHERE id = $1 RETURNING *; diff --git a/coderd/provisionerdserver/provisionerdserver.go b/coderd/provisionerdserver/provisionerdserver.go index d3944aa034023..937b034dfdb0f 100644 --- a/coderd/provisionerdserver/provisionerdserver.go +++ b/coderd/provisionerdserver/provisionerdserver.go @@ -6,7 +6,6 @@ import ( "encoding/json" "errors" "fmt" - "math/rand" "net/http" "net/url" "reflect" @@ -50,21 +49,21 @@ var ( ) type Server struct { - AccessURL *url.URL - ID uuid.UUID - Logger slog.Logger - Provisioners []database.ProvisionerType - GitAuthConfigs []*gitauth.Config - Tags json.RawMessage - Database database.Store - Pubsub pubsub.Pubsub - Telemetry telemetry.Reporter - Tracer trace.Tracer - QuotaCommitter *atomic.Pointer[proto.QuotaCommitter] - Auditor *atomic.Pointer[audit.Auditor] - TemplateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore] - UserMaintenanceScheduleStore *atomic.Pointer[schedule.UserMaintenanceScheduleStore] - DeploymentValues *codersdk.DeploymentValues + AccessURL *url.URL + ID uuid.UUID + Logger slog.Logger + Provisioners []database.ProvisionerType + GitAuthConfigs []*gitauth.Config + Tags json.RawMessage + Database database.Store + Pubsub pubsub.Pubsub + Telemetry telemetry.Reporter + Tracer trace.Tracer + QuotaCommitter *atomic.Pointer[proto.QuotaCommitter] + Auditor *atomic.Pointer[audit.Auditor] + TemplateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore] + UserQuietHoursScheduleStore *atomic.Pointer[schedule.UserQuietHoursScheduleStore] + DeploymentValues *codersdk.DeploymentValues AcquireJobDebounce time.Duration OIDCConfig httpmw.OAuth2Config @@ -936,51 +935,33 @@ func (server *Server) CompleteJob(ctx context.Context, completed *proto.Complete // deadline is sooner than the workspace deadline, use the // max deadline as the actual deadline. // - // Notably, this isn't affected by the user's maintenance + // Notably, this isn't affected by the user's quiet hours // schedule below because we'd still like to use the max TTL // as the TTL for the workspace if it's not set. deadline = maxDeadline } } - userMaintenanceSchedule, err := (*server.UserMaintenanceScheduleStore.Load()).GetUserMaintenanceScheduleOptions(ctx, db, workspace.OwnerID) + userQuietHoursSchedule, err := (*server.UserQuietHoursScheduleStore.Load()).GetUserQuietHoursScheduleOptions(ctx, db, workspace.OwnerID) if err != nil { - return xerrors.Errorf("get user maintenance schedule options: %w", err) + return xerrors.Errorf("get user quiet hours schedule options: %w", err) } - if userMaintenanceSchedule.Schedule != nil { + if userQuietHoursSchedule.Schedule != nil { // Round the max deadline up to the nearest occurrence of the - // user's maintenance schedule. This ensures that workspaces + // user's quiet hours schedule. This ensures that workspaces // can't be force-stopped due to max TTL during business hours. // Get the schedule occurrence that happens right before, during // or after the max deadline. - // TODO: change to the maintenance window BEFORE max TTL - scheduleDur := userMaintenanceSchedule.Duration + // TODO: change to the quiet hours window BEFORE max TTL + scheduleDur := userQuietHoursSchedule.Duration if scheduleDur > 1*time.Hour { // Allow a 15 minute buffer when possible so we're not too // constrained with the autostop time. scheduleDur -= 15 * time.Minute } - windowStart := userMaintenanceSchedule.Schedule.Next(maxDeadline.Add(scheduleDur)) - - // Get the window of time that the workspace can be stopped in. - // This must be between windowStart and windowEnd, and also must - // be after the current max deadline. - minTime := maxDeadline - if windowStart.After(minTime) { - minTime = windowStart - } - maxTime := windowStart.Add(scheduleDur) - if minTime.After(maxTime) { - // TODO: remove this panic once we have good tests, and add - // a sensible fallback instead - panic("minTime is after maxTime") - } - - // Pick a random time between minTime and maxTime. - actualDur := maxTime.Sub(minTime) - jitter := time.Duration(rand.Int63n(int64(actualDur))) - maxDeadline = minTime.Add(jitter) + windowStart := userQuietHoursSchedule.Schedule.Next(maxDeadline.Add(scheduleDur)) + maxDeadline = windowStart } err = db.UpdateProvisionerJobWithCompleteByID(ctx, database.UpdateProvisionerJobWithCompleteByIDParams{ diff --git a/coderd/provisionerdserver/provisionerdserver_test.go b/coderd/provisionerdserver/provisionerdserver_test.go index f1868adce5964..6883da1654c19 100644 --- a/coderd/provisionerdserver/provisionerdserver_test.go +++ b/coderd/provisionerdserver/provisionerdserver_test.go @@ -47,9 +47,9 @@ func testTemplateScheduleStore() *atomic.Pointer[schedule.TemplateScheduleStore] return ptr } -func testUserMaintenanceScheduleStore() *atomic.Pointer[schedule.UserMaintenanceScheduleStore] { - ptr := &atomic.Pointer[schedule.UserMaintenanceScheduleStore]{} - store := schedule.NewAGPLUserMaintenanceScheduleStore() +func testUserMaintenanceScheduleStore() *atomic.Pointer[schedule.UserQuietHoursScheduleStore] { + ptr := &atomic.Pointer[schedule.UserQuietHoursScheduleStore]{} + store := schedule.NewAGPLUserQuietHoursScheduleStore() ptr.Store(&store) return ptr } @@ -61,19 +61,19 @@ func TestAcquireJob(t *testing.T) { db := dbfake.New() ps := pubsub.NewInMemory() srv := &provisionerdserver.Server{ - ID: uuid.New(), - Logger: slogtest.Make(t, nil), - AccessURL: &url.URL{}, - Provisioners: []database.ProvisionerType{database.ProvisionerTypeEcho}, - Database: db, - Pubsub: ps, - Telemetry: telemetry.NewNoop(), - AcquireJobDebounce: time.Hour, - Auditor: mockAuditor(), - TemplateScheduleStore: testTemplateScheduleStore(), - UserMaintenanceScheduleStore: testUserMaintenanceScheduleStore(), - Tracer: trace.NewNoopTracerProvider().Tracer("noop"), - DeploymentValues: &codersdk.DeploymentValues{}, + ID: uuid.New(), + Logger: slogtest.Make(t, nil), + AccessURL: &url.URL{}, + Provisioners: []database.ProvisionerType{database.ProvisionerTypeEcho}, + Database: db, + Pubsub: ps, + Telemetry: telemetry.NewNoop(), + AcquireJobDebounce: time.Hour, + Auditor: mockAuditor(), + TemplateScheduleStore: testTemplateScheduleStore(), + UserQuietHoursScheduleStore: testUserMaintenanceScheduleStore(), + Tracer: trace.NewNoopTracerProvider().Tracer("noop"), + DeploymentValues: &codersdk.DeploymentValues{}, } job, err := srv.AcquireJob(context.Background(), nil) require.NoError(t, err) @@ -1268,19 +1268,19 @@ func setup(t *testing.T, ignoreLogErrors bool) *provisionerdserver.Server { ps := pubsub.NewInMemory() return &provisionerdserver.Server{ - ID: uuid.New(), - Logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: ignoreLogErrors}), - OIDCConfig: &oauth2.Config{}, - AccessURL: &url.URL{}, - Provisioners: []database.ProvisionerType{database.ProvisionerTypeEcho}, - Database: db, - Pubsub: ps, - Telemetry: telemetry.NewNoop(), - Auditor: mockAuditor(), - TemplateScheduleStore: testTemplateScheduleStore(), - UserMaintenanceScheduleStore: testUserMaintenanceScheduleStore(), - Tracer: trace.NewNoopTracerProvider().Tracer("noop"), - DeploymentValues: &codersdk.DeploymentValues{}, + ID: uuid.New(), + Logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: ignoreLogErrors}), + OIDCConfig: &oauth2.Config{}, + AccessURL: &url.URL{}, + Provisioners: []database.ProvisionerType{database.ProvisionerTypeEcho}, + Database: db, + Pubsub: ps, + Telemetry: telemetry.NewNoop(), + Auditor: mockAuditor(), + TemplateScheduleStore: testTemplateScheduleStore(), + UserQuietHoursScheduleStore: testUserMaintenanceScheduleStore(), + Tracer: trace.NewNoopTracerProvider().Tracer("noop"), + DeploymentValues: &codersdk.DeploymentValues{}, } } diff --git a/coderd/schedule/user.go b/coderd/schedule/user.go index 3e4c73bb0109c..e455227eae676 100644 --- a/coderd/schedule/user.go +++ b/coderd/schedule/user.go @@ -9,57 +9,57 @@ import ( "github.com/coder/coder/coderd/database" ) -type UserMaintenanceScheduleOptions struct { - // Schedule is the cron schedule to use for maintenance windows for all +type UserQuietHoursScheduleOptions struct { + // Schedule is the cron schedule to use for quiet hours windows for all // workspaces owned by the user. // // This value will be set to the parsed custom schedule of the user. If the // user doesn't have a custom schedule set, it will be set to the default - // schedule (and UserSet will be false). If maintenance schedules are not + // schedule (and UserSet will be false). If quiet hours schedules are not // entitled or disabled instance-wide, this value will be nil to denote that - // maintenance windows should not be used. + // quiet hours windows should not be used. Schedule *Schedule UserSet bool - // Duration is the duration of the maintenance window starting when the cron + // Duration is the duration of the quiet hours window starting when the cron // triggers. Workspaces can be stopped for maintenance or due to max_ttl // during this window. Duration time.Duration } -type UserMaintenanceScheduleStore interface { - // GetUserMaintenanceScheduleOptions retrieves the maintenance schedule for +type UserQuietHoursScheduleStore interface { + // GetUserQuietHoursScheduleOptions retrieves the quiet hours schedule for // the given user. If the user has not set a custom schedule, the default - // schedule will be returned. If maintenance schedules are not entitled or + // schedule will be returned. If quiet hours schedules are not entitled or // disabled instance-wide, this will return a nil schedule. - GetUserMaintenanceScheduleOptions(ctx context.Context, db database.Store, userID uuid.UUID) (UserMaintenanceScheduleOptions, error) - // SetUserMaintenanceScheduleOptions sets the maintenance schedule for the - // given user. If the given schedule is an empty string, the user's custom schedule will - // be cleared and the default schedule will be used from now on. If - // maintenance schedules are not entitled or disabled instance-wide, this - // will do nothing and return a nil schedule. - SetUserMaintenanceScheduleOptions(ctx context.Context, db database.Store, userID uuid.UUID, rawSchedule string) (UserMaintenanceScheduleOptions, error) + GetUserQuietHoursScheduleOptions(ctx context.Context, db database.Store, userID uuid.UUID) (UserQuietHoursScheduleOptions, error) + // SetUserQuietHoursScheduleOptions sets the quiet hours schedule for the + // given user. If the given schedule is an empty string, the user's custom + // schedule will be cleared and the default schedule will be used from now + // on. If quiet hours schedules are not entitled or disabled instance-wide, + // this will do nothing and return a nil schedule. + SetUserQuietHoursScheduleOptions(ctx context.Context, db database.Store, userID uuid.UUID, rawSchedule string) (UserQuietHoursScheduleOptions, error) } -type agplUserMaintenanceScheduleStore struct{} +type agplUserQuietHoursScheduleStore struct{} -var _ UserMaintenanceScheduleStore = &agplUserMaintenanceScheduleStore{} +var _ UserQuietHoursScheduleStore = &agplUserQuietHoursScheduleStore{} -func NewAGPLUserMaintenanceScheduleStore() UserMaintenanceScheduleStore { - return &agplUserMaintenanceScheduleStore{} +func NewAGPLUserQuietHoursScheduleStore() UserQuietHoursScheduleStore { + return &agplUserQuietHoursScheduleStore{} } -func (*agplUserMaintenanceScheduleStore) GetUserMaintenanceScheduleOptions(_ context.Context, _ database.Store, _ uuid.UUID) (UserMaintenanceScheduleOptions, error) { - // User maintenance windows are not supported in AGPL. - return UserMaintenanceScheduleOptions{ +func (*agplUserQuietHoursScheduleStore) GetUserQuietHoursScheduleOptions(_ context.Context, _ database.Store, _ uuid.UUID) (UserQuietHoursScheduleOptions, error) { + // User quiet hours windows are not supported in AGPL. + return UserQuietHoursScheduleOptions{ Schedule: nil, UserSet: false, Duration: 0, }, nil } -func (*agplUserMaintenanceScheduleStore) SetUserMaintenanceScheduleOptions(_ context.Context, _ database.Store, _ uuid.UUID, _ string) (UserMaintenanceScheduleOptions, error) { - // User maintenance windows are not supported in AGPL. - return UserMaintenanceScheduleOptions{ +func (*agplUserQuietHoursScheduleStore) SetUserQuietHoursScheduleOptions(_ context.Context, _ database.Store, _ uuid.UUID, _ string) (UserQuietHoursScheduleOptions, error) { + // User quiet hours windows are not supported in AGPL. + return UserQuietHoursScheduleOptions{ Schedule: nil, UserSet: false, Duration: 0, diff --git a/codersdk/deployment.go b/codersdk/deployment.go index fd2384e502dc4..1db645a1220d6 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -45,7 +45,7 @@ const ( FeatureExternalProvisionerDaemons FeatureName = "external_provisioner_daemons" FeatureAppearance FeatureName = "appearance" FeatureAdvancedTemplateScheduling FeatureName = "advanced_template_scheduling" - FeatureUserMaintenanceSchedule FeatureName = "user_maintenance_schedule" + FeatureUserQuietHoursSchedule FeatureName = "user_quiet_hours" FeatureWorkspaceProxy FeatureName = "workspace_proxy" ) @@ -61,7 +61,7 @@ var FeatureNames = []FeatureName{ FeatureExternalProvisionerDaemons, FeatureAppearance, FeatureAdvancedTemplateScheduling, - FeatureUserMaintenanceSchedule, + FeatureUserQuietHoursSchedule, FeatureWorkspaceProxy, } @@ -166,7 +166,7 @@ type DeploymentValues struct { WgtunnelHost clibase.String `json:"wgtunnel_host,omitempty" typescript:",notnull"` DisableOwnerWorkspaceExec clibase.Bool `json:"disable_owner_workspace_exec,omitempty" typescript:",notnull"` ProxyHealthStatusInterval clibase.Duration `json:"proxy_health_status_interval,omitempty" typescript:",notnull"` - UserMaintenanceSchedule UserMaintenanceScheduleConfig `json:"user_maintenance_schedule,omitempty" typescript:",notnull"` + UserQuietHoursSchedule UserQuietHoursScheduleConfig `json:"user_quiet_hours_schedule,omitempty" typescript:",notnull"` Config clibase.YAMLConfigPath `json:"config,omitempty" typescript:",notnull"` WriteConfig clibase.Bool `json:"write_config,omitempty" typescript:",notnull"` @@ -338,7 +338,7 @@ type DangerousConfig struct { AllowAllCors clibase.Bool `json:"allow_all_cors" typescript:",notnull"` } -type UserMaintenanceScheduleConfig struct { +type UserQuietHoursScheduleConfig struct { DefaultSchedule clibase.String `json:"default_schedule" typescript:",notnull"` WindowDuration clibase.Duration `json:"window_duration" typescript:",notnull"` } @@ -466,10 +466,10 @@ when required by your organization's security policy.`, Description: `Tune the behavior of the provisioner, which is responsible for creating, updating, and deleting workspace resources.`, YAML: "provisioning", } - deploymentGroupUserMaintenanceSchedule = clibase.Group{ - Name: "User Maintenance Schedule", - Description: "Allow users to set maintenance schedules each day for workspaces to avoid workspaces stopping during the day due to template max TTL.", - YAML: "user-maintenance-schedule", + deploymentGroupUserQuietHoursSchedule = clibase.Group{ + Name: "User Quiet Hours Schedule", + Description: "Allow users to set quiet hours schedules each day for workspaces to avoid workspaces stopping during the day due to template max TTL.", + YAML: "userQuietHoursSchedule", } deploymentGroupDangerous = clibase.Group{ Name: "⚠️ Dangerous", @@ -1533,24 +1533,24 @@ Write out the current server config as YAML to stdout.`, YAML: "proxyHealthInterval", }, { - Name: "Default Maintenance Schedule", - Description: "The default daily cron schedule applied to users that haven't set a custom maintenance schedule themselves. The maintenance 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 maintenance 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).", - Flag: "default-maintenance-schedule", - Env: "CODER_MAINTENANCE_DEFAULT_SCHEDULE", + Name: "Default Quiet Hours Schedule", + Description: "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).", + Flag: "default-quiet-hours-schedule", + Env: "CODER_QUIET_HOURS_DEFAULT_SCHEDULE", Default: "", - Value: &c.UserMaintenanceSchedule.DefaultSchedule, - Group: &deploymentGroupUserMaintenanceSchedule, - YAML: "defaultMaintenanceSchedule", + Value: &c.UserQuietHoursSchedule.DefaultSchedule, + Group: &deploymentGroupUserQuietHoursSchedule, + YAML: "defaultQuietHoursSchedule", }, { - Name: "Maintenance Window Duration", - Description: "The duration of maintenance windows when triggered by cron. Workspaces can only be stopped due to max TTL during this window. Must be at least 1 hour.", - Flag: "maintenance-window-duration", - Env: "CODER_MAINTENANCE_WINDOW_DURATION", + Name: "Quiet Hours Window Duration", + Description: "The duration of quiet hours windows when triggered by cron. Workspaces can only be stopped due to max TTL during this window. Must be at least 1 hour.", + Flag: "quiet-hours-window-duration", + Env: "CODER_QUIET_HOURS_WINDOW_DURATION", Default: (4 * time.Hour).String(), - Value: &c.UserMaintenanceSchedule.DefaultSchedule, - Group: &deploymentGroupUserMaintenanceSchedule, - YAML: "maintenanceWindowDuration", + Value: &c.UserQuietHoursSchedule.DefaultSchedule, + Group: &deploymentGroupUserQuietHoursSchedule, + YAML: "quietHoursWindowDuration", }, } return opts diff --git a/codersdk/users.go b/codersdk/users.go index 985ab73b0b749..8db70dddb1744 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -84,23 +84,23 @@ type UpdateUserPasswordRequest struct { Password string `json:"password" validate:"required"` } -type UserMaintenanceScheduleResponse struct { +type UserQuietHoursScheduleResponse struct { RawSchedule string `json:"raw_schedule"` - // UserSet is true if the user has set their own maintenance schedule. If + // UserSet is true if the user has set their own quiet hours schedule. If // false, the user is using the default schedule. UserSet bool `json:"user_set"` - // Time is the time of day that the maintenance window starts in the given + // Time is the time of day that the quiet hours window starts in the given // Timezone each day. Time string `json:"time"` // HH:mm (24-hour) Timezone string `json:"timezone"` // raw format from the cron expression, UTC if unspecified - // Duration is the duration of the maintenance window. + // Duration is the duration of the quiet hours window. Duration time.Duration `json:"duration"` - // Next is the next time that the maintenance window will start. + // Next is the next time that the quiet hours window will start. Next time.Time `json:"next" format:"date-time"` } -type UpdateUserMaintenanceScheduleRequest struct { - // Schedule is a cron expression that defines when the user's maintenance +type UpdateUserQuietHoursScheduleRequest struct { + // Schedule is a cron expression that defines when the user's quiet hours // window is. Schedule must not be empty. For new users, the schedule is set // to 2am in their browser or computer's timezone. The schedule denotes the // beginning of a 4 hour window where the workspace is allowed to diff --git a/docs/admin/audit-logs.md b/docs/admin/audit-logs.md index af1f8e3bdf0de..38f8259585aca 100644 --- a/docs/admin/audit-logs.md +++ b/docs/admin/audit-logs.md @@ -17,7 +17,7 @@ We track the following resources: | License
create, delete |
FieldTracked
exptrue
idfalse
jwtfalse
uploaded_attrue
uuidtrue
| | Template
write, delete |
FieldTracked
active_version_idtrue
allow_user_autostarttrue
allow_user_autostoptrue
allow_user_cancel_workspace_jobstrue
created_atfalse
created_bytrue
default_ttltrue
deletedfalse
descriptiontrue
display_nametrue
failure_ttltrue
group_acltrue
icontrue
idtrue
inactivity_ttltrue
locked_ttltrue
max_ttltrue
nametrue
organization_idfalse
provisionertrue
updated_atfalse
user_acltrue
| | TemplateVersion
create, write |
FieldTracked
created_atfalse
created_bytrue
git_auth_providersfalse
idtrue
job_idfalse
nametrue
organization_idfalse
readmetrue
template_idtrue
updated_atfalse
| -| User
create, write, delete |
FieldTracked
avatar_urlfalse
created_atfalse
deletedtrue
emailtrue
hashed_passwordtrue
idtrue
last_seen_atfalse
login_typefalse
maintenance_scheduletrue
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| +| User
create, write, delete |
FieldTracked
avatar_urlfalse
created_atfalse
deletedtrue
emailtrue
hashed_passwordtrue
idtrue
last_seen_atfalse
login_typefalse
quiet_hours_scheduletrue
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| | Workspace
create, write, delete |
FieldTracked
autostart_scheduletrue
created_atfalse
deletedfalse
idtrue
last_used_atfalse
nametrue
organization_idfalse
owner_idtrue
template_idtrue
ttltrue
updated_atfalse
| | WorkspaceBuild
start, stop |
FieldTracked
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
idfalse
initiator_idfalse
job_idfalse
max_deadlinefalse
provisioner_statefalse
reasonfalse
template_version_idtrue
transitionfalse
updated_atfalse
workspace_idfalse
| | WorkspaceProxy
|
FieldTracked
created_attrue
deletedfalse
display_nametrue
icontrue
idtrue
nametrue
token_hashed_secrettrue
updated_atfalse
urltrue
wildcard_hostnametrue
| diff --git a/docs/api/enterprise.md b/docs/api/enterprise.md index c5ecaea1c26ff..2aa41a7fc328d 100644 --- a/docs/api/enterprise.md +++ b/docs/api/enterprise.md @@ -1122,6 +1122,132 @@ curl -X PATCH http://coder-server:8080/api/v2/templates/{template}/acl \ To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Get user quiet hours schedule + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/users/{user}/quiet-hours-schedule \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /users/{user}/quiet-hours-schedule` + +### Parameters + +| Name | In | Type | Required | Description | +| ------ | ---- | ------------ | -------- | ----------- | +| `user` | path | string(uuid) | true | User ID | + +### Example responses + +> 200 Response + +```json +[ + { + "duration": 0, + "next": "2019-08-24T14:15:22Z", + "raw_schedule": "string", + "time": "string", + "timezone": "string", + "user_set": true + } +] +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.UserQuietHoursScheduleResponse](schemas.md#codersdkuserquiethoursscheduleresponse) | + +

Response Schema

+ +Status Code **200** + +| Name | Type | Required | Restrictions | Description | +| ---------------- | ----------------- | -------- | ------------ | ---------------------------------------------------------------------------------------------------------------------- | +| `[array item]` | array | false | | | +| `» duration` | integer | false | | Duration is the duration of the quiet hours window. | +| `» next` | string(date-time) | false | | Next is the next time that the quiet hours window will start. | +| `» raw_schedule` | string | false | | | +| `» time` | string | false | | Time is the time of day that the quiet hours window starts in the given Timezone each day. | +| `» timezone` | string | false | | raw format from the cron expression, UTC if unspecified | +| `» user_set` | boolean | false | | User set is true if the user has set their own quiet hours schedule. If false, the user is using the default schedule. | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Update user quiet hours schedule + +### Code samples + +```shell +# Example request using curl +curl -X PUT http://coder-server:8080/api/v2/users/{user}/quiet-hours-schedule \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`PUT /users/{user}/quiet-hours-schedule` + +> Body parameter + +```json +{ + "schedule": "string" +} +``` + +### Parameters + +| Name | In | Type | Required | Description | +| ------ | ---- | ------------------------------------------------------------------------------------------------------ | -------- | ----------------------- | +| `user` | path | string(uuid) | true | User ID | +| `body` | body | [codersdk.UpdateUserQuietHoursScheduleRequest](schemas.md#codersdkupdateuserquiethoursschedulerequest) | true | Update schedule request | + +### Example responses + +> 200 Response + +```json +[ + { + "duration": 0, + "next": "2019-08-24T14:15:22Z", + "raw_schedule": "string", + "time": "string", + "timezone": "string", + "user_set": true + } +] +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.UserQuietHoursScheduleResponse](schemas.md#codersdkuserquiethoursscheduleresponse) | + +

Response Schema

+ +Status Code **200** + +| Name | Type | Required | Restrictions | Description | +| ---------------- | ----------------- | -------- | ------------ | ---------------------------------------------------------------------------------------------------------------------- | +| `[array item]` | array | false | | | +| `» duration` | integer | false | | Duration is the duration of the quiet hours window. | +| `» next` | string(date-time) | false | | Next is the next time that the quiet hours window will start. | +| `» raw_schedule` | string | false | | | +| `» time` | string | false | | Time is the time of day that the quiet hours window starts in the given Timezone each day. | +| `» timezone` | string | false | | raw format from the cron expression, UTC if unspecified | +| `» user_set` | boolean | false | | User set is true if the user has set their own quiet hours schedule. If false, the user is using the default schedule. | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Get workspace quota by user ### Code samples diff --git a/docs/api/general.md b/docs/api/general.md index 22be046b47e64..e8ff72826dd6c 100644 --- a/docs/api/general.md +++ b/docs/api/general.md @@ -349,6 +349,10 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ "honeycomb_api_key": "string" }, "update_check": true, + "user_quiet_hours_schedule": { + "default_schedule": "string", + "window_duration": 0 + }, "verbose": true, "wgtunnel_host": "string", "wildcard_access_url": { diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 71b0a85f619c3..19457455a6229 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -2021,6 +2021,10 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "honeycomb_api_key": "string" }, "update_check": true, + "user_quiet_hours_schedule": { + "default_schedule": "string", + "window_duration": 0 + }, "verbose": true, "wgtunnel_host": "string", "wildcard_access_url": { @@ -2349,6 +2353,10 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "honeycomb_api_key": "string" }, "update_check": true, + "user_quiet_hours_schedule": { + "default_schedule": "string", + "window_duration": 0 + }, "verbose": true, "wgtunnel_host": "string", "wildcard_access_url": { @@ -2417,6 +2425,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `tls` | [codersdk.TLSConfig](#codersdktlsconfig) | false | | | | `trace` | [codersdk.TraceConfig](#codersdktraceconfig) | false | | | | `update_check` | boolean | false | | | +| `user_quiet_hours_schedule` | [codersdk.UserQuietHoursScheduleConfig](#codersdkuserquiethoursscheduleconfig) | false | | | | `verbose` | boolean | false | | | | `wgtunnel_host` | string | false | | | | `wildcard_access_url` | [clibase.URL](#clibaseurl) | false | | | @@ -4265,6 +4274,23 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | ---------- | ------ | -------- | ------------ | ----------- | | `username` | string | true | | | +## codersdk.UpdateUserQuietHoursScheduleRequest + +```json +{ + "schedule": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ---------- | ------ | -------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `schedule` | string | true | | Schedule is a cron expression that defines when the user's quiet hours window is. Schedule must not be empty. For new users, the schedule is set to 2am in their browser or computer's timezone. The schedule denotes the beginning of a 4 hour window where the workspace is allowed to automatically stop or restart due to maintenance or template max TTL. | + +The schedule must be daily with a single time, and should have a timezone specified via a CRON_TZ prefix (otherwise UTC will be used). +If the schedule is empty, the user will be updated to use the default schedule.| + ## codersdk.UpdateWorkspaceAutostartRequest ```json @@ -4363,6 +4389,46 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `status` | `active` | | `status` | `suspended` | +## codersdk.UserQuietHoursScheduleConfig + +```json +{ + "default_schedule": "string", + "window_duration": 0 +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ------------------ | ------- | -------- | ------------ | ----------- | +| `default_schedule` | string | false | | | +| `window_duration` | integer | false | | | + +## codersdk.UserQuietHoursScheduleResponse + +```json +{ + "duration": 0, + "next": "2019-08-24T14:15:22Z", + "raw_schedule": "string", + "time": "string", + "timezone": "string", + "user_set": true +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| -------------- | ------- | -------- | ------------ | ---------------------------------------------------------------------------------------------------------------------- | +| `duration` | integer | false | | Duration is the duration of the quiet hours window. | +| `next` | string | false | | Next is the next time that the quiet hours window will start. | +| `raw_schedule` | string | false | | | +| `time` | string | false | | Time is the time of day that the quiet hours window starts in the given Timezone each day. | +| `timezone` | string | false | | raw format from the cron expression, UTC if unspecified | +| `user_set` | boolean | false | | User set is true if the user has set their own quiet hours schedule. If false, the user is using the default schedule. | + ## codersdk.UserStatus ```json diff --git a/docs/cli/server.md b/docs/cli/server.md index 68c79cebf8f26..104e4b20b514d 100644 --- a/docs/cli/server.md +++ b/docs/cli/server.md @@ -173,6 +173,16 @@ An HTTP URL that is accessible by other replicas to relay DERP traffic. Required Addresses for STUN servers to establish P2P connections. Use special value 'disable' to turn off STUN. +### --default-quiet-hours-schedule + +| | | +| ----------- | ------------------------------------------------------------- | +| Type | string | +| Environment | $CODER_QUIET_HOURS_DEFAULT_SCHEDULE | +| YAML | userQuietHoursSchedule.defaultQuietHoursSchedule | + +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). + ### --disable-owner-workspace-access | | | @@ -628,6 +638,17 @@ Headers to trust for forwarding IP addresses. e.g. Cf-Connecting-Ip, True-Client Origin addresses to respect "proxy-trusted-headers". e.g. 192.168.1.0/24. +### --quiet-hours-window-duration + +| | | +| ----------- | ------------------------------------------------------------ | +| Type | string | +| Environment | $CODER_QUIET_HOURS_WINDOW_DURATION | +| YAML | userQuietHoursSchedule.quietHoursWindowDuration | +| Default | 4h0m0s | + +The duration of quiet hours windows when triggered by cron. Workspaces can only be stopped due to max TTL during this window. Must be at least 1 hour. + ### --redirect-to-access-url | | | diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index b6a90c36c06b6..510a2c8e4c2c5 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -104,7 +104,7 @@ var auditableResourcesTypes = map[any]map[string]Action{ "avatar_url": ActionIgnore, "last_seen_at": ActionIgnore, "deleted": ActionTrack, - "maintenance_schedule": ActionTrack, + "quiet_hours_schedule": ActionTrack, }, &database.Workspace{}: { "id": ActionTrack, diff --git a/enterprise/cli/server.go b/enterprise/cli/server.go index bd20de97c82db..3386a459de476 100644 --- a/enterprise/cli/server.go +++ b/enterprise/cli/server.go @@ -57,16 +57,16 @@ func (r *RootCmd) server() *clibase.Cmd { options.TrialGenerator = trialer.New(options.Database, "https://v2-licensor.coder.com/trial", coderd.Keys) o := &coderd.Options{ - 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(), - DefaultUserMaintenanceSchedule: options.DeploymentValues.UserMaintenanceSchedule.DefaultSchedule.Value(), - UserMaintenanceWindowDuration: options.DeploymentValues.UserMaintenanceSchedule.WindowDuration.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(), + QuietHoursWindowDuration: options.DeploymentValues.UserQuietHoursSchedule.WindowDuration.Value(), } api, err := coderd.New(ctx, o) diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 5758f70afd43f..6c85215a53434 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -52,8 +52,8 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { if options.Options.Authorizer == nil { options.Options.Authorizer = rbac.NewCachingAuthorizer(options.PrometheusRegistry) } - if options.UserMaintenanceWindowDuration < time.Hour { - return nil, xerrors.Errorf("user maintenance window duration must be at least 1 hour") + if options.QuietHoursWindowDuration < time.Hour { + return nil, xerrors.Errorf("quiet hours window duration must be at least 1 hour") } ctx, cancelFunc := context.WithCancel(ctx) @@ -203,15 +203,15 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { r.Get("/", api.appearance) r.Put("/", api.putAppearance) }) - r.Route("/users/{user}/maintenance-schedule", func(r chi.Router) { + r.Route("/users/{user}/quiet-hours", func(r chi.Router) { r.Use( // TODO: enabled MW? apiKeyMiddleware, httpmw.ExtractUserParam(options.Database, false), ) - r.Get("/", api.userMaintenanceSchedule) - r.Put("/", api.putUserMaintenanceSchedule) + r.Get("/", api.userQuietHoursSchedule) + r.Put("/", api.putUserQuietHoursSchedule) }) }) @@ -307,9 +307,9 @@ type Options struct { DERPServerRelayAddress string DERPServerRegionID int - // Used for user maintenance schedules. - DefaultUserMaintenanceSchedule string // cron schedule, if empty user maintenance schedules are disabled - UserMaintenanceWindowDuration time.Duration // how long each window should last + // Used for user quiet hours schedules. + DefaultQuietHoursSchedule string // cron schedule, if empty user quiet hours schedules are disabled + QuietHoursWindowDuration time.Duration // how long each window should last EntitlementsUpdateInterval time.Duration ProxyHealthInterval time.Duration @@ -362,7 +362,7 @@ func (api *API) updateEntitlements(ctx context.Context) error { codersdk.FeatureTemplateRBAC: api.RBAC, codersdk.FeatureExternalProvisionerDaemons: true, codersdk.FeatureAdvancedTemplateScheduling: true, - codersdk.FeatureUserMaintenanceSchedule: api.DefaultUserMaintenanceSchedule != "", + codersdk.FeatureUserQuietHoursSchedule: api.DefaultQuietHoursSchedule != "", codersdk.FeatureWorkspaceProxy: true, }) if err != nil { @@ -430,17 +430,17 @@ func (api *API) updateEntitlements(ctx context.Context) error { } } - if changed, enabled := featureChanged(codersdk.FeatureUserMaintenanceSchedule); changed { - if enabled && api.DefaultUserMaintenanceSchedule != "" { - store, err := schedule.NewEnterpriseUserMaintenanceScheduleStore(api.DefaultUserMaintenanceSchedule, api.UserMaintenanceWindowDuration) + if changed, enabled := featureChanged(codersdk.FeatureUserQuietHoursSchedule); changed { + if enabled && api.DefaultQuietHoursSchedule != "" { + store, err := schedule.NewEnterpriseUserQuietHoursScheduleStore(api.DefaultQuietHoursSchedule, api.QuietHoursWindowDuration) if err != nil { - api.Logger.Error(ctx, "unable to set up enterprise user maintenance schedule store, maintenance schedules will not be applied", slog.Error(err)) + api.Logger.Error(ctx, "unable to set up enterprise user quiet hours schedule store, quiet hours schedules will not be applied", slog.Error(err)) } else { - api.AGPL.UserMaintenanceScheduleStore.Store(&store) + api.AGPL.UserQuietHoursScheduleStore.Store(&store) } } else { - store := agplschedule.NewAGPLUserMaintenanceScheduleStore() - api.AGPL.UserMaintenanceScheduleStore.Store(&store) + store := agplschedule.NewAGPLUserQuietHoursScheduleStore() + api.AGPL.UserQuietHoursScheduleStore.Store(&store) } } diff --git a/enterprise/coderd/coderd_test.go b/enterprise/coderd/coderd_test.go index c12c7f9e9c9b8..0f416be0fdad3 100644 --- a/enterprise/coderd/coderd_test.go +++ b/enterprise/coderd/coderd_test.go @@ -53,7 +53,7 @@ func TestEntitlements(t *testing.T) { codersdk.FeatureTemplateRBAC: 1, codersdk.FeatureExternalProvisionerDaemons: 1, codersdk.FeatureAdvancedTemplateScheduling: 1, - codersdk.FeatureUserMaintenanceSchedule: 1, + codersdk.FeatureUserQuietHoursSchedule: 1, codersdk.FeatureWorkspaceProxy: 1, }, GraceAt: time.Now().Add(59 * 24 * time.Hour), diff --git a/enterprise/coderd/provisionerdaemons.go b/enterprise/coderd/provisionerdaemons.go index f7fd3ce372b5e..055704a6bcb11 100644 --- a/enterprise/coderd/provisionerdaemons.go +++ b/enterprise/coderd/provisionerdaemons.go @@ -217,21 +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, - UserMaintenanceScheduleStore: api.AGPL.UserMaintenanceScheduleStore, - 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)) diff --git a/enterprise/coderd/schedule/user.go b/enterprise/coderd/schedule/user.go index 99f44fdd4e108..8004cfd816c76 100644 --- a/enterprise/coderd/schedule/user.go +++ b/enterprise/coderd/schedule/user.go @@ -12,17 +12,17 @@ import ( agpl "github.com/coder/coder/coderd/schedule" ) -// enterpriseUserMaintenanceScheduleStore provides an -// agpl.UserMaintenanceScheduleStore that has all fields implemented for +// enterpriseUserQuietHoursScheduleStore provides an +// agpl.UserQuietHoursScheduleStore that has all fields implemented for // enterprise customers. -type enterpriseUserMaintenanceScheduleStore struct { +type enterpriseUserQuietHoursScheduleStore struct { defaultSchedule string windowDuration time.Duration } -var _ agpl.UserMaintenanceScheduleStore = &enterpriseUserMaintenanceScheduleStore{} +var _ agpl.UserQuietHoursScheduleStore = &enterpriseUserQuietHoursScheduleStore{} -func NewEnterpriseUserMaintenanceScheduleStore(defaultSchedule string, windowDuration time.Duration) (agpl.UserMaintenanceScheduleStore, error) { +func NewEnterpriseUserQuietHoursScheduleStore(defaultSchedule string, windowDuration time.Duration) (agpl.UserQuietHoursScheduleStore, error) { if defaultSchedule == "" { return nil, xerrors.Errorf("default schedule must be set") } @@ -30,7 +30,7 @@ func NewEnterpriseUserMaintenanceScheduleStore(defaultSchedule string, windowDur return nil, xerrors.Errorf("window duration must be greater than 1 hour") } - s := &enterpriseUserMaintenanceScheduleStore{ + s := &enterpriseUserQuietHoursScheduleStore{ defaultSchedule: defaultSchedule, windowDuration: windowDuration, } @@ -43,7 +43,7 @@ func NewEnterpriseUserMaintenanceScheduleStore(defaultSchedule string, windowDur return s, nil } -func (s *enterpriseUserMaintenanceScheduleStore) parseSchedule(rawSchedule string) (agpl.UserMaintenanceScheduleOptions, error) { +func (s *enterpriseUserQuietHoursScheduleStore) parseSchedule(rawSchedule string) (agpl.UserQuietHoursScheduleOptions, error) { userSet := true if strings.TrimSpace(rawSchedule) == "" { userSet = false @@ -53,30 +53,30 @@ func (s *enterpriseUserMaintenanceScheduleStore) parseSchedule(rawSchedule strin sched, err := agpl.Daily(rawSchedule) if err != nil { // This shouldn't get hit during Gets, only Sets. - return agpl.UserMaintenanceScheduleOptions{}, xerrors.Errorf("parse daily schedule %q: %w", rawSchedule, err) + return agpl.UserQuietHoursScheduleOptions{}, xerrors.Errorf("parse daily schedule %q: %w", rawSchedule, err) } if strings.HasPrefix(sched.Time(), "cron(") { // This shouldn't get hit during Gets, only Sets. - return agpl.UserMaintenanceScheduleOptions{}, xerrors.Errorf("daily schedule %q has more than one time: %v", rawSchedule, sched.Time()) + return agpl.UserQuietHoursScheduleOptions{}, xerrors.Errorf("daily schedule %q has more than one time: %v", rawSchedule, sched.Time()) } - return agpl.UserMaintenanceScheduleOptions{ + return agpl.UserQuietHoursScheduleOptions{ Schedule: sched, UserSet: userSet, Duration: s.windowDuration, }, nil } -func (s *enterpriseUserMaintenanceScheduleStore) GetUserMaintenanceScheduleOptions(ctx context.Context, db database.Store, userID uuid.UUID) (agpl.UserMaintenanceScheduleOptions, error) { +func (s *enterpriseUserQuietHoursScheduleStore) GetUserQuietHoursScheduleOptions(ctx context.Context, db database.Store, userID uuid.UUID) (agpl.UserQuietHoursScheduleOptions, error) { user, err := db.GetUserByID(ctx, userID) if err != nil { - return agpl.UserMaintenanceScheduleOptions{}, xerrors.Errorf("get user by ID: %w", err) + return agpl.UserQuietHoursScheduleOptions{}, xerrors.Errorf("get user by ID: %w", err) } - return s.parseSchedule(user.MaintenanceSchedule) + return s.parseSchedule(user.QuietHoursSchedule) } -func (s *enterpriseUserMaintenanceScheduleStore) SetUserMaintenanceScheduleOptions(ctx context.Context, db database.Store, userID uuid.UUID, rawSchedule string) (agpl.UserMaintenanceScheduleOptions, error) { +func (s *enterpriseUserQuietHoursScheduleStore) SetUserQuietHoursScheduleOptions(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 @@ -87,12 +87,12 @@ func (s *enterpriseUserMaintenanceScheduleStore) SetUserMaintenanceScheduleOptio if opts.UserSet { rawSchedule = opts.Schedule.String() } - _, err = db.UpdateUserMaintenanceSchedule(ctx, database.UpdateUserMaintenanceScheduleParams{ - ID: userID, - MaintenanceSchedule: rawSchedule, + _, err = db.UpdateUserQuietHoursSchedule(ctx, database.UpdateUserQuietHoursScheduleParams{ + ID: userID, + QuietHoursSchedule: rawSchedule, }) if err != nil { - return agpl.UserMaintenanceScheduleOptions{}, xerrors.Errorf("update user maintenance schedule: %w", err) + return agpl.UserQuietHoursScheduleOptions{}, xerrors.Errorf("update user quiet hours schedule: %w", err) } // TODO: update max_ttl for all active builds for this user to clamp to the diff --git a/enterprise/coderd/users.go b/enterprise/coderd/users.go index 475a297b0dd02..424881fc97001 100644 --- a/enterprise/coderd/users.go +++ b/enterprise/coderd/users.go @@ -11,22 +11,22 @@ import ( "github.com/coder/coder/codersdk" ) -// @Summary Get user maintenance schedule -// @ID get-user-maintenance-schedule +// @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.UserMaintenanceScheduleResponse -// @Router /users/{user}/maintenance-schedule [get] -func (api *API) userMaintenanceSchedule(rw http.ResponseWriter, r *http.Request) { +// @Success 200 {array} codersdk.UserQuietHoursScheduleResponse +// @Router /users/{user}/quiet-hours-schedule [get] +func (api *API) userQuietHoursSchedule(rw http.ResponseWriter, r *http.Request) { var ( ctx = r.Context() user = httpmw.UserParam(r) ) // TODO: Double query here cuz of the user param - opts, err := (*api.UserMaintenanceScheduleStore.Load()).GetUserMaintenanceScheduleOptions(ctx, api.Database, user.ID) + opts, err := (*api.UserQuietHoursScheduleStore.Load()).GetUserQuietHoursScheduleOptions(ctx, api.Database, user.ID) if err != nil { // TODO: some of these errors are related to bad syntax, would be nice // to 400 @@ -38,7 +38,7 @@ func (api *API) userMaintenanceSchedule(rw http.ResponseWriter, r *http.Request) return } - httpapi.Write(ctx, rw, http.StatusOK, codersdk.UserMaintenanceScheduleResponse{ + httpapi.Write(ctx, rw, http.StatusOK, codersdk.UserQuietHoursScheduleResponse{ RawSchedule: opts.Schedule.String(), UserSet: opts.UserSet, Time: opts.Schedule.Time(), @@ -48,21 +48,21 @@ func (api *API) userMaintenanceSchedule(rw http.ResponseWriter, r *http.Request) }) } -// @Summary Update user maintenance schedule -// @ID update-user-maintenance-schedule +// @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.UpdateUserMaintenanceScheduleRequest true "Update schedule request" -// @Success 200 {array} codersdk.UserMaintenanceScheduleResponse -// @Router /users/{user}/maintenance-schedule [put] -func (api *API) putUserMaintenanceSchedule(rw http.ResponseWriter, r *http.Request) { +// @Param request body codersdk.UpdateUserQuietHoursScheduleRequest true "Update schedule request" +// @Success 200 {array} codersdk.UserQuietHoursScheduleResponse +// @Router /users/{user}/quiet-hours-schedule [put] +func (api *API) putUserQuietHoursSchedule(rw http.ResponseWriter, r *http.Request) { var ( ctx = r.Context() user = httpmw.UserParam(r) - params codersdk.UpdateUserMaintenanceScheduleRequest + params codersdk.UpdateUserQuietHoursScheduleRequest aReq, commitAudit = audit.InitRequest[database.User](rw, &audit.RequestParams{ Audit: api.Auditor, Log: api.Logger, @@ -77,7 +77,7 @@ func (api *API) putUserMaintenanceSchedule(rw http.ResponseWriter, r *http.Reque return } - opts, err := (*api.UserMaintenanceScheduleStore.Load()).SetUserMaintenanceScheduleOptions(ctx, api.Database, user.ID, params.Schedule) + opts, err := (*api.UserQuietHoursScheduleStore.Load()).SetUserQuietHoursScheduleOptions(ctx, api.Database, user.ID, params.Schedule) if err != nil { // TODO: some of these errors are related to bad syntax, would be nice // to 400 @@ -85,7 +85,7 @@ func (api *API) putUserMaintenanceSchedule(rw http.ResponseWriter, r *http.Reque return } - httpapi.Write(ctx, rw, http.StatusOK, codersdk.UserMaintenanceScheduleResponse{ + httpapi.Write(ctx, rw, http.StatusOK, codersdk.UserQuietHoursScheduleResponse{ RawSchedule: opts.Schedule.String(), UserSet: opts.UserSet, Time: opts.Schedule.Time(), diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 3f6d7d7a3dce0..4fc911373477b 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -372,6 +372,7 @@ export interface DeploymentValues { readonly wgtunnel_host?: string readonly disable_owner_workspace_exec?: boolean readonly proxy_health_status_interval?: number + readonly user_quiet_hours_schedule?: UserQuietHoursScheduleConfig // This is likely an enum in an external package ("github.com/coder/coder/cli/clibase.YAMLConfigPath") readonly config?: string readonly write_config?: boolean @@ -977,11 +978,6 @@ export interface UpdateTemplateMeta { readonly locked_ttl_ms?: number } -// From codersdk/users.go -export interface UpdateUserMaintenanceScheduleRequest { - readonly schedule: string -} - // From codersdk/users.go export interface UpdateUserPasswordRequest { readonly old_password: string @@ -993,6 +989,11 @@ export interface UpdateUserProfileRequest { readonly username: string } +// From codersdk/users.go +export interface UpdateUserQuietHoursScheduleRequest { + readonly schedule: string +} + // From codersdk/workspaces.go export interface UpdateWorkspaceAutostartRequest { readonly schedule?: string @@ -1032,8 +1033,14 @@ export interface User { readonly avatar_url: string } +// From codersdk/deployment.go +export interface UserQuietHoursScheduleConfig { + readonly default_schedule: string + readonly window_duration: number +} + // From codersdk/users.go -export interface UserMaintenanceScheduleResponse { +export interface UserQuietHoursScheduleResponse { readonly raw_schedule: string readonly user_set: boolean readonly time: string @@ -1377,7 +1384,7 @@ export type FeatureName = | "scim" | "template_rbac" | "user_limit" - | "user_maintenance_schedule" + | "user_quiet_hours" | "workspace_proxy" export const FeatureNames: FeatureName[] = [ "advanced_template_scheduling", @@ -1390,7 +1397,7 @@ export const FeatureNames: FeatureName[] = [ "scim", "template_rbac", "user_limit", - "user_maintenance_schedule", + "user_quiet_hours", "workspace_proxy", ] From ede278e44b920529464d86a4d2548cc1d7815ca3 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Wed, 28 Jun 2023 15:42:35 +0000 Subject: [PATCH 05/25] stuff --- cli/testdata/coder_server_--help.golden | 19 ++++ cli/testdata/server-config.yaml.golden | 16 +++ coderd/database/dbauthz/dbauthz.go | 14 +-- coderd/database/dbfake/dbfake.go | 12 +- coderd/database/dbmetrics/dbmetrics.go | 14 +-- coderd/database/dbmock/dbmock.go | 24 ++-- ...000131_user_quiet_hours_schedule.down.sql} | 0 ...> 000131_user_quiet_hours_schedule.up.sql} | 0 coderd/database/querier.go | 2 +- coderd/database/queries.sql.go | 106 +++++++++--------- codersdk/deployment.go | 2 +- .../cli/testdata/coder_server_--help.golden | 19 ++++ enterprise/coderd/coderd.go | 2 +- .../coderd/coderdenttest/coderdenttest.go | 2 + enterprise/coderd/users.go | 4 +- 15 files changed, 146 insertions(+), 90 deletions(-) rename coderd/database/migrations/{000130_user_quiet_hours_schedule.down.sql => 000131_user_quiet_hours_schedule.down.sql} (100%) rename coderd/database/migrations/{000130_user_quiet_hours_schedule.up.sql => 000131_user_quiet_hours_schedule.up.sql} (100%) diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index 70e81cd42c997..ea1cdd69eea07 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -371,6 +371,25 @@ 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). + + --quiet-hours-window-duration duration, $CODER_QUIET_HOURS_WINDOW_DURATION (default: 4h0m0s) + The duration of quiet hours windows when triggered by cron. Workspaces + can only be stopped due to max TTL during this window. Must be at + least 1 hour. + ⚠️ 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. diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index bfd9ed467bca8..c185774130a87 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -363,3 +363,19 @@ supportLinks: [] # "tunnel.example.com". # (default: , type: string) wgtunnelHost: "" +# Allow users to set quiet hours schedules each day for workspaces to avoid +# workspaces stopping during the day due to template max TTL. +userQuietHoursSchedule: + # 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). + # (default: , type: string) + defaultQuietHoursSchedule: "" + # The duration of quiet hours windows when triggered by cron. Workspaces can only + # be stopped due to max TTL during this window. Must be at least 1 hour. + # (default: 4h0m0s, type: duration) + quietHoursWindowDuration: 4h0m0s diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index d5e2417ab5e4e..0260def9c6cfd 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -2348,13 +2348,6 @@ func (q *querier) UpdateUserLinkedID(ctx context.Context, arg database.UpdateUse return q.db.UpdateUserLinkedID(ctx, arg) } -func (q *querier) UpdateUserQuietHoursSchedule(ctx context.Context, arg database.UpdateUserQuietHoursScheduleParams) (database.User, error) { - fetch := func(ctx context.Context, arg database.UpdateUserQuietHoursScheduleParams) (database.User, error) { - return q.db.GetUserByID(ctx, arg.ID) - } - return updateWithReturn(q.log, q.auth, fetch, q.db.UpdateUserQuietHoursSchedule)(ctx, arg) -} - func (q *querier) UpdateUserProfile(ctx context.Context, arg database.UpdateUserProfileParams) (database.User, error) { u, err := q.db.GetUserByID(ctx, arg.ID) if err != nil { @@ -2366,6 +2359,13 @@ func (q *querier) UpdateUserProfile(ctx context.Context, arg database.UpdateUser return q.db.UpdateUserProfile(ctx, arg) } +func (q *querier) UpdateUserQuietHoursSchedule(ctx context.Context, arg database.UpdateUserQuietHoursScheduleParams) (database.User, error) { + fetch := func(ctx context.Context, arg database.UpdateUserQuietHoursScheduleParams) (database.User, error) { + return q.db.GetUserByID(ctx, arg.ID) + } + return updateWithReturn(q.log, q.auth, fetch, q.db.UpdateUserQuietHoursSchedule)(ctx, arg) +} + // UpdateUserRoles updates the site roles of a user. The validation for this function include more than // just a basic RBAC check. func (q *querier) UpdateUserRoles(ctx context.Context, arg database.UpdateUserRolesParams) (database.User, error) { diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go index 47ae85df4f2ea..0b31a90998b3b 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -4870,7 +4870,7 @@ func (q *fakeQuerier) UpdateUserLinkedID(_ context.Context, params database.Upda return database.UserLink{}, sql.ErrNoRows } -func (q *fakeQuerier) UpdateUserQuietHoursSchedule(_ context.Context, arg database.UpdateUserQuietHoursScheduleParams) (database.User, error) { +func (q *fakeQuerier) UpdateUserProfile(_ context.Context, arg database.UpdateUserProfileParams) (database.User, error) { if err := validateDatabaseType(arg); err != nil { return database.User{}, err } @@ -4882,14 +4882,16 @@ func (q *fakeQuerier) UpdateUserQuietHoursSchedule(_ context.Context, arg databa if user.ID != arg.ID { continue } - user.QuietHoursSchedule = arg.QuietHoursSchedule + user.Email = arg.Email + user.Username = arg.Username + user.AvatarURL = arg.AvatarURL q.users[index] = user return user, nil } return database.User{}, sql.ErrNoRows } -func (q *fakeQuerier) UpdateUserProfile(_ context.Context, arg database.UpdateUserProfileParams) (database.User, error) { +func (q *fakeQuerier) UpdateUserQuietHoursSchedule(_ context.Context, arg database.UpdateUserQuietHoursScheduleParams) (database.User, error) { if err := validateDatabaseType(arg); err != nil { return database.User{}, err } @@ -4901,9 +4903,7 @@ func (q *fakeQuerier) UpdateUserProfile(_ context.Context, arg database.UpdateUs if user.ID != arg.ID { continue } - user.Email = arg.Email - user.Username = arg.Username - user.AvatarURL = arg.AvatarURL + user.QuietHoursSchedule = arg.QuietHoursSchedule q.users[index] = user return user, nil } diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index b25379c6a2e92..9a62262bb1397 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -1426,13 +1426,6 @@ func (m metricsStore) UpdateUserLinkedID(ctx context.Context, arg database.Updat return link, err } -func (m metricsStore) UpdateUserQuietHoursSchedule(ctx context.Context, arg database.UpdateUserQuietHoursScheduleParams) (database.User, error) { - start := time.Now() - r0, r1 := m.s.UpdateUserQuietHoursSchedule(ctx, arg) - m.queryLatencies.WithLabelValues("UpdateUserQuietHoursSchedule").Observe(time.Since(start).Seconds()) - return r0, r1 -} - func (m metricsStore) UpdateUserProfile(ctx context.Context, arg database.UpdateUserProfileParams) (database.User, error) { start := time.Now() user, err := m.s.UpdateUserProfile(ctx, arg) @@ -1440,6 +1433,13 @@ func (m metricsStore) UpdateUserProfile(ctx context.Context, arg database.Update return user, err } +func (m metricsStore) UpdateUserQuietHoursSchedule(ctx context.Context, arg database.UpdateUserQuietHoursScheduleParams) (database.User, error) { + start := time.Now() + r0, r1 := m.s.UpdateUserQuietHoursSchedule(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateUserQuietHoursSchedule").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m metricsStore) UpdateUserRoles(ctx context.Context, arg database.UpdateUserRolesParams) (database.User, error) { start := time.Now() user, err := m.s.UpdateUserRoles(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 297d81b5c09bd..2512698bb222c 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -2949,34 +2949,34 @@ func (mr *MockStoreMockRecorder) UpdateUserLinkedID(arg0, arg1 interface{}) *gom return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserLinkedID", reflect.TypeOf((*MockStore)(nil).UpdateUserLinkedID), arg0, arg1) } -// UpdateUserQuietHoursSchedule mocks base method. -func (m *MockStore) UpdateUserQuietHoursSchedule(arg0 context.Context, arg1 database.UpdateUserQuietHoursScheduleParams) (database.User, error) { +// UpdateUserProfile mocks base method. +func (m *MockStore) UpdateUserProfile(arg0 context.Context, arg1 database.UpdateUserProfileParams) (database.User, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateUserQuietHoursSchedule", arg0, arg1) + ret := m.ctrl.Call(m, "UpdateUserProfile", arg0, arg1) ret0, _ := ret[0].(database.User) ret1, _ := ret[1].(error) return ret0, ret1 } -// UpdateUserQuietHoursSchedule indicates an expected call of UpdateUserQuietHoursSchedule. -func (mr *MockStoreMockRecorder) UpdateUserQuietHoursSchedule(arg0, arg1 interface{}) *gomock.Call { +// UpdateUserProfile indicates an expected call of UpdateUserProfile. +func (mr *MockStoreMockRecorder) UpdateUserProfile(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserQuietHoursSchedule", reflect.TypeOf((*MockStore)(nil).UpdateUserQuietHoursSchedule), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserProfile", reflect.TypeOf((*MockStore)(nil).UpdateUserProfile), arg0, arg1) } -// UpdateUserProfile mocks base method. -func (m *MockStore) UpdateUserProfile(arg0 context.Context, arg1 database.UpdateUserProfileParams) (database.User, error) { +// UpdateUserQuietHoursSchedule mocks base method. +func (m *MockStore) UpdateUserQuietHoursSchedule(arg0 context.Context, arg1 database.UpdateUserQuietHoursScheduleParams) (database.User, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateUserProfile", arg0, arg1) + ret := m.ctrl.Call(m, "UpdateUserQuietHoursSchedule", arg0, arg1) ret0, _ := ret[0].(database.User) ret1, _ := ret[1].(error) return ret0, ret1 } -// UpdateUserProfile indicates an expected call of UpdateUserProfile. -func (mr *MockStoreMockRecorder) UpdateUserProfile(arg0, arg1 interface{}) *gomock.Call { +// UpdateUserQuietHoursSchedule indicates an expected call of UpdateUserQuietHoursSchedule. +func (mr *MockStoreMockRecorder) UpdateUserQuietHoursSchedule(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserProfile", reflect.TypeOf((*MockStore)(nil).UpdateUserProfile), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserQuietHoursSchedule", reflect.TypeOf((*MockStore)(nil).UpdateUserQuietHoursSchedule), arg0, arg1) } // UpdateUserRoles mocks base method. diff --git a/coderd/database/migrations/000130_user_quiet_hours_schedule.down.sql b/coderd/database/migrations/000131_user_quiet_hours_schedule.down.sql similarity index 100% rename from coderd/database/migrations/000130_user_quiet_hours_schedule.down.sql rename to coderd/database/migrations/000131_user_quiet_hours_schedule.down.sql diff --git a/coderd/database/migrations/000130_user_quiet_hours_schedule.up.sql b/coderd/database/migrations/000131_user_quiet_hours_schedule.up.sql similarity index 100% rename from coderd/database/migrations/000130_user_quiet_hours_schedule.up.sql rename to coderd/database/migrations/000131_user_quiet_hours_schedule.up.sql diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 448c193249005..ac785d63b301b 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -239,8 +239,8 @@ type sqlcQuerier interface { UpdateUserLastSeenAt(ctx context.Context, arg UpdateUserLastSeenAtParams) (User, error) UpdateUserLink(ctx context.Context, arg UpdateUserLinkParams) (UserLink, error) UpdateUserLinkedID(ctx context.Context, arg UpdateUserLinkedIDParams) (UserLink, error) - UpdateUserQuietHoursSchedule(ctx context.Context, arg UpdateUserQuietHoursScheduleParams) (User, error) UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) (User, error) + UpdateUserQuietHoursSchedule(ctx context.Context, arg UpdateUserQuietHoursScheduleParams) (User, error) UpdateUserRoles(ctx context.Context, arg UpdateUserRolesParams) (User, error) UpdateUserStatus(ctx context.Context, arg UpdateUserStatusParams) (User, error) UpdateWorkspace(ctx context.Context, arg UpdateWorkspaceParams) (Workspace, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 47d70b5b727cb..2667c1a454e5a 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -1066,7 +1066,7 @@ func (q *sqlQuerier) DeleteGroupMembersByOrgAndUser(ctx context.Context, arg Del const getGroupMembers = `-- name: GetGroupMembers :many SELECT - users.id, users.email, users.username, users.hashed_password, users.created_at, users.updated_at, users.status, users.rbac_roles, users.login_type, users.avatar_url, users.deleted, users.last_seen_at, users.maintenance_schedule + users.id, users.email, users.username, users.hashed_password, users.created_at, users.updated_at, users.status, users.rbac_roles, users.login_type, users.avatar_url, users.deleted, users.last_seen_at, users.quiet_hours_schedule FROM users JOIN @@ -5118,7 +5118,7 @@ func (q *sqlQuerier) GetFilteredUserCount(ctx context.Context, arg GetFilteredUs const getUserByEmailOrUsername = `-- name: GetUserByEmailOrUsername :one SELECT - id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, maintenance_schedule + id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule FROM users WHERE @@ -5156,7 +5156,7 @@ func (q *sqlQuerier) GetUserByEmailOrUsername(ctx context.Context, arg GetUserBy const getUserByID = `-- name: GetUserByID :one SELECT - id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, maintenance_schedule + id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule FROM users WHERE @@ -5204,7 +5204,7 @@ func (q *sqlQuerier) GetUserCount(ctx context.Context) (int64, error) { const getUsers = `-- name: GetUsers :many SELECT - id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, maintenance_schedule, COUNT(*) OVER() AS count + id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, COUNT(*) OVER() AS count FROM users WHERE @@ -5285,20 +5285,20 @@ type GetUsersParams struct { } type GetUsersRow struct { - ID uuid.UUID `db:"id" json:"id"` - Email string `db:"email" json:"email"` - Username string `db:"username" json:"username"` - HashedPassword []byte `db:"hashed_password" json:"hashed_password"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` - Status UserStatus `db:"status" json:"status"` - RBACRoles pq.StringArray `db:"rbac_roles" json:"rbac_roles"` - LoginType LoginType `db:"login_type" json:"login_type"` - AvatarURL sql.NullString `db:"avatar_url" json:"avatar_url"` - Deleted bool `db:"deleted" json:"deleted"` - LastSeenAt time.Time `db:"last_seen_at" json:"last_seen_at"` - MaintenanceSchedule string `db:"maintenance_schedule" json:"maintenance_schedule"` - Count int64 `db:"count" json:"count"` + ID uuid.UUID `db:"id" json:"id"` + Email string `db:"email" json:"email"` + Username string `db:"username" json:"username"` + HashedPassword []byte `db:"hashed_password" json:"hashed_password"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + Status UserStatus `db:"status" json:"status"` + RBACRoles pq.StringArray `db:"rbac_roles" json:"rbac_roles"` + LoginType LoginType `db:"login_type" json:"login_type"` + AvatarURL sql.NullString `db:"avatar_url" json:"avatar_url"` + Deleted bool `db:"deleted" json:"deleted"` + LastSeenAt time.Time `db:"last_seen_at" json:"last_seen_at"` + QuietHoursSchedule string `db:"quiet_hours_schedule" json:"quiet_hours_schedule"` + Count int64 `db:"count" json:"count"` } // This will never return deleted users. @@ -5333,7 +5333,7 @@ func (q *sqlQuerier) GetUsers(ctx context.Context, arg GetUsersParams) ([]GetUse &i.AvatarURL, &i.Deleted, &i.LastSeenAt, - &i.MaintenanceSchedule, + &i.QuietHoursSchedule, &i.Count, ); err != nil { return nil, err @@ -5350,7 +5350,7 @@ func (q *sqlQuerier) GetUsers(ctx context.Context, arg GetUsersParams) ([]GetUse } const getUsersByIDs = `-- name: GetUsersByIDs :many -SELECT id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, maintenance_schedule FROM users WHERE id = ANY($1 :: uuid [ ]) +SELECT id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule FROM users WHERE id = ANY($1 :: uuid [ ]) ` // This shouldn't check for deleted, because it's frequently used @@ -5406,7 +5406,7 @@ INSERT INTO login_type ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, maintenance_schedule + ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule ` type InsertUserParams struct { @@ -5495,7 +5495,7 @@ SET last_seen_at = $2, updated_at = $3 WHERE - id = $1 RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, maintenance_schedule + id = $1 RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule ` type UpdateUserLastSeenAtParams struct { @@ -5525,23 +5525,34 @@ func (q *sqlQuerier) UpdateUserLastSeenAt(ctx context.Context, arg UpdateUserLas return i, err } -const UpdateUserQuietHoursSchedule = `-- name: UpdateUserQuietHoursSchedule :one +const updateUserProfile = `-- name: UpdateUserProfile :one UPDATE users SET - maintenance_schedule = $2 + email = $2, + username = $3, + avatar_url = $4, + updated_at = $5 WHERE - id = $1 -RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, maintenance_schedule + id = $1 RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule ` -type UpdateUserQuietHoursScheduleParams struct { - ID uuid.UUID `db:"id" json:"id"` - QuietHoursSchedule string `db:"quiet_hours_schedule" json:"quiet_hours_schedule"` +type UpdateUserProfileParams struct { + ID uuid.UUID `db:"id" json:"id"` + Email string `db:"email" json:"email"` + Username string `db:"username" json:"username"` + AvatarURL sql.NullString `db:"avatar_url" json:"avatar_url"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` } -func (q *sqlQuerier) UpdateUserQuietHoursSchedule(ctx context.Context, arg UpdateUserQuietHoursScheduleParams) (User, error) { - row := q.db.QueryRowContext(ctx, UpdateUserQuietHoursSchedule, arg.ID, arg.QuietHoursSchedule) +func (q *sqlQuerier) UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) (User, error) { + row := q.db.QueryRowContext(ctx, updateUserProfile, + arg.ID, + arg.Email, + arg.Username, + arg.AvatarURL, + arg.UpdatedAt, + ) var i User err := row.Scan( &i.ID, @@ -5561,34 +5572,23 @@ func (q *sqlQuerier) UpdateUserQuietHoursSchedule(ctx context.Context, arg Updat return i, err } -const updateUserProfile = `-- name: UpdateUserProfile :one +const updateUserQuietHoursSchedule = `-- name: UpdateUserQuietHoursSchedule :one UPDATE users SET - email = $2, - username = $3, - avatar_url = $4, - updated_at = $5 + quiet_hours_schedule = $2 WHERE - id = $1 RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, maintenance_schedule + id = $1 +RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule ` -type UpdateUserProfileParams struct { - ID uuid.UUID `db:"id" json:"id"` - Email string `db:"email" json:"email"` - Username string `db:"username" json:"username"` - AvatarURL sql.NullString `db:"avatar_url" json:"avatar_url"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +type UpdateUserQuietHoursScheduleParams struct { + ID uuid.UUID `db:"id" json:"id"` + QuietHoursSchedule string `db:"quiet_hours_schedule" json:"quiet_hours_schedule"` } -func (q *sqlQuerier) UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) (User, error) { - row := q.db.QueryRowContext(ctx, updateUserProfile, - arg.ID, - arg.Email, - arg.Username, - arg.AvatarURL, - arg.UpdatedAt, - ) +func (q *sqlQuerier) UpdateUserQuietHoursSchedule(ctx context.Context, arg UpdateUserQuietHoursScheduleParams) (User, error) { + row := q.db.QueryRowContext(ctx, updateUserQuietHoursSchedule, arg.ID, arg.QuietHoursSchedule) var i User err := row.Scan( &i.ID, @@ -5616,7 +5616,7 @@ SET rbac_roles = ARRAY(SELECT DISTINCT UNNEST($1 :: text[])) WHERE id = $2 -RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, maintenance_schedule +RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule ` type UpdateUserRolesParams struct { @@ -5652,7 +5652,7 @@ SET status = $2, updated_at = $3 WHERE - id = $1 RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, maintenance_schedule + id = $1 RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule ` type UpdateUserStatusParams struct { diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 56683eaf1dd34..fd65bc13a651d 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -1584,7 +1584,7 @@ Write out the current server config as YAML to stdout.`, Flag: "quiet-hours-window-duration", Env: "CODER_QUIET_HOURS_WINDOW_DURATION", Default: (4 * time.Hour).String(), - Value: &c.UserQuietHoursSchedule.DefaultSchedule, + Value: &c.UserQuietHoursSchedule.WindowDuration, Group: &deploymentGroupUserQuietHoursSchedule, YAML: "quietHoursWindowDuration", }, diff --git a/enterprise/cli/testdata/coder_server_--help.golden b/enterprise/cli/testdata/coder_server_--help.golden index 70e81cd42c997..ea1cdd69eea07 100644 --- a/enterprise/cli/testdata/coder_server_--help.golden +++ b/enterprise/cli/testdata/coder_server_--help.golden @@ -371,6 +371,25 @@ 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). + + --quiet-hours-window-duration duration, $CODER_QUIET_HOURS_WINDOW_DURATION (default: 4h0m0s) + The duration of quiet hours windows when triggered by cron. Workspaces + can only be stopped due to max TTL during this window. Must be at + least 1 hour. + ⚠️ 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. diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 0ce064ec76b43..d31d91b609032 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -53,7 +53,7 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { options.Options.Authorizer = rbac.NewCachingAuthorizer(options.PrometheusRegistry) } if options.QuietHoursWindowDuration < time.Hour { - return nil, xerrors.Errorf("quiet hours window duration must be at least 1 hour") + return nil, xerrors.Errorf("quiet hours window duration (%v) must be at least 1 hour", options.QuietHoursWindowDuration) } ctx, cancelFunc := context.WithCancel(ctx) diff --git a/enterprise/coderd/coderdenttest/coderdenttest.go b/enterprise/coderd/coderdenttest/coderdenttest.go index 06758d013ded8..9d684ddcb0300 100644 --- a/enterprise/coderd/coderdenttest/coderdenttest.go +++ b/enterprise/coderd/coderdenttest/coderdenttest.go @@ -77,6 +77,8 @@ func NewWithAPI(t *testing.T, options *Options) (*codersdk.Client, io.Closer, *c EntitlementsUpdateInterval: options.EntitlementsUpdateInterval, Keys: Keys, ProxyHealthInterval: options.ProxyHealthInterval, + DefaultQuietHoursSchedule: oop.DeploymentValues.UserQuietHoursSchedule.DefaultSchedule.Value(), + QuietHoursWindowDuration: oop.DeploymentValues.UserQuietHoursSchedule.WindowDuration.Value(), }) require.NoError(t, err) setHandler(coderAPI.AGPL.RootHandler) diff --git a/enterprise/coderd/users.go b/enterprise/coderd/users.go index 424881fc97001..bc3f8a2ac25de 100644 --- a/enterprise/coderd/users.go +++ b/enterprise/coderd/users.go @@ -18,7 +18,7 @@ import ( // @Tags Enterprise // @Param user path string true "User ID" format(uuid) // @Success 200 {array} codersdk.UserQuietHoursScheduleResponse -// @Router /users/{user}/quiet-hours-schedule [get] +// @Router /users/{user}/quiet-hours [get] func (api *API) userQuietHoursSchedule(rw http.ResponseWriter, r *http.Request) { var ( ctx = r.Context() @@ -57,7 +57,7 @@ func (api *API) userQuietHoursSchedule(rw http.ResponseWriter, r *http.Request) // @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-schedule [put] +// @Router /users/{user}/quiet-hours [put] func (api *API) putUserQuietHoursSchedule(rw http.ResponseWriter, r *http.Request) { var ( ctx = r.Context() From 06272e26fc419a20d8ef682bbba17cd560d89d67 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Wed, 28 Jun 2023 16:49:18 +0000 Subject: [PATCH 06/25] progress --- coderd/database/dbauthz/dbauthz.go | 7 -- coderd/database/dbfake/dbfake.go | 20 ----- coderd/database/dbmetrics/dbmetrics.go | 7 -- coderd/database/dbmock/dbmock.go | 14 --- coderd/database/querier.go | 1 - coderd/database/queries.sql.go | 25 ------ coderd/database/queries/workspaces.sql | 14 --- .../provisionerdserver/provisionerdserver.go | 87 ++++++++++++------- .../provisionerdserver_test.go | 6 +- coderd/schedule/template.go | 58 +++++++++++-- enterprise/coderd/schedule/template.go | 43 +++------ 11 files changed, 124 insertions(+), 158 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 0260def9c6cfd..73188afc4bd89 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -2558,13 +2558,6 @@ func (q *querier) UpdateWorkspaceTTL(ctx context.Context, arg database.UpdateWor return update(q.log, q.auth, fetch, q.db.UpdateWorkspaceTTL)(ctx, arg) } -func (q *querier) UpdateWorkspaceTTLToBeWithinTemplateMax(ctx context.Context, arg database.UpdateWorkspaceTTLToBeWithinTemplateMaxParams) error { - fetch := func(ctx context.Context, arg database.UpdateWorkspaceTTLToBeWithinTemplateMaxParams) (database.Template, error) { - return q.db.GetTemplateByID(ctx, arg.TemplateID) - } - return fetchAndExec(q.log, q.auth, rbac.ActionUpdate, fetch, q.db.UpdateWorkspaceTTLToBeWithinTemplateMax)(ctx, arg) -} - func (q *querier) UpsertAppSecurityKey(ctx context.Context, data string) error { // No authz checks as this is done during startup return q.db.UpsertAppSecurityKey(ctx, data) diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go index 0b31a90998b3b..674d5abaa34bc 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -5276,26 +5276,6 @@ func (q *fakeQuerier) UpdateWorkspaceTTL(_ context.Context, arg database.UpdateW return sql.ErrNoRows } -func (q *fakeQuerier) UpdateWorkspaceTTLToBeWithinTemplateMax(_ context.Context, arg database.UpdateWorkspaceTTLToBeWithinTemplateMaxParams) error { - if err := validateDatabaseType(arg); err != nil { - return err - } - - q.mutex.Lock() - defer q.mutex.Unlock() - - for index, workspace := range q.workspaces { - if workspace.TemplateID != arg.TemplateID || !workspace.Ttl.Valid || workspace.Ttl.Int64 < arg.TemplateMaxTTL { - continue - } - - workspace.Ttl = sql.NullInt64{Int64: arg.TemplateMaxTTL, Valid: true} - q.workspaces[index] = workspace - } - - return nil -} - func (q *fakeQuerier) UpsertAppSecurityKey(_ context.Context, data string) error { q.mutex.Lock() defer q.mutex.Unlock() diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index 9a62262bb1397..1feb550674fdc 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -1559,13 +1559,6 @@ func (m metricsStore) UpdateWorkspaceTTL(ctx context.Context, arg database.Updat return r0 } -func (m metricsStore) UpdateWorkspaceTTLToBeWithinTemplateMax(ctx context.Context, arg database.UpdateWorkspaceTTLToBeWithinTemplateMaxParams) error { - start := time.Now() - r0 := m.s.UpdateWorkspaceTTLToBeWithinTemplateMax(ctx, arg) - m.queryLatencies.WithLabelValues("UpdateWorkspaceTTLToBeWithinTemplateMax").Observe(time.Since(start).Seconds()) - return r0 -} - func (m metricsStore) UpsertAppSecurityKey(ctx context.Context, value string) error { start := time.Now() r0 := m.s.UpsertAppSecurityKey(ctx, value) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 2512698bb222c..7d2e5f0b7247a 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -3223,20 +3223,6 @@ func (mr *MockStoreMockRecorder) UpdateWorkspaceTTL(arg0, arg1 interface{}) *gom return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceTTL", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceTTL), arg0, arg1) } -// UpdateWorkspaceTTLToBeWithinTemplateMax mocks base method. -func (m *MockStore) UpdateWorkspaceTTLToBeWithinTemplateMax(arg0 context.Context, arg1 database.UpdateWorkspaceTTLToBeWithinTemplateMaxParams) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateWorkspaceTTLToBeWithinTemplateMax", arg0, arg1) - ret0, _ := ret[0].(error) - return ret0 -} - -// UpdateWorkspaceTTLToBeWithinTemplateMax indicates an expected call of UpdateWorkspaceTTLToBeWithinTemplateMax. -func (mr *MockStoreMockRecorder) UpdateWorkspaceTTLToBeWithinTemplateMax(arg0, arg1 interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceTTLToBeWithinTemplateMax", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceTTLToBeWithinTemplateMax), arg0, arg1) -} - // UpsertAppSecurityKey mocks base method. func (m *MockStore) UpsertAppSecurityKey(arg0 context.Context, arg1 string) error { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index ac785d63b301b..c81b8f66ceefe 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -259,7 +259,6 @@ type sqlcQuerier interface { UpdateWorkspaceProxy(ctx context.Context, arg UpdateWorkspaceProxyParams) (WorkspaceProxy, error) UpdateWorkspaceProxyDeleted(ctx context.Context, arg UpdateWorkspaceProxyDeletedParams) error UpdateWorkspaceTTL(ctx context.Context, arg UpdateWorkspaceTTLParams) error - UpdateWorkspaceTTLToBeWithinTemplateMax(ctx context.Context, arg UpdateWorkspaceTTLToBeWithinTemplateMaxParams) error UpsertAppSecurityKey(ctx context.Context, value string) error // The default proxy is implied and not actually stored in the database. // So we need to store it's configuration here for display purposes. diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 2667c1a454e5a..7414c3be99649 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -8883,28 +8883,3 @@ func (q *sqlQuerier) UpdateWorkspaceTTL(ctx context.Context, arg UpdateWorkspace _, err := q.db.ExecContext(ctx, updateWorkspaceTTL, arg.ID, arg.Ttl) return err } - -const updateWorkspaceTTLToBeWithinTemplateMax = `-- name: UpdateWorkspaceTTLToBeWithinTemplateMax :exec -UPDATE - workspaces -SET - ttl = LEAST(ttl, $1::bigint) -WHERE - template_id = $2 - -- LEAST() does not pick NULL, so filter it out as we don't want to set a - -- TTL on the workspace if it's unset. - -- - -- During build time, the template max TTL will still be used if the - -- workspace TTL is NULL. - AND ttl IS NOT NULL -` - -type UpdateWorkspaceTTLToBeWithinTemplateMaxParams struct { - TemplateMaxTTL int64 `db:"template_max_ttl" json:"template_max_ttl"` - TemplateID uuid.UUID `db:"template_id" json:"template_id"` -} - -func (q *sqlQuerier) UpdateWorkspaceTTLToBeWithinTemplateMax(ctx context.Context, arg UpdateWorkspaceTTLToBeWithinTemplateMaxParams) error { - _, err := q.db.ExecContext(ctx, updateWorkspaceTTLToBeWithinTemplateMax, arg.TemplateMaxTTL, arg.TemplateID) - return err -} diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index 0327ff5420fec..15bbc773b3587 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -329,20 +329,6 @@ SET WHERE id = $1; --- name: UpdateWorkspaceTTLToBeWithinTemplateMax :exec -UPDATE - workspaces -SET - ttl = LEAST(ttl, @template_max_ttl::bigint) -WHERE - template_id = @template_id - -- LEAST() does not pick NULL, so filter it out as we don't want to set a - -- TTL on the workspace if it's unset. - -- - -- During build time, the template max TTL will still be used if the - -- workspace TTL is NULL. - AND ttl IS NOT NULL; - -- name: GetDeploymentWorkspaceStats :one WITH workspaces_with_jobs AS ( SELECT diff --git a/coderd/provisionerdserver/provisionerdserver.go b/coderd/provisionerdserver/provisionerdserver.go index 6e1ac4c74ac9d..816d509af7130 100644 --- a/coderd/provisionerdserver/provisionerdserver.go +++ b/coderd/provisionerdserver/provisionerdserver.go @@ -927,41 +927,68 @@ func (server *Server) CompleteJob(ctx context.Context, completed *proto.Complete deadline = now.Add(templateSchedule.DefaultTTL) } } - if templateSchedule.MaxTTL > 0 { - maxDeadline = now.Add(templateSchedule.MaxTTL) + if templateSchedule.RestartRequirement.DaysOfWeek != 0 { + // The template has a restart requirement, so determine the max + // deadline of this workspace build. - if deadline.IsZero() || maxDeadline.Before(deadline) { - // If the workspace doesn't have a deadline or the max - // deadline is sooner than the workspace deadline, use the - // max deadline as the actual deadline. + // First, get the user's quiet hours schedule (this will return + // the default if the user has not set their own schedule). + userQuietHoursSchedule, err := (*server.UserQuietHoursScheduleStore.Load()).GetUserQuietHoursScheduleOptions(ctx, db, workspace.OwnerID) + if err != nil { + return xerrors.Errorf("get user quiet hours schedule options: %w", err) + } + + // If the schedule is nil, that means the deployment isn't + // entitled to use quiet hours or the default schedule has not + // been set. In this case, do not set a max deadline on the + // workspace. + if userQuietHoursSchedule.Schedule != nil { + loc := userQuietHoursSchedule.Schedule.Location() + now := time.Now().In(loc) + startOfDay := now.Truncate(24 * time.Hour) + + // First, determine if we can restart today or if the + // schedule is too near/already passed. // - // Notably, this isn't affected by the user's quiet hours - // schedule below because we'd still like to use the max TTL - // as the TTL for the workspace if it's not set. - deadline = maxDeadline + // Allow an hour of leeway (i.e. any workspaces started + // within an hour of the scheduled stop time will always + // bounce to the next stop window). + todaySchedule := userQuietHoursSchedule.Schedule.Next(startOfDay) + if todaySchedule.Before(now.Add(-time.Hour)) { + // Set the first stop day we try to tomorrow because + // today's schedule is too close to now or has already + // passed. + startOfDay = startOfDay.Add(24 * time.Hour) + } + + // Get the current day of week and iterate through the days + // of week until we wrap or find a day present in the + // restart requirement. + requirementDays := templateSchedule.RestartRequirement.DaysMap() + for i := 0; i < len(schedule.DaysOfWeek)+1; i++ { + if i == len(schedule.DaysOfWeek) { + // We've wrapped, so somehow we couldn't find a day + // in the restart requirement. This shouldn't happen + // because the restart requirement has a day set. + return xerrors.Errorf("could not find suitable day for template restart requirement in the next 7 days") + } + if requirementDays[startOfDay.Weekday()] { + break + } + startOfDay = startOfDay.Add(24 * time.Hour) + } + + // Get the next occurrence of the restart schedule after + // the start of today. + maxDeadline = userQuietHoursSchedule.Schedule.Next(startOfDay) } - } - userQuietHoursSchedule, err := (*server.UserQuietHoursScheduleStore.Load()).GetUserQuietHoursScheduleOptions(ctx, db, workspace.OwnerID) - if err != nil { - return xerrors.Errorf("get user quiet hours schedule options: %w", err) - } - if userQuietHoursSchedule.Schedule != nil { - // Round the max deadline up to the nearest occurrence of the - // user's quiet hours schedule. This ensures that workspaces - // can't be force-stopped due to max TTL during business hours. - - // Get the schedule occurrence that happens right before, during - // or after the max deadline. - // TODO: change to the quiet hours window BEFORE max TTL - scheduleDur := userQuietHoursSchedule.Duration - if scheduleDur > 1*time.Hour { - // Allow a 15 minute buffer when possible so we're not too - // constrained with the autostop time. - scheduleDur -= 15 * time.Minute + // If the workspace doesn't have a deadline or the max deadline + // is sooner than the workspace deadline, use the max deadline + // as the actual deadline. + if deadline.IsZero() || maxDeadline.Before(deadline) { + deadline = maxDeadline } - windowStart := userQuietHoursSchedule.Schedule.Next(maxDeadline.Add(scheduleDur)) - maxDeadline = windowStart } err = db.UpdateProvisionerJobWithCompleteByID(ctx, database.UpdateProvisionerJobWithCompleteByIDParams{ diff --git a/coderd/provisionerdserver/provisionerdserver_test.go b/coderd/provisionerdserver/provisionerdserver_test.go index 6883da1654c19..f1a665052684b 100644 --- a/coderd/provisionerdserver/provisionerdserver_test.go +++ b/coderd/provisionerdserver/provisionerdserver_test.go @@ -47,7 +47,7 @@ func testTemplateScheduleStore() *atomic.Pointer[schedule.TemplateScheduleStore] return ptr } -func testUserMaintenanceScheduleStore() *atomic.Pointer[schedule.UserQuietHoursScheduleStore] { +func testUserQuietHoursScheduleStore() *atomic.Pointer[schedule.UserQuietHoursScheduleStore] { ptr := &atomic.Pointer[schedule.UserQuietHoursScheduleStore]{} store := schedule.NewAGPLUserQuietHoursScheduleStore() ptr.Store(&store) @@ -71,7 +71,7 @@ func TestAcquireJob(t *testing.T) { AcquireJobDebounce: time.Hour, Auditor: mockAuditor(), TemplateScheduleStore: testTemplateScheduleStore(), - UserQuietHoursScheduleStore: testUserMaintenanceScheduleStore(), + UserQuietHoursScheduleStore: testUserQuietHoursScheduleStore(), Tracer: trace.NewNoopTracerProvider().Tracer("noop"), DeploymentValues: &codersdk.DeploymentValues{}, } @@ -1278,7 +1278,7 @@ func setup(t *testing.T, ignoreLogErrors bool) *provisionerdserver.Server { Telemetry: telemetry.NewNoop(), Auditor: mockAuditor(), TemplateScheduleStore: testTemplateScheduleStore(), - UserQuietHoursScheduleStore: testUserMaintenanceScheduleStore(), + UserQuietHoursScheduleStore: testUserQuietHoursScheduleStore(), Tracer: trace.NewNoopTracerProvider().Tracer("noop"), DeploymentValues: &codersdk.DeploymentValues{}, } diff --git a/coderd/schedule/template.go b/coderd/schedule/template.go index 0876a49e7a01a..23e9182a93b5c 100644 --- a/coderd/schedule/template.go +++ b/coderd/schedule/template.go @@ -9,15 +9,54 @@ import ( "github.com/coder/coder/coderd/database" ) +var DaysOfWeek = []time.Weekday{ + time.Sunday, + time.Monday, + time.Tuesday, + time.Wednesday, + time.Thursday, + time.Friday, + time.Saturday, +} + +type TemplateRestartRequirement struct { + // DaysOfWeek is a bitmap of which days of the week the workspace must be + // restarted. If fully zero, the workspace is not required to be restarted + // ever. + // + // First bit is Sunday, second bit is Monday, ..., seventh bit is Saturday, + // eighth bit is unused. + DaysOfWeek uint8 +} + +// Days returns the days of the week that the workspace must be restarted. +func (r TemplateRestartRequirement) Days() []time.Weekday { + days := make([]time.Weekday, 0, 7) + for i, day := range DaysOfWeek { + if r.DaysOfWeek&(1< 0 { - err = db.UpdateWorkspaceTTLToBeWithinTemplateMax(ctx, database.UpdateWorkspaceTTLToBeWithinTemplateMaxParams{ - TemplateID: template.ID, - TemplateMaxTTL: int64(opts.MaxTTL), - }) - if err != nil { - return database.Template{}, xerrors.Errorf("update TTL of all workspaces on template to be within new template max TTL: %w", err) - } - } - return template, nil } From a1ebbdbf4dcc9f485b4415bcfae50657eb161635 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Thu, 6 Jul 2023 01:44:16 +0000 Subject: [PATCH 07/25] progress --- cli/create.go | 2 - coderd/apidoc/docs.go | 7 +- coderd/apidoc/swagger.json | 7 +- coderd/database/dbfake/dbfake.go | 1 - coderd/database/dump.sql | 9 +- ...0132_template_restart_requirement.down.sql | 4 + ...000132_template_restart_requirement.up.sql | 11 + coderd/database/modelqueries.go | 1 - coderd/database/models.go | 7 +- coderd/database/queries.sql.go | 71 +++-- coderd/database/queries/templates.sql | 9 +- .../provisionerdserver/provisionerdserver.go | 104 +++++- .../provisionerdserver_test.go | 301 ++++++++++++------ coderd/schedule/mock.go | 23 ++ coderd/schedule/template.go | 62 +++- coderd/templates.go | 6 - coderd/templates_test.go | 198 ------------ coderd/workspaces.go | 21 +- codersdk/templates.go | 2 +- docs/admin/audit-logs.md | 24 +- docs/api/enterprise.md | 8 +- docs/api/schemas.md | 4 +- docs/api/templates.md | 8 +- docs/cli/server.md | 2 +- enterprise/audit/table.go | 3 +- enterprise/coderd/schedule/template.go | 45 ++- site/src/api/typesGenerated.ts | 1 - 27 files changed, 507 insertions(+), 434 deletions(-) create mode 100644 coderd/database/migrations/000132_template_restart_requirement.down.sql create mode 100644 coderd/database/migrations/000132_template_restart_requirement.up.sql diff --git a/cli/create.go b/cli/create.go index b7ffbec5819cb..9bff7af8d60be 100644 --- a/cli/create.go +++ b/cli/create.go @@ -139,8 +139,6 @@ func (r *RootCmd) create() *clibase.Cmd { var ttlMillis *int64 if stopAfter > 0 { ttlMillis = ptr.Ref(stopAfter.Milliseconds()) - } else if template.MaxTTLMillis > 0 { - ttlMillis = &template.MaxTTLMillis } workspace, err := client.CreateWorkspace(inv.Context(), organization.ID, codersdk.Me, codersdk.CreateWorkspaceRequest{ diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index a041a6bb0c75d..fc254d8d1eb16 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -3652,7 +3652,7 @@ const docTemplate = `{ } } }, - "/users/{user}/quiet-hours-schedule": { + "/users/{user}/quiet-hours": { "get": { "security": [ { @@ -8629,6 +8629,7 @@ const docTemplate = `{ "format": "date-time" }, "created_by_id": { + "description": "MaxTTLMillis is an enterprise feature. It's value is only used if your\nlicense is entitled to use the advanced template scheduling feature.\nTODO: fix and comment", "type": "string", "format": "uuid" }, @@ -8661,10 +8662,6 @@ const docTemplate = `{ "locked_ttl_ms": { "type": "integer" }, - "max_ttl_ms": { - "description": "MaxTTLMillis is an enterprise feature. It's value is only used if your\nlicense is entitled to use the advanced template scheduling feature.", - "type": "integer" - }, "name": { "type": "string" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 17882209e3a26..7608c46fafd9e 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -3216,7 +3216,7 @@ } } }, - "/users/{user}/quiet-hours-schedule": { + "/users/{user}/quiet-hours": { "get": { "security": [ { @@ -7772,6 +7772,7 @@ "format": "date-time" }, "created_by_id": { + "description": "MaxTTLMillis is an enterprise feature. It's value is only used if your\nlicense is entitled to use the advanced template scheduling feature.\nTODO: fix and comment", "type": "string", "format": "uuid" }, @@ -7804,10 +7805,6 @@ "locked_ttl_ms": { "type": "integer" }, - "max_ttl_ms": { - "description": "MaxTTLMillis is an enterprise feature. It's value is only used if your\nlicense is entitled to use the advanced template scheduling feature.", - "type": "integer" - }, "name": { "type": "string" }, diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go index 674d5abaa34bc..140a8edea1dd3 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -4685,7 +4685,6 @@ func (q *fakeQuerier) UpdateTemplateScheduleByID(_ context.Context, arg database tpl.AllowUserAutostop = arg.AllowUserAutostop tpl.UpdatedAt = database.Now() tpl.DefaultTTL = arg.DefaultTTL - tpl.MaxTTL = arg.MaxTTL tpl.FailureTTL = arg.FailureTTL tpl.InactivityTTL = arg.InactivityTTL q.templates[idx] = tpl diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index cd8751ca730a8..740c646156e4d 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -551,12 +551,13 @@ CREATE TABLE templates ( group_acl jsonb DEFAULT '{}'::jsonb NOT NULL, display_name character varying(64) DEFAULT ''::character varying NOT NULL, allow_user_cancel_workspace_jobs boolean DEFAULT true NOT NULL, - max_ttl bigint DEFAULT '0'::bigint NOT NULL, allow_user_autostart boolean DEFAULT true NOT NULL, allow_user_autostop boolean DEFAULT true NOT NULL, failure_ttl bigint DEFAULT 0 NOT NULL, inactivity_ttl bigint DEFAULT 0 NOT NULL, - locked_ttl bigint DEFAULT 0 NOT NULL + locked_ttl bigint DEFAULT 0 NOT NULL, + restart_requirement_days_of_week smallint DEFAULT 0 NOT NULL, + restart_requirement_weeks bigint DEFAULT 0 NOT NULL ); COMMENT ON COLUMN templates.default_ttl IS 'The default duration for autostop for workspaces created from this template.'; @@ -569,6 +570,10 @@ COMMENT ON COLUMN templates.allow_user_autostart IS 'Allow users to specify an a COMMENT ON COLUMN templates.allow_user_autostop IS 'Allow users to specify custom autostop values for workspaces (enterprise).'; +COMMENT ON COLUMN templates.restart_requirement_days_of_week IS 'A bitmap of days of week to restart the workspace on, starting with Monday as the 0th bit, and Sunday as the 6th bit. Always little-endian. The 7th bit is unused.'; + +COMMENT ON COLUMN templates.restart_requirement_weeks IS 'The number of weeks between restarts. 0 weeks means "every week", 1 week means "every other week", etc. Weeks are counted from January 2, 2023, which is the first Monday of 2023. This is to ensure workspaces are started consistently for all customers on the same n-week cycles.'; + CREATE TABLE user_links ( user_id uuid NOT NULL, login_type login_type NOT NULL, diff --git a/coderd/database/migrations/000132_template_restart_requirement.down.sql b/coderd/database/migrations/000132_template_restart_requirement.down.sql new file mode 100644 index 0000000000000..729133834461c --- /dev/null +++ b/coderd/database/migrations/000132_template_restart_requirement.down.sql @@ -0,0 +1,4 @@ +ALTER TABLE templates + DROP COLUMN restart_requirement_days_of_week, + DROP COLUMN restart_requirement_weeks, + ADD COLUMN max_ttl bigint NOT NULL DEFAULT 0; diff --git a/coderd/database/migrations/000132_template_restart_requirement.up.sql b/coderd/database/migrations/000132_template_restart_requirement.up.sql new file mode 100644 index 0000000000000..fcc8fab6132fc --- /dev/null +++ b/coderd/database/migrations/000132_template_restart_requirement.up.sql @@ -0,0 +1,11 @@ +BEGIN; + +ALTER TABLE templates + DROP COLUMN max_ttl, + ADD COLUMN restart_requirement_days_of_week smallint NOT NULL DEFAULT 0, + ADD COLUMN restart_requirement_weeks bigint NOT NULL DEFAULT 0; + +COMMENT ON COLUMN templates.restart_requirement_days_of_week IS 'A bitmap of days of week to restart the workspace on, starting with Monday as the 0th bit, and Sunday as the 6th bit. The 7th bit is unused.'; +COMMENT ON COLUMN templates.restart_requirement_weeks IS 'The number of weeks between restarts. 0 weeks means "every week", 1 week means "every other week", etc. Weeks are counted from January 2, 2023, which is the first Monday of 2023. This is to ensure workspaces are started consistently for all customers on the same n-week cycles.'; + +COMMIT; diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 83aeca7e06f3e..ce07a212f04c3 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -77,7 +77,6 @@ func (q *sqlQuerier) GetAuthorizedTemplates(ctx context.Context, arg GetTemplate &i.GroupACL, &i.DisplayName, &i.AllowUserCancelWorkspaceJobs, - &i.MaxTTL, &i.AllowUserAutostart, &i.AllowUserAutostop, &i.FailureTTL, diff --git a/coderd/database/models.go b/coderd/database/models.go index fe274a1342e50..860aaf5a41123 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1574,8 +1574,7 @@ type Template struct { // Display name is a custom, human-friendly template name that user can set. DisplayName string `db:"display_name" json:"display_name"` // Allow users to cancel in-progress workspace jobs. - AllowUserCancelWorkspaceJobs bool `db:"allow_user_cancel_workspace_jobs" json:"allow_user_cancel_workspace_jobs"` - MaxTTL int64 `db:"max_ttl" json:"max_ttl"` + AllowUserCancelWorkspaceJobs bool `db:"allow_user_cancel_workspace_jobs" json:"allow_user_cancel_workspace_jobs"` // Allow users to specify an autostart schedule for workspaces (enterprise). AllowUserAutostart bool `db:"allow_user_autostart" json:"allow_user_autostart"` // Allow users to specify custom autostop values for workspaces (enterprise). @@ -1583,6 +1582,10 @@ type Template struct { FailureTTL int64 `db:"failure_ttl" json:"failure_ttl"` InactivityTTL int64 `db:"inactivity_ttl" json:"inactivity_ttl"` LockedTTL int64 `db:"locked_ttl" json:"locked_ttl"` + // A bitmap of days of week to restart the workspace on, starting with Monday as the 0th bit, and Sunday as the 6th bit. Always little-endian. The 7th bit is unused. + RestartRequirementDaysOfWeek int16 `db:"restart_requirement_days_of_week" json:"restart_requirement_days_of_week"` + // The number of weeks between restarts. 0 weeks means "every week", 1 week means "every other week", etc. Weeks are counted from January 2, 2023, which is the first Monday of 2023. This is to ensure workspaces are started consistently for all customers on the same n-week cycles. + RestartRequirementWeeks int64 `db:"restart_requirement_weeks" json:"restart_requirement_weeks"` } type TemplateVersion struct { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 7414c3be99649..03e26d8be1fef 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -3617,7 +3617,7 @@ func (q *sqlQuerier) GetTemplateAverageBuildTime(ctx context.Context, arg GetTem const getTemplateByID = `-- name: GetTemplateByID :one SELECT - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl, locked_ttl + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl, locked_ttl, restart_requirement_days_of_week, restart_requirement_weeks FROM templates WHERE @@ -3646,19 +3646,20 @@ func (q *sqlQuerier) GetTemplateByID(ctx context.Context, id uuid.UUID) (Templat &i.GroupACL, &i.DisplayName, &i.AllowUserCancelWorkspaceJobs, - &i.MaxTTL, &i.AllowUserAutostart, &i.AllowUserAutostop, &i.FailureTTL, &i.InactivityTTL, &i.LockedTTL, + &i.RestartRequirementDaysOfWeek, + &i.RestartRequirementWeeks, ) return i, err } const getTemplateByOrganizationAndName = `-- name: GetTemplateByOrganizationAndName :one SELECT - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl, locked_ttl + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl, locked_ttl, restart_requirement_days_of_week, restart_requirement_weeks FROM templates WHERE @@ -3695,18 +3696,19 @@ func (q *sqlQuerier) GetTemplateByOrganizationAndName(ctx context.Context, arg G &i.GroupACL, &i.DisplayName, &i.AllowUserCancelWorkspaceJobs, - &i.MaxTTL, &i.AllowUserAutostart, &i.AllowUserAutostop, &i.FailureTTL, &i.InactivityTTL, &i.LockedTTL, + &i.RestartRequirementDaysOfWeek, + &i.RestartRequirementWeeks, ) return i, err } const getTemplates = `-- name: GetTemplates :many -SELECT id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl, locked_ttl FROM templates +SELECT id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl, locked_ttl, restart_requirement_days_of_week, restart_requirement_weeks FROM templates ORDER BY (name, id) ASC ` @@ -3736,12 +3738,13 @@ func (q *sqlQuerier) GetTemplates(ctx context.Context) ([]Template, error) { &i.GroupACL, &i.DisplayName, &i.AllowUserCancelWorkspaceJobs, - &i.MaxTTL, &i.AllowUserAutostart, &i.AllowUserAutostop, &i.FailureTTL, &i.InactivityTTL, &i.LockedTTL, + &i.RestartRequirementDaysOfWeek, + &i.RestartRequirementWeeks, ); err != nil { return nil, err } @@ -3758,7 +3761,7 @@ func (q *sqlQuerier) GetTemplates(ctx context.Context) ([]Template, error) { const getTemplatesWithFilter = `-- name: GetTemplatesWithFilter :many SELECT - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl, locked_ttl + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl, locked_ttl, restart_requirement_days_of_week, restart_requirement_weeks FROM templates WHERE @@ -3825,12 +3828,13 @@ func (q *sqlQuerier) GetTemplatesWithFilter(ctx context.Context, arg GetTemplate &i.GroupACL, &i.DisplayName, &i.AllowUserCancelWorkspaceJobs, - &i.MaxTTL, &i.AllowUserAutostart, &i.AllowUserAutostop, &i.FailureTTL, &i.InactivityTTL, &i.LockedTTL, + &i.RestartRequirementDaysOfWeek, + &i.RestartRequirementWeeks, ); err != nil { return nil, err } @@ -3864,7 +3868,7 @@ INSERT INTO allow_user_cancel_workspace_jobs ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl, locked_ttl + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl, locked_ttl, restart_requirement_days_of_week, restart_requirement_weeks ` type InsertTemplateParams struct { @@ -3919,12 +3923,13 @@ func (q *sqlQuerier) InsertTemplate(ctx context.Context, arg InsertTemplateParam &i.GroupACL, &i.DisplayName, &i.AllowUserCancelWorkspaceJobs, - &i.MaxTTL, &i.AllowUserAutostart, &i.AllowUserAutostop, &i.FailureTTL, &i.InactivityTTL, &i.LockedTTL, + &i.RestartRequirementDaysOfWeek, + &i.RestartRequirementWeeks, ) return i, err } @@ -3938,7 +3943,7 @@ SET WHERE id = $3 RETURNING - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl, locked_ttl + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl, locked_ttl, restart_requirement_days_of_week, restart_requirement_weeks ` type UpdateTemplateACLByIDParams struct { @@ -3967,12 +3972,13 @@ func (q *sqlQuerier) UpdateTemplateACLByID(ctx context.Context, arg UpdateTempla &i.GroupACL, &i.DisplayName, &i.AllowUserCancelWorkspaceJobs, - &i.MaxTTL, &i.AllowUserAutostart, &i.AllowUserAutostop, &i.FailureTTL, &i.InactivityTTL, &i.LockedTTL, + &i.RestartRequirementDaysOfWeek, + &i.RestartRequirementWeeks, ) return i, err } @@ -4032,7 +4038,7 @@ SET WHERE id = $1 RETURNING - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl, locked_ttl + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl, locked_ttl, restart_requirement_days_of_week, restart_requirement_weeks ` type UpdateTemplateMetaByIDParams struct { @@ -4073,12 +4079,13 @@ func (q *sqlQuerier) UpdateTemplateMetaByID(ctx context.Context, arg UpdateTempl &i.GroupACL, &i.DisplayName, &i.AllowUserCancelWorkspaceJobs, - &i.MaxTTL, &i.AllowUserAutostart, &i.AllowUserAutostop, &i.FailureTTL, &i.InactivityTTL, &i.LockedTTL, + &i.RestartRequirementDaysOfWeek, + &i.RestartRequirementWeeks, ) return i, err } @@ -4091,26 +4098,28 @@ SET allow_user_autostart = $3, allow_user_autostop = $4, default_ttl = $5, - max_ttl = $6, - failure_ttl = $7, - inactivity_ttl = $8, - locked_ttl = $9 + restart_requirement_days_of_week = $6, + restart_requirement_weeks = $7, + failure_ttl = $8, + inactivity_ttl = $9, + locked_ttl = $10 WHERE id = $1 RETURNING - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl, locked_ttl + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl, locked_ttl, restart_requirement_days_of_week, restart_requirement_weeks ` type UpdateTemplateScheduleByIDParams struct { - ID uuid.UUID `db:"id" json:"id"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` - AllowUserAutostart bool `db:"allow_user_autostart" json:"allow_user_autostart"` - AllowUserAutostop bool `db:"allow_user_autostop" json:"allow_user_autostop"` - DefaultTTL int64 `db:"default_ttl" json:"default_ttl"` - MaxTTL int64 `db:"max_ttl" json:"max_ttl"` - FailureTTL int64 `db:"failure_ttl" json:"failure_ttl"` - InactivityTTL int64 `db:"inactivity_ttl" json:"inactivity_ttl"` - LockedTTL int64 `db:"locked_ttl" json:"locked_ttl"` + ID uuid.UUID `db:"id" json:"id"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + AllowUserAutostart bool `db:"allow_user_autostart" json:"allow_user_autostart"` + AllowUserAutostop bool `db:"allow_user_autostop" json:"allow_user_autostop"` + DefaultTTL int64 `db:"default_ttl" json:"default_ttl"` + RestartRequirementDaysOfWeek int16 `db:"restart_requirement_days_of_week" json:"restart_requirement_days_of_week"` + RestartRequirementWeeks int64 `db:"restart_requirement_weeks" json:"restart_requirement_weeks"` + FailureTTL int64 `db:"failure_ttl" json:"failure_ttl"` + InactivityTTL int64 `db:"inactivity_ttl" json:"inactivity_ttl"` + LockedTTL int64 `db:"locked_ttl" json:"locked_ttl"` } func (q *sqlQuerier) UpdateTemplateScheduleByID(ctx context.Context, arg UpdateTemplateScheduleByIDParams) (Template, error) { @@ -4120,7 +4129,8 @@ func (q *sqlQuerier) UpdateTemplateScheduleByID(ctx context.Context, arg UpdateT arg.AllowUserAutostart, arg.AllowUserAutostop, arg.DefaultTTL, - arg.MaxTTL, + arg.RestartRequirementDaysOfWeek, + arg.RestartRequirementWeeks, arg.FailureTTL, arg.InactivityTTL, arg.LockedTTL, @@ -4143,12 +4153,13 @@ func (q *sqlQuerier) UpdateTemplateScheduleByID(ctx context.Context, arg UpdateT &i.GroupACL, &i.DisplayName, &i.AllowUserCancelWorkspaceJobs, - &i.MaxTTL, &i.AllowUserAutostart, &i.AllowUserAutostop, &i.FailureTTL, &i.InactivityTTL, &i.LockedTTL, + &i.RestartRequirementDaysOfWeek, + &i.RestartRequirementWeeks, ) return i, err } diff --git a/coderd/database/queries/templates.sql b/coderd/database/queries/templates.sql index 3750b2fa76fd7..735364bde14ea 100644 --- a/coderd/database/queries/templates.sql +++ b/coderd/database/queries/templates.sql @@ -118,10 +118,11 @@ SET allow_user_autostart = $3, allow_user_autostop = $4, default_ttl = $5, - max_ttl = $6, - failure_ttl = $7, - inactivity_ttl = $8, - locked_ttl = $9 + restart_requirement_days_of_week = $6, + restart_requirement_weeks = $7, + failure_ttl = $8, + inactivity_ttl = $9, + locked_ttl = $10 WHERE id = $1 RETURNING diff --git a/coderd/provisionerdserver/provisionerdserver.go b/coderd/provisionerdserver/provisionerdserver.go index 816d509af7130..9d084dfa08cab 100644 --- a/coderd/provisionerdserver/provisionerdserver.go +++ b/coderd/provisionerdserver/provisionerdserver.go @@ -67,6 +67,17 @@ type Server struct { AcquireJobDebounce time.Duration OIDCConfig httpmw.OAuth2Config + + TimeNowFn func() time.Time +} + +// timeNow should be used when trying to get the current time for math +// calculations regarding workspace start and stop time. +func (server *Server) timeNow() time.Time { + if server.TimeNowFn != nil { + return database.Time(server.TimeNowFn()) + } + return database.Now() } // AcquireJob queries the database to lock a job. @@ -101,7 +112,7 @@ func (server *Server) AcquireJob(ctx context.Context, _ *proto.Empty) (*proto.Ac // The provisioner daemon assumes no jobs are available if // an empty struct is returned. lastAcquireMutex.Lock() - lastAcquire = time.Now() + lastAcquire = database.Now() lastAcquireMutex.Unlock() return &proto.AcquiredJob{}, nil } @@ -893,7 +904,9 @@ func (server *Server) CompleteJob(ctx context.Context, completed *proto.Complete err = server.Database.InTx(func(db database.Store) error { var ( - now = database.Now() + // It's important we use server.timeNow() here because we want + // to be able to customize the current time from within tests. + now = server.timeNow() // deadline is the time when the workspace will be stopped. The // value can be bumped by user activity or manually by the user // via the UI. @@ -912,6 +925,9 @@ func (server *Server) CompleteJob(ctx context.Context, completed *proto.Complete return getWorkspaceError } if workspace.Ttl.Valid { + // When the workspace is made it copies the template's TTL, and + // the user can unset it to disable it (unless the template + // has UserAutoStopEnabled set to false, see below). deadline = now.Add(time.Duration(workspace.Ttl.Int64)) } @@ -944,26 +960,45 @@ func (server *Server) CompleteJob(ctx context.Context, completed *proto.Complete // workspace. if userQuietHoursSchedule.Schedule != nil { loc := userQuietHoursSchedule.Schedule.Location() - now := time.Now().In(loc) - startOfDay := now.Truncate(24 * time.Hour) + now := server.timeNow().In(loc) + startOfDay := truncateMidnight(now) + + // If the template schedule wants to only restart on n-th + // weeks then change the startOfDay to be the start of the + // next n-th week if this week is not an n-th week. + /* + TODO: get working and add tests + if templateSchedule.RestartRequirement.Weeks != 0 { + epoch := schedule.TemplateRestartRequirementEpoch(loc) + if startOfDay.Before(epoch) { + return xerrors.Errorf("coder server system clock is incorrect, cannot calculate template restart requirement") + } + since := startOfDay.Sub(epoch) + weeksSince := int64(since.Hours() / (24 * 7)) + if weeksSince%templateSchedule.RestartRequirement.Weeks != 0 { + startOfDay = epoch.Add(time.Duration(weeksSince/templateSchedule.RestartRequirement.Weeks+1) * 24 * 7 * time.Hour) + startOfDay = truncateMidnight(startOfDay) + } + } + */ - // First, determine if we can restart today or if the - // schedule is too near/already passed. + // Determine if we should skip today because the schedule is + // too near or has already passed. // // Allow an hour of leeway (i.e. any workspaces started // within an hour of the scheduled stop time will always // bounce to the next stop window). todaySchedule := userQuietHoursSchedule.Schedule.Next(startOfDay) - if todaySchedule.Before(now.Add(-time.Hour)) { + if todaySchedule.Before(now.Add(time.Hour)) { // Set the first stop day we try to tomorrow because // today's schedule is too close to now or has already // passed. - startOfDay = startOfDay.Add(24 * time.Hour) + startOfDay = nextDayMidnight(startOfDay) } - // Get the current day of week and iterate through the days - // of week until we wrap or find a day present in the - // restart requirement. + // Iterate from 0 to 7, check if the current startOfDay is + // in the restart requirement. If it isn't then add a day + // and try again. requirementDays := templateSchedule.RestartRequirement.DaysMap() for i := 0; i < len(schedule.DaysOfWeek)+1; i++ { if i == len(schedule.DaysOfWeek) { @@ -975,12 +1010,27 @@ func (server *Server) CompleteJob(ctx context.Context, completed *proto.Complete if requirementDays[startOfDay.Weekday()] { break } - startOfDay = startOfDay.Add(24 * time.Hour) + startOfDay = nextDayMidnight(startOfDay) + } + + // If the startOfDay is within an hour of now, then we add + // an hour. + checkTime := startOfDay + if checkTime.Before(now.Add(time.Hour)) { + checkTime = now.Add(time.Hour) + } else { + // If it's not within an hour of now, subtract 15 + // minutes to give a little leeway. This prevents + // skipped stop events because autostart perfectly lines + // up with autostop. + checkTime = checkTime.Add(-15 * time.Minute) } - // Get the next occurrence of the restart schedule after - // the start of today. - maxDeadline = userQuietHoursSchedule.Schedule.Next(startOfDay) + // Get the next occurrence of the restart schedule. + maxDeadline = userQuietHoursSchedule.Schedule.Next(checkTime) + if maxDeadline.IsZero() { + return xerrors.Errorf("could not find next occurrence of template restart requirement in user quiet hours schedule") + } } // If the workspace doesn't have a deadline or the max deadline @@ -991,6 +1041,12 @@ func (server *Server) CompleteJob(ctx context.Context, completed *proto.Complete } } + if (!deadline.IsZero() && deadline.Before(now)) || (!maxDeadline.IsZero() && maxDeadline.Before(now)) { + // Something went wrong with the deadline calculation, so we + // should bail. + return xerrors.Errorf("deadline calculation error, computed deadline or max deadline is in the past for workspace build: deadline=%q maxDeadline=%q now=%q", deadline, maxDeadline, now) + } + err = db.UpdateProvisionerJobWithCompleteByID(ctx, database.UpdateProvisionerJobWithCompleteByIDParams{ ID: jobID, UpdatedAt: database.Now(), @@ -1610,3 +1666,21 @@ func redactTemplateVariable(templateVariable *sdkproto.TemplateVariable) *sdkpro } return maybeRedacted } + +// truncateMidnight truncates a time to midnight in the time object's timezone. +// t.Truncate(24 * time.Hour) truncates based on the internal time and doesn't +// factor daylight savings properly. +// +// See: https://github.com/golang/go/issues/10894 +func truncateMidnight(t time.Time) time.Time { + yy, mm, dd := t.Date() + return time.Date(yy, mm, dd, 0, 0, 0, 0, t.Location()) +} + +// nextDayMidnight returns the next midnight in the time object's timezone. +func nextDayMidnight(t time.Time) time.Time { + yy, mm, dd := t.Date() + // time.Date will correctly normalize the date if it's past the end of the + // month. E.g. October 32nd will be November 1st. + return time.Date(yy, mm, dd+1, 0, 0, 0, 0, t.Location()) +} diff --git a/coderd/provisionerdserver/provisionerdserver_test.go b/coderd/provisionerdserver/provisionerdserver_test.go index f1a665052684b..82c21c5a42f9c 100644 --- a/coderd/provisionerdserver/provisionerdserver_test.go +++ b/coderd/provisionerdserver/provisionerdserver_test.go @@ -11,6 +11,7 @@ import ( "time" "github.com/google/uuid" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.opentelemetry.io/otel/trace" "golang.org/x/oauth2" @@ -903,142 +904,254 @@ func TestCompleteJob(t *testing.T) { t.Run("WorkspaceBuild", func(t *testing.T) { t.Parallel() + now := time.Now() + + // Wednesday the 5th of July 2023 at midnight. + wednesdayMidnightUTC := time.Date(2023, 7, 5, 0, 0, 0, 0, time.UTC) + + sydneyQuietHours := "CRON_TZ=Australia/Sydney 0 0 * * *" + sydneyLoc, err := time.LoadLocation("Australia/Sydney") + require.NoError(t, err) + // 10pm on Friday the 7th of July 2023 in Sydney. + fridayEveningSydney := time.Date(2023, 7, 7, 22, 0, 0, 0, sydneyLoc) + // 12am on Saturday the 8th of July 2023 in Sydney. + saturdayMidnightSydney := time.Date(2023, 7, 8, 0, 0, 0, 0, sydneyLoc) + + t.Log("now", now) + t.Log("wednesdayMidnightUTC", wednesdayMidnightUTC) + t.Log("fridayEveningSydney", fridayEveningSydney) + t.Log("saturdayMidnightSydney", saturdayMidnightSydney) + cases := []struct { name string + now time.Time templateAllowAutostop bool templateDefaultTTL time.Duration - templateMaxTTL time.Duration workspaceTTL time.Duration transition database.WorkspaceTransition - // The TTL is actually a deadline time on the workspace_build row, - // so during the test this will be compared to be within 15 seconds - // of the expected value. - expectedTTL time.Duration - expectedMaxTTL time.Duration + + // These fields are only used when testing max deadline. + userQuietHoursSchedule string + templateRestartRequirement schedule.TemplateRestartRequirement + + expectedDeadline time.Time + expectedMaxDeadline time.Time }{ { - name: "OK", - templateAllowAutostop: true, - templateDefaultTTL: 0, - templateMaxTTL: 0, - workspaceTTL: 0, - transition: database.WorkspaceTransitionStart, - expectedTTL: 0, - expectedMaxTTL: 0, + name: "OK", + now: now, + templateAllowAutostop: true, + templateDefaultTTL: 0, + templateRestartRequirement: schedule.TemplateRestartRequirement{}, + workspaceTTL: 0, + transition: database.WorkspaceTransitionStart, + expectedDeadline: time.Time{}, + expectedMaxDeadline: time.Time{}, }, { - name: "Delete", - templateAllowAutostop: true, - templateDefaultTTL: 0, - templateMaxTTL: 0, - workspaceTTL: 0, - transition: database.WorkspaceTransitionDelete, - expectedTTL: 0, - expectedMaxTTL: 0, + name: "Delete", + now: now, + templateAllowAutostop: true, + templateDefaultTTL: 0, + templateRestartRequirement: schedule.TemplateRestartRequirement{}, + workspaceTTL: 0, + transition: database.WorkspaceTransitionDelete, + expectedDeadline: time.Time{}, + expectedMaxDeadline: time.Time{}, }, { - name: "WorkspaceTTL", - templateAllowAutostop: true, - templateDefaultTTL: 0, - templateMaxTTL: 0, - workspaceTTL: time.Hour, - transition: database.WorkspaceTransitionStart, - expectedTTL: time.Hour, - expectedMaxTTL: 0, + name: "WorkspaceTTL", + now: now, + templateAllowAutostop: true, + templateDefaultTTL: 0, + templateRestartRequirement: schedule.TemplateRestartRequirement{}, + workspaceTTL: time.Hour, + transition: database.WorkspaceTransitionStart, + expectedDeadline: now.Add(time.Hour), + expectedMaxDeadline: time.Time{}, }, { - name: "TemplateDefaultTTLIgnored", - templateAllowAutostop: true, - templateDefaultTTL: time.Hour, - templateMaxTTL: 0, - workspaceTTL: 0, - transition: database.WorkspaceTransitionStart, - expectedTTL: 0, - expectedMaxTTL: 0, + name: "TemplateDefaultTTLIgnored", + now: now, + templateAllowAutostop: true, + templateDefaultTTL: time.Hour, + templateRestartRequirement: schedule.TemplateRestartRequirement{}, + workspaceTTL: 0, + transition: database.WorkspaceTransitionStart, + expectedDeadline: time.Time{}, + expectedMaxDeadline: time.Time{}, }, { - name: "WorkspaceTTLOverridesTemplateDefaultTTL", - templateAllowAutostop: true, - templateDefaultTTL: 2 * time.Hour, - templateMaxTTL: 0, - workspaceTTL: time.Hour, - transition: database.WorkspaceTransitionStart, - expectedTTL: time.Hour, - expectedMaxTTL: 0, + name: "WorkspaceTTLOverridesTemplateDefaultTTL", + now: now, + templateAllowAutostop: true, + templateDefaultTTL: 2 * time.Hour, + templateRestartRequirement: schedule.TemplateRestartRequirement{}, + workspaceTTL: time.Hour, + transition: database.WorkspaceTransitionStart, + expectedDeadline: now.Add(time.Hour), + expectedMaxDeadline: time.Time{}, }, { - name: "TemplateMaxTTL", - templateAllowAutostop: true, - templateDefaultTTL: 0, - templateMaxTTL: time.Hour, - workspaceTTL: 0, - transition: database.WorkspaceTransitionStart, - expectedTTL: time.Hour, - expectedMaxTTL: time.Hour, + name: "TemplateBlockWorkspaceTTL", + now: now, + templateAllowAutostop: false, + templateDefaultTTL: 3 * time.Hour, + templateRestartRequirement: schedule.TemplateRestartRequirement{}, + workspaceTTL: 4 * time.Hour, + transition: database.WorkspaceTransitionStart, + expectedDeadline: now.Add(3 * time.Hour), + expectedMaxDeadline: time.Time{}, + }, + { + name: "TemplateRestartRequirement", + now: wednesdayMidnightUTC, + templateAllowAutostop: true, + templateDefaultTTL: 0, + userQuietHoursSchedule: sydneyQuietHours, + templateRestartRequirement: schedule.TemplateRestartRequirement{ + DaysOfWeek: 0b00100000, // Saturday + Weeks: 0, // weekly + }, + workspaceTTL: 0, + transition: database.WorkspaceTransitionStart, + // expectedDeadline is copied from expectedMaxDeadline. + expectedMaxDeadline: saturdayMidnightSydney.In(time.UTC), + }, + { + name: "TemplateRestartRequirement1HourSkip", + now: saturdayMidnightSydney.Add(-59 * time.Minute), + templateAllowAutostop: true, + templateDefaultTTL: 0, + userQuietHoursSchedule: sydneyQuietHours, + templateRestartRequirement: schedule.TemplateRestartRequirement{ + DaysOfWeek: 0b00100000, // Saturday + Weeks: 0, // weekly + }, + workspaceTTL: 0, + transition: database.WorkspaceTransitionStart, + // expectedDeadline is copied from expectedMaxDeadline. + expectedMaxDeadline: saturdayMidnightSydney.Add(7 * 24 * time.Hour).In(time.UTC), }, { - name: "TemplateMaxTTLOverridesWorkspaceTTL", - templateAllowAutostop: true, - templateDefaultTTL: 0, - templateMaxTTL: 2 * time.Hour, - workspaceTTL: 3 * time.Hour, - transition: database.WorkspaceTransitionStart, - expectedTTL: 2 * time.Hour, - expectedMaxTTL: 2 * time.Hour, + // The next restart requirement should be skipped if the + // workspace is started within 1 hour of it. + name: "TemplateRestartRequirementDaily", + now: fridayEveningSydney, + templateAllowAutostop: true, + templateDefaultTTL: 0, + userQuietHoursSchedule: sydneyQuietHours, + templateRestartRequirement: schedule.TemplateRestartRequirement{ + DaysOfWeek: 0b01111111, // daily + Weeks: 0, // all weeks + }, + workspaceTTL: 0, + transition: database.WorkspaceTransitionStart, + // expectedDeadline is copied from expectedMaxDeadline. + expectedMaxDeadline: saturdayMidnightSydney.In(time.UTC), }, { - name: "TemplateMaxTTLOverridesTemplateDefaultTTL", - templateAllowAutostop: true, - templateDefaultTTL: 3 * time.Hour, - templateMaxTTL: 2 * time.Hour, - workspaceTTL: 0, - transition: database.WorkspaceTransitionStart, - expectedTTL: 2 * time.Hour, - expectedMaxTTL: 2 * time.Hour, + name: "TemplateRestartRequirementOverridesWorkspaceTTL", + // now doesn't have to be UTC, but it helps us ensure that + // timezones are compared correctly in this test. + now: fridayEveningSydney.In(time.UTC), + templateAllowAutostop: true, + templateDefaultTTL: 0, + userQuietHoursSchedule: sydneyQuietHours, + templateRestartRequirement: schedule.TemplateRestartRequirement{ + DaysOfWeek: 0b00100000, // Saturday + Weeks: 0, // weekly + }, + workspaceTTL: 3 * time.Hour, + transition: database.WorkspaceTransitionStart, + // expectedDeadline is copied from expectedMaxDeadline. + expectedMaxDeadline: saturdayMidnightSydney.In(time.UTC), }, { - name: "TemplateBlockWorkspaceTTL", - templateAllowAutostop: false, - templateDefaultTTL: 3 * time.Hour, - templateMaxTTL: 6 * time.Hour, - workspaceTTL: 4 * time.Hour, - transition: database.WorkspaceTransitionStart, - expectedTTL: 3 * time.Hour, - expectedMaxTTL: 6 * time.Hour, + name: "TemplateRestartRequirementOverridesTemplateDefaultTTL", + now: fridayEveningSydney.In(time.UTC), + templateAllowAutostop: true, + templateDefaultTTL: 3 * time.Hour, + userQuietHoursSchedule: sydneyQuietHours, + templateRestartRequirement: schedule.TemplateRestartRequirement{ + DaysOfWeek: 0b00100000, // Saturday + Weeks: 0, // weekly + }, + workspaceTTL: 0, + transition: database.WorkspaceTransitionStart, + // expectedDeadline is copied from expectedMaxDeadline. + expectedMaxDeadline: saturdayMidnightSydney.In(time.UTC), }, } for _, c := range cases { c := c + if c.name != "TemplateRestartRequirement1HourSkip" { + // TODO: REMOVE + continue + } + t.Run(c.name, func(t *testing.T) { t.Parallel() srv := setup(t, false) - var store schedule.TemplateScheduleStore = schedule.MockTemplateScheduleStore{ + // Simulate the given time starting from now. + require.False(t, c.now.IsZero()) + start := time.Now() + srv.TimeNowFn = func() time.Time { + return c.now.Add(time.Since(start)) + } + + var templateScheduleStore schedule.TemplateScheduleStore = schedule.MockTemplateScheduleStore{ GetFn: func(_ context.Context, _ database.Store, _ uuid.UUID) (schedule.TemplateScheduleOptions, error) { return schedule.TemplateScheduleOptions{ UserAutostartEnabled: false, UserAutostopEnabled: c.templateAllowAutostop, DefaultTTL: c.templateDefaultTTL, - MaxTTL: c.templateMaxTTL, + RestartRequirement: c.templateRestartRequirement, }, nil }, } - srv.TemplateScheduleStore.Store(&store) + srv.TemplateScheduleStore.Store(&templateScheduleStore) + + var userQuietHoursScheduleStore schedule.UserQuietHoursScheduleStore = schedule.MockUserQuietHoursScheduleStore{ + GetFn: func(_ context.Context, _ database.Store, _ uuid.UUID) (schedule.UserQuietHoursScheduleOptions, error) { + if c.userQuietHoursSchedule == "" { + return schedule.UserQuietHoursScheduleOptions{ + Schedule: nil, + }, nil + } + + sched, err := schedule.Daily(c.userQuietHoursSchedule) + if !assert.NoError(t, err) { + return schedule.UserQuietHoursScheduleOptions{}, err + } + + return schedule.UserQuietHoursScheduleOptions{ + Schedule: sched, + UserSet: false, + Duration: 4 * time.Hour, + }, nil + }, + } + srv.UserQuietHoursScheduleStore.Store(&userQuietHoursScheduleStore) - user := dbgen.User(t, srv.Database, database.User{}) + user := dbgen.User(t, srv.Database, database.User{ + QuietHoursSchedule: c.userQuietHoursSchedule, + }) template := dbgen.Template(t, srv.Database, database.Template{ Name: "template", Provisioner: database.ProvisionerTypeEcho, }) template, err := srv.Database.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{ - ID: template.ID, - UpdatedAt: database.Now(), - AllowUserAutostart: c.templateAllowAutostop, - DefaultTTL: int64(c.templateDefaultTTL), - MaxTTL: int64(c.templateMaxTTL), + ID: template.ID, + UpdatedAt: database.Now(), + AllowUserAutostart: c.templateAllowAutostop, + DefaultTTL: int64(c.templateDefaultTTL), + RestartRequirementDaysOfWeek: int16(c.templateRestartRequirement.DaysOfWeek), + RestartRequirementWeeks: c.templateRestartRequirement.Weeks, }) require.NoError(t, err) file := dbgen.File(t, srv.Database, database.File{CreatedBy: user.ID}) @@ -1119,15 +1232,21 @@ func TestCompleteJob(t *testing.T) { workspaceBuild, err := srv.Database.GetWorkspaceBuildByID(ctx, build.ID) require.NoError(t, err) - if c.expectedTTL == 0 { + // If the max deadline is set, the deadline should also be set. + // Default to the max deadline if the deadline is not set. + if c.expectedDeadline.IsZero() { + c.expectedDeadline = c.expectedMaxDeadline + } + + if c.expectedDeadline.IsZero() { require.True(t, workspaceBuild.Deadline.IsZero()) } else { - require.WithinDuration(t, time.Now().Add(c.expectedTTL), workspaceBuild.Deadline, 15*time.Second, "deadline does not match expected") + require.WithinDuration(t, c.expectedDeadline, workspaceBuild.Deadline, 15*time.Second, "deadline does not match expected") } - if c.expectedMaxTTL == 0 { + if c.expectedMaxDeadline.IsZero() { require.True(t, workspaceBuild.MaxDeadline.IsZero()) } else { - require.WithinDuration(t, time.Now().Add(c.expectedMaxTTL), workspaceBuild.MaxDeadline, 15*time.Second, "max deadline does not match expected") + require.WithinDuration(t, c.expectedMaxDeadline, workspaceBuild.MaxDeadline, 15*time.Second, "max deadline does not match expected") require.GreaterOrEqual(t, workspaceBuild.MaxDeadline.Unix(), workspaceBuild.Deadline.Unix(), "max deadline is smaller than deadline") } }) diff --git a/coderd/schedule/mock.go b/coderd/schedule/mock.go index 5c3c1e77ed803..04237e9b7ffd1 100644 --- a/coderd/schedule/mock.go +++ b/coderd/schedule/mock.go @@ -30,3 +30,26 @@ func (m MockTemplateScheduleStore) SetTemplateScheduleOptions(ctx context.Contex return NewAGPLTemplateScheduleStore().SetTemplateScheduleOptions(ctx, db, template, options) } + +type MockUserQuietHoursScheduleStore struct { + GetFn func(ctx context.Context, db database.Store, userID uuid.UUID) (UserQuietHoursScheduleOptions, error) + SetFn func(ctx context.Context, db database.Store, userID uuid.UUID, schedule string) (UserQuietHoursScheduleOptions, error) +} + +var _ UserQuietHoursScheduleStore = MockUserQuietHoursScheduleStore{} + +func (m MockUserQuietHoursScheduleStore) GetUserQuietHoursScheduleOptions(ctx context.Context, db database.Store, userID uuid.UUID) (UserQuietHoursScheduleOptions, error) { + if m.GetFn != nil { + return m.GetFn(ctx, db, userID) + } + + return NewAGPLUserQuietHoursScheduleStore().GetUserQuietHoursScheduleOptions(ctx, db, userID) +} + +func (m MockUserQuietHoursScheduleStore) SetUserQuietHoursScheduleOptions(ctx context.Context, db database.Store, userID uuid.UUID, schedule string) (UserQuietHoursScheduleOptions, error) { + if m.SetFn != nil { + return m.SetFn(ctx, db, userID, schedule) + } + + return NewAGPLUserQuietHoursScheduleStore().SetUserQuietHoursScheduleOptions(ctx, db, userID, schedule) +} diff --git a/coderd/schedule/template.go b/coderd/schedule/template.go index 23e9182a93b5c..091919ab87c17 100644 --- a/coderd/schedule/template.go +++ b/coderd/schedule/template.go @@ -5,18 +5,32 @@ import ( "time" "github.com/google/uuid" + "golang.org/x/xerrors" "github.com/coder/coder/coderd/database" ) +const MaxTemplateRestartRequirementWeeks = 16 + +func TemplateRestartRequirementEpoch(loc *time.Location) time.Time { + // The "first week" starts on January 2nd, 2023, which is the first Monday + // of 2023. All other weeks are counted using modulo arithmetic from that + // date. + return time.Date(2023, time.January, 2, 0, 0, 0, 0, loc) +} + +// DaysOfWeek intentionally starts on Monday as opposed to Sunday so the weekend +// days are contiguous in the bitmap. This matters greatly when doing restarts +// every second week or more to avoid workspaces restarting "at the start" of +// the week rather than "at the end" of the week. var DaysOfWeek = []time.Weekday{ - time.Sunday, time.Monday, time.Tuesday, time.Wednesday, time.Thursday, time.Friday, time.Saturday, + time.Sunday, } type TemplateRestartRequirement struct { @@ -24,9 +38,18 @@ type TemplateRestartRequirement struct { // restarted. If fully zero, the workspace is not required to be restarted // ever. // - // First bit is Sunday, second bit is Monday, ..., seventh bit is Saturday, - // eighth bit is unused. + // First bit is Monday, ..., seventh bit is Sunday, eighth bit is unused. DaysOfWeek uint8 + // Weeks is the amount of weeks between restarts. If zero, the workspace is + // restarted weekly in accordance with DaysOfWeek. If 1, the workspace is + // restarted every other week. And so forth. + // + // The limit for this value is 16, which is roughly 4 months. + // + // The "first week" starts on January 2nd, 2023, which is the first Monday + // of 2023. All other weeks are counted using modulo arithmetic from that + // date. + Weeks int64 } // Days returns the days of the week that the workspace must be restarted. @@ -50,6 +73,24 @@ func (r TemplateRestartRequirement) DaysMap() map[time.Weekday]bool { return days } +// VerifyTemplateRestartRequirement returns an error if the restart requirement +// is invalid. +func VerifyTemplateRestartRequirement(days uint8, weeks int64) error { + if days&0b10000000 != 0 { + return xerrors.New("invalid restart requirement days, last bit is set") + } + if days > 0b11111111 { + return xerrors.New("invalid restart requirement days, too large") + } + if weeks < 0 { + return xerrors.New("invalid restart requirement weeks, negative") + } + if weeks > 16 { + return xerrors.New("invalid restart requirement weeks, too large") + } + return nil +} + type TemplateScheduleOptions struct { UserAutostartEnabled bool `json:"user_autostart_enabled"` UserAutostopEnabled bool `json:"user_autostop_enabled"` @@ -99,6 +140,7 @@ func (*agplTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.Context // FailureTTL, InactivityTTL, and LockedTTL are enterprise features. RestartRequirement: TemplateRestartRequirement{ DaysOfWeek: 0, + Weeks: 0, }, FailureTTL: 0, InactivityTTL: 0, @@ -112,18 +154,18 @@ func (*agplTemplateScheduleStore) SetTemplateScheduleOptions(ctx context.Context return tpl, nil } - // TODO: fix storage to use new restart requirement return db.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{ ID: tpl.ID, UpdatedAt: database.Now(), DefaultTTL: int64(opts.DefaultTTL), // Don't allow changing these settings, but keep the value in the DB (to // avoid clearing settings if the license has an issue). - AllowUserAutostart: tpl.AllowUserAutostart, - AllowUserAutostop: tpl.AllowUserAutostop, - MaxTTL: tpl.MaxTTL, - FailureTTL: tpl.FailureTTL, - InactivityTTL: tpl.InactivityTTL, - LockedTTL: tpl.LockedTTL, + RestartRequirementDaysOfWeek: tpl.RestartRequirementDaysOfWeek, + RestartRequirementWeeks: tpl.RestartRequirementWeeks, + AllowUserAutostart: tpl.AllowUserAutostart, + AllowUserAutostop: tpl.AllowUserAutostop, + FailureTTL: tpl.FailureTTL, + InactivityTTL: tpl.InactivityTTL, + LockedTTL: tpl.LockedTTL, }) } diff --git a/coderd/templates.go b/coderd/templates.go index c8a06f3b88a8f..2bfcf4d8d00ca 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -309,7 +309,6 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque // Some of these values are enterprise-only, but the // TemplateScheduleStore will handle avoiding setting them if // unlicensed. - MaxTTL: maxTTL, FailureTTL: failureTTL, InactivityTTL: inactivityTTL, }) @@ -522,7 +521,6 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { req.AllowUserAutostop == template.AllowUserAutostop && req.AllowUserCancelWorkspaceJobs == template.AllowUserCancelWorkspaceJobs && req.DefaultTTLMillis == time.Duration(template.DefaultTTL).Milliseconds() && - req.MaxTTLMillis == time.Duration(template.MaxTTL).Milliseconds() && req.FailureTTLMillis == time.Duration(template.FailureTTL).Milliseconds() && req.InactivityTTLMillis == time.Duration(template.InactivityTTL).Milliseconds() && req.FailureTTLMillis == time.Duration(template.LockedTTL).Milliseconds() { @@ -550,13 +548,11 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { } defaultTTL := time.Duration(req.DefaultTTLMillis) * time.Millisecond - maxTTL := time.Duration(req.MaxTTLMillis) * time.Millisecond failureTTL := time.Duration(req.FailureTTLMillis) * time.Millisecond inactivityTTL := time.Duration(req.InactivityTTLMillis) * time.Millisecond lockedTTL := time.Duration(req.LockedTTLMillis) * time.Millisecond if defaultTTL != time.Duration(template.DefaultTTL) || - maxTTL != time.Duration(template.MaxTTL) || failureTTL != time.Duration(template.FailureTTL) || inactivityTTL != time.Duration(template.InactivityTTL) || lockedTTL != time.Duration(template.LockedTTL) || @@ -569,7 +565,6 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { UserAutostartEnabled: req.AllowUserAutostart, UserAutostopEnabled: req.AllowUserAutostop, DefaultTTL: defaultTTL, - MaxTTL: maxTTL, FailureTTL: failureTTL, InactivityTTL: inactivityTTL, LockedTTL: lockedTTL, @@ -718,7 +713,6 @@ func (api *API) convertTemplate( Description: template.Description, Icon: template.Icon, DefaultTTLMillis: time.Duration(template.DefaultTTL).Milliseconds(), - MaxTTLMillis: time.Duration(template.MaxTTL).Milliseconds(), CreatedByID: template.CreatedBy, CreatedByName: createdByName, AllowUserAutostart: template.AllowUserAutostart, diff --git a/coderd/templates_test.go b/coderd/templates_test.go index e476519f55d2e..42106ca2244ab 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -168,95 +168,6 @@ func TestPostTemplateByOrganization(t *testing.T) { require.Contains(t, err.Error(), "Try logging in using 'coder login '.") }) - t.Run("MaxTTL", func(t *testing.T) { - t.Parallel() - - const ( - defaultTTL = 1 * time.Hour - maxTTL = 24 * time.Hour - ) - - t.Run("OK", func(t *testing.T) { - t.Parallel() - - var setCalled int64 - client := coderdtest.New(t, &coderdtest.Options{ - TemplateScheduleStore: schedule.MockTemplateScheduleStore{ - SetFn: func(ctx context.Context, db database.Store, template database.Template, options schedule.TemplateScheduleOptions) (database.Template, error) { - atomic.AddInt64(&setCalled, 1) - require.Equal(t, maxTTL, options.MaxTTL) - template.DefaultTTL = int64(options.DefaultTTL) - template.MaxTTL = int64(options.MaxTTL) - return template, nil - }, - }, - }) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() - - got, err := client.CreateTemplate(ctx, user.OrganizationID, codersdk.CreateTemplateRequest{ - Name: "testing", - VersionID: version.ID, - DefaultTTLMillis: ptr.Ref(int64(0)), - MaxTTLMillis: ptr.Ref(maxTTL.Milliseconds()), - }) - require.NoError(t, err) - - require.EqualValues(t, 1, atomic.LoadInt64(&setCalled)) - require.EqualValues(t, 0, got.DefaultTTLMillis) - require.Equal(t, maxTTL.Milliseconds(), got.MaxTTLMillis) - }) - - t.Run("DefaultTTLBigger", func(t *testing.T) { - t.Parallel() - - client := coderdtest.New(t, nil) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() - - _, err := client.CreateTemplate(ctx, user.OrganizationID, codersdk.CreateTemplateRequest{ - Name: "testing", - VersionID: version.ID, - DefaultTTLMillis: ptr.Ref((maxTTL * 2).Milliseconds()), - MaxTTLMillis: ptr.Ref(maxTTL.Milliseconds()), - }) - require.Error(t, err) - var sdkErr *codersdk.Error - require.ErrorAs(t, err, &sdkErr) - require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) - require.Len(t, sdkErr.Validations, 1) - require.Equal(t, "default_ttl_ms", sdkErr.Validations[0].Field) - require.Contains(t, sdkErr.Validations[0].Detail, "Must be less than or equal to max_ttl_ms") - }) - - t.Run("IgnoredUnlicensed", func(t *testing.T) { - t.Parallel() - - client := coderdtest.New(t, nil) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() - - got, err := client.CreateTemplate(ctx, user.OrganizationID, codersdk.CreateTemplateRequest{ - Name: "testing", - VersionID: version.ID, - DefaultTTLMillis: ptr.Ref(defaultTTL.Milliseconds()), - MaxTTLMillis: ptr.Ref(maxTTL.Milliseconds()), - }) - require.NoError(t, err) - require.Equal(t, defaultTTL.Milliseconds(), got.DefaultTTLMillis) - require.Zero(t, got.MaxTTLMillis) - }) - }) - t.Run("AllowUserScheduling", func(t *testing.T) { t.Parallel() @@ -521,114 +432,6 @@ func TestPatchTemplateMeta(t *testing.T) { assert.Equal(t, updated.DefaultTTLMillis, template.DefaultTTLMillis) }) - t.Run("MaxTTL", func(t *testing.T) { - t.Parallel() - - const ( - defaultTTL = 1 * time.Hour - maxTTL = 24 * time.Hour - ) - - t.Run("OK", func(t *testing.T) { - t.Parallel() - - var setCalled int64 - client := coderdtest.New(t, &coderdtest.Options{ - TemplateScheduleStore: schedule.MockTemplateScheduleStore{ - SetFn: func(ctx context.Context, db database.Store, template database.Template, options schedule.TemplateScheduleOptions) (database.Template, error) { - if atomic.AddInt64(&setCalled, 1) == 2 { - require.Equal(t, maxTTL, options.MaxTTL) - } - template.DefaultTTL = int64(options.DefaultTTL) - template.MaxTTL = int64(options.MaxTTL) - return template, nil - }, - }, - }) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { - ctr.DefaultTTLMillis = ptr.Ref(24 * time.Hour.Milliseconds()) - }) - - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() - - got, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ - Name: template.Name, - DisplayName: template.DisplayName, - Description: template.Description, - Icon: template.Icon, - DefaultTTLMillis: 0, - MaxTTLMillis: maxTTL.Milliseconds(), - AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, - }) - require.NoError(t, err) - - require.EqualValues(t, 2, atomic.LoadInt64(&setCalled)) - require.EqualValues(t, 0, got.DefaultTTLMillis) - require.Equal(t, maxTTL.Milliseconds(), got.MaxTTLMillis) - }) - - t.Run("DefaultTTLBigger", func(t *testing.T) { - t.Parallel() - - client := coderdtest.New(t, nil) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { - ctr.DefaultTTLMillis = ptr.Ref(24 * time.Hour.Milliseconds()) - }) - - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() - - _, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ - Name: template.Name, - DisplayName: template.DisplayName, - Description: template.Description, - Icon: template.Icon, - DefaultTTLMillis: (maxTTL * 2).Milliseconds(), - MaxTTLMillis: maxTTL.Milliseconds(), - AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, - }) - require.Error(t, err) - var sdkErr *codersdk.Error - require.ErrorAs(t, err, &sdkErr) - require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) - require.Len(t, sdkErr.Validations, 1) - require.Equal(t, "default_ttl_ms", sdkErr.Validations[0].Field) - require.Contains(t, sdkErr.Validations[0].Detail, "Must be less than or equal to max_ttl_ms") - }) - - t.Run("IgnoredUnlicensed", func(t *testing.T) { - t.Parallel() - - client := coderdtest.New(t, nil) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { - ctr.DefaultTTLMillis = ptr.Ref(24 * time.Hour.Milliseconds()) - }) - - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() - - got, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ - Name: template.Name, - DisplayName: template.DisplayName, - Description: template.Description, - Icon: template.Icon, - DefaultTTLMillis: defaultTTL.Milliseconds(), - MaxTTLMillis: maxTTL.Milliseconds(), - AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, - }) - require.NoError(t, err) - require.Equal(t, defaultTTL.Milliseconds(), got.DefaultTTLMillis) - require.Zero(t, got.MaxTTLMillis) - }) - }) - t.Run("CleanupTTLs", func(t *testing.T) { t.Parallel() @@ -743,7 +546,6 @@ func TestPatchTemplateMeta(t *testing.T) { assert.Equal(t, allowAutostop.Load(), options.UserAutostopEnabled) template.DefaultTTL = int64(options.DefaultTTL) - template.MaxTTL = int64(options.MaxTTL) template.AllowUserAutostart = options.UserAutostartEnabled template.AllowUserAutostop = options.UserAutostopEnabled return template, nil diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 447c8403e23bf..e366af04a4acf 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -381,7 +381,7 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req return } - dbTTL, err := validWorkspaceTTLMillis(createWorkspace.TTLMillis, templateSchedule.DefaultTTL, templateSchedule.MaxTTL) + dbTTL, err := validWorkspaceTTLMillis(createWorkspace.TTLMillis, templateSchedule.DefaultTTL) if err != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Invalid Workspace Time to Shutdown.", @@ -719,7 +719,7 @@ func (api *API) putWorkspaceTTL(rw http.ResponseWriter, r *http.Request) { // don't override 0 ttl with template default here because it indicates // disabled autostop var validityErr error - dbTTL, validityErr = validWorkspaceTTLMillis(req.TTLMillis, 0, templateSchedule.MaxTTL) + dbTTL, validityErr = validWorkspaceTTLMillis(req.TTLMillis, 0) if validityErr != nil { return codersdk.ValidationError{Field: "ttl_ms", Detail: validityErr.Error()} } @@ -1102,20 +1102,9 @@ func calculateDeletingAt(workspace database.Workspace, template database.Templat return ptr.Ref(workspace.LastUsedAt.Add(time.Duration(template.InactivityTTL) * time.Nanosecond)) } -func validWorkspaceTTLMillis(millis *int64, templateDefault, templateMax time.Duration) (sql.NullInt64, error) { - if templateDefault == 0 && templateMax != 0 || (templateMax > 0 && templateDefault > templateMax) { - templateDefault = templateMax - } - +func validWorkspaceTTLMillis(millis *int64, templateDefault time.Duration) (sql.NullInt64, error) { if ptr.NilOrZero(millis) { if templateDefault == 0 { - if templateMax > 0 { - return sql.NullInt64{ - Int64: int64(templateMax), - Valid: true, - }, nil - } - return sql.NullInt64{}, nil } @@ -1135,10 +1124,6 @@ func validWorkspaceTTLMillis(millis *int64, templateDefault, templateMax time.Du return sql.NullInt64{}, errTTLMax } - if templateMax > 0 && truncated > templateMax { - return sql.NullInt64{}, xerrors.Errorf("time until shutdown must be less than or equal to the template's maximum TTL %q", templateMax.String()) - } - return sql.NullInt64{ Valid: true, Int64: int64(truncated), diff --git a/codersdk/templates.go b/codersdk/templates.go index ee81f99f3106b..560fb1c2d1ff1 100644 --- a/codersdk/templates.go +++ b/codersdk/templates.go @@ -30,7 +30,7 @@ type Template struct { DefaultTTLMillis int64 `json:"default_ttl_ms"` // MaxTTLMillis is an enterprise feature. It's value is only used if your // license is entitled to use the advanced template scheduling feature. - MaxTTLMillis int64 `json:"max_ttl_ms"` + // TODO: fix and comment CreatedByID uuid.UUID `json:"created_by_id" format:"uuid"` CreatedByName string `json:"created_by_name"` diff --git a/docs/admin/audit-logs.md b/docs/admin/audit-logs.md index 1ba4be5211515..90bbef62be8e2 100644 --- a/docs/admin/audit-logs.md +++ b/docs/admin/audit-logs.md @@ -9,18 +9,18 @@ We track the following resources: -| Resource | | -| -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| APIKey
login, logout, register, create, delete |
FieldTracked
created_attrue
expires_attrue
hashed_secretfalse
idfalse
ip_addressfalse
last_usedtrue
lifetime_secondsfalse
login_typefalse
scopefalse
token_namefalse
updated_atfalse
user_idtrue
| -| Group
create, write, delete |
FieldTracked
avatar_urltrue
idtrue
memberstrue
nametrue
organization_idfalse
quota_allowancetrue
| -| GitSSHKey
create |
FieldTracked
created_atfalse
private_keytrue
public_keytrue
updated_atfalse
user_idtrue
| -| License
create, delete |
FieldTracked
exptrue
idfalse
jwtfalse
uploaded_attrue
uuidtrue
| -| Template
write, delete |
FieldTracked
active_version_idtrue
allow_user_autostarttrue
allow_user_autostoptrue
allow_user_cancel_workspace_jobstrue
created_atfalse
created_bytrue
default_ttltrue
deletedfalse
descriptiontrue
display_nametrue
failure_ttltrue
group_acltrue
icontrue
idtrue
inactivity_ttltrue
locked_ttltrue
max_ttltrue
nametrue
organization_idfalse
provisionertrue
updated_atfalse
user_acltrue
| -| TemplateVersion
create, write |
FieldTracked
created_atfalse
created_bytrue
git_auth_providersfalse
idtrue
job_idfalse
nametrue
organization_idfalse
readmetrue
template_idtrue
updated_atfalse
| -| User
create, write, delete |
FieldTracked
avatar_urlfalse
created_atfalse
deletedtrue
emailtrue
hashed_passwordtrue
idtrue
last_seen_atfalse
login_typefalse
quiet_hours_scheduletrue
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| -| Workspace
create, write, delete |
FieldTracked
autostart_scheduletrue
created_atfalse
deletedfalse
idtrue
last_used_atfalse
nametrue
organization_idfalse
owner_idtrue
template_idtrue
ttltrue
updated_atfalse
| -| WorkspaceBuild
start, stop |
FieldTracked
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
idfalse
initiator_idfalse
job_idfalse
max_deadlinefalse
provisioner_statefalse
reasonfalse
template_version_idtrue
transitionfalse
updated_atfalse
workspace_idfalse
| -| WorkspaceProxy
|
FieldTracked
created_attrue
deletedfalse
display_nametrue
icontrue
idtrue
nametrue
token_hashed_secrettrue
updated_atfalse
urltrue
wildcard_hostnametrue
| +| Resource | | +| -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| APIKey
login, logout, register, create, delete |
FieldTracked
created_attrue
expires_attrue
hashed_secretfalse
idfalse
ip_addressfalse
last_usedtrue
lifetime_secondsfalse
login_typefalse
scopefalse
token_namefalse
updated_atfalse
user_idtrue
| +| Group
create, write, delete |
FieldTracked
avatar_urltrue
idtrue
memberstrue
nametrue
organization_idfalse
quota_allowancetrue
| +| GitSSHKey
create |
FieldTracked
created_atfalse
private_keytrue
public_keytrue
updated_atfalse
user_idtrue
| +| License
create, delete |
FieldTracked
exptrue
idfalse
jwtfalse
uploaded_attrue
uuidtrue
| +| Template
write, delete |
FieldTracked
active_version_idtrue
allow_user_autostarttrue
allow_user_autostoptrue
allow_user_cancel_workspace_jobstrue
created_atfalse
created_bytrue
default_ttltrue
deletedfalse
descriptiontrue
display_nametrue
failure_ttltrue
group_acltrue
icontrue
idtrue
inactivity_ttltrue
locked_ttltrue
nametrue
organization_idfalse
provisionertrue
restart_requirement_daystrue
restart_requirement_weekstrue
updated_atfalse
user_acltrue
| +| TemplateVersion
create, write |
FieldTracked
created_atfalse
created_bytrue
git_auth_providersfalse
idtrue
job_idfalse
nametrue
organization_idfalse
readmetrue
template_idtrue
updated_atfalse
| +| User
create, write, delete |
FieldTracked
avatar_urlfalse
created_atfalse
deletedtrue
emailtrue
hashed_passwordtrue
idtrue
last_seen_atfalse
login_typefalse
quiet_hours_scheduletrue
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| +| Workspace
create, write, delete |
FieldTracked
autostart_scheduletrue
created_atfalse
deletedfalse
idtrue
last_used_atfalse
nametrue
organization_idfalse
owner_idtrue
template_idtrue
ttltrue
updated_atfalse
| +| WorkspaceBuild
start, stop |
FieldTracked
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
idfalse
initiator_idfalse
job_idfalse
max_deadlinefalse
provisioner_statefalse
reasonfalse
template_version_idtrue
transitionfalse
updated_atfalse
workspace_idfalse
| +| WorkspaceProxy
|
FieldTracked
created_attrue
deletedfalse
display_nametrue
icontrue
idtrue
nametrue
token_hashed_secrettrue
updated_atfalse
urltrue
wildcard_hostnametrue
| diff --git a/docs/api/enterprise.md b/docs/api/enterprise.md index 2aa41a7fc328d..2244cfea345d0 100644 --- a/docs/api/enterprise.md +++ b/docs/api/enterprise.md @@ -1128,12 +1128,12 @@ To perform this operation, you must be authenticated. [Learn more](authenticatio ```shell # Example request using curl -curl -X GET http://coder-server:8080/api/v2/users/{user}/quiet-hours-schedule \ +curl -X GET http://coder-server:8080/api/v2/users/{user}/quiet-hours \ -H 'Accept: application/json' \ -H 'Coder-Session-Token: API_KEY' ``` -`GET /users/{user}/quiet-hours-schedule` +`GET /users/{user}/quiet-hours` ### Parameters @@ -1186,13 +1186,13 @@ To perform this operation, you must be authenticated. [Learn more](authenticatio ```shell # Example request using curl -curl -X PUT http://coder-server:8080/api/v2/users/{user}/quiet-hours-schedule \ +curl -X PUT http://coder-server:8080/api/v2/users/{user}/quiet-hours \ -H 'Content-Type: application/json' \ -H 'Accept: application/json' \ -H 'Coder-Session-Token: API_KEY' ``` -`PUT /users/{user}/quiet-hours-schedule` +`PUT /users/{user}/quiet-hours` > Body parameter diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 6069a72009768..eb6e6cb551567 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -3762,7 +3762,6 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "inactivity_ttl_ms": 0, "locked_ttl_ms": 0, - "max_ttl_ms": 0, "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "provisioner": "terraform", @@ -3781,7 +3780,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `allow_user_cancel_workspace_jobs` | boolean | false | | | | `build_time_stats` | [codersdk.TemplateBuildTimeStats](#codersdktemplatebuildtimestats) | false | | | | `created_at` | string | false | | | -| `created_by_id` | string | false | | | +| `created_by_id` | string | false | | Created by ID is an enterprise feature. It's value is only used if your license is entitled to use the advanced template scheduling feature. TODO: fix and comment | | `created_by_name` | string | false | | | | `default_ttl_ms` | integer | false | | | | `description` | string | false | | | @@ -3791,7 +3790,6 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `id` | string | false | | | | `inactivity_ttl_ms` | integer | false | | | | `locked_ttl_ms` | integer | false | | | -| `max_ttl_ms` | integer | false | | Max ttl ms is an enterprise feature. It's value is only used if your license is entitled to use the advanced template scheduling feature. | | `name` | string | false | | | | `organization_id` | string | false | | | | `provisioner` | string | false | | | diff --git a/docs/api/templates.md b/docs/api/templates.md index 8a65ff8e45b08..a10d861c6989c 100644 --- a/docs/api/templates.md +++ b/docs/api/templates.md @@ -52,7 +52,6 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/templat "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "inactivity_ttl_ms": 0, "locked_ttl_ms": 0, - "max_ttl_ms": 0, "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "provisioner": "terraform", @@ -84,7 +83,7 @@ Status Code **200** | `»»» p50` | integer | false | | | | `»»» p95` | integer | false | | | | `» created_at` | string(date-time) | false | | | -| `» created_by_id` | string(uuid) | false | | | +| `» created_by_id` | string(uuid) | false | | Created by ID is an enterprise feature. It's value is only used if your license is entitled to use the advanced template scheduling feature. TODO: fix and comment | | `» created_by_name` | string | false | | | | `» default_ttl_ms` | integer | false | | | | `» description` | string | false | | | @@ -94,7 +93,6 @@ Status Code **200** | `» id` | string(uuid) | false | | | | `» inactivity_ttl_ms` | integer | false | | | | `» locked_ttl_ms` | integer | false | | | -| `» max_ttl_ms` | integer | false | | Max ttl ms is an enterprise feature. It's value is only used if your license is entitled to use the advanced template scheduling feature. | | `» name` | string | false | | | | `» organization_id` | string(uuid) | false | | | | `» provisioner` | string | false | | | @@ -182,7 +180,6 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/templa "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "inactivity_ttl_ms": 0, "locked_ttl_ms": 0, - "max_ttl_ms": 0, "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "provisioner": "terraform", @@ -310,7 +307,6 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/templat "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "inactivity_ttl_ms": 0, "locked_ttl_ms": 0, - "max_ttl_ms": 0, "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "provisioner": "terraform", @@ -640,7 +636,6 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template} \ "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "inactivity_ttl_ms": 0, "locked_ttl_ms": 0, - "max_ttl_ms": 0, "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "provisioner": "terraform", @@ -751,7 +746,6 @@ curl -X PATCH http://coder-server:8080/api/v2/templates/{template} \ "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "inactivity_ttl_ms": 0, "locked_ttl_ms": 0, - "max_ttl_ms": 0, "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "provisioner": "terraform", diff --git a/docs/cli/server.md b/docs/cli/server.md index 4bf2e597fd356..4987fd4278a65 100644 --- a/docs/cli/server.md +++ b/docs/cli/server.md @@ -652,7 +652,7 @@ Origin addresses to respect "proxy-trusted-headers". e.g. 192.168.1.0/24. | | | | ----------- | ------------------------------------------------------------ | -| Type | string | +| Type | duration | | Environment | $CODER_QUIET_HOURS_WINDOW_DURATION | | YAML | userQuietHoursSchedule.quietHoursWindowDuration | | Default | 4h0m0s | diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index 510a2c8e4c2c5..3cdbea444da81 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -74,7 +74,8 @@ var auditableResourcesTypes = map[any]map[string]Action{ "allow_user_autostart": ActionTrack, "allow_user_autostop": ActionTrack, "allow_user_cancel_workspace_jobs": ActionTrack, - "max_ttl": ActionTrack, + "restart_requirement_days": ActionTrack, + "restart_requirement_weeks": ActionTrack, "failure_ttl": ActionTrack, "inactivity_ttl": ActionTrack, "locked_ttl": ActionTrack, diff --git a/enterprise/coderd/schedule/template.go b/enterprise/coderd/schedule/template.go index 5adc99d8545b0..fbaf1c3b48bc3 100644 --- a/enterprise/coderd/schedule/template.go +++ b/enterprise/coderd/schedule/template.go @@ -28,13 +28,25 @@ func (*EnterpriseTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.C return agpl.TemplateScheduleOptions{}, err } + // These extra checks have to be done before the conversion. + 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), - // TODO: fix storage to use new restart requirement RestartRequirement: agpl.TemplateRestartRequirement{ - DaysOfWeek: 0b01111111, + DaysOfWeek: uint8(tpl.RestartRequirementDaysOfWeek), + Weeks: tpl.RestartRequirementWeeks, }, FailureTTL: time.Duration(tpl.FailureTTL), InactivityTTL: time.Duration(tpl.InactivityTTL), @@ -45,8 +57,8 @@ func (*EnterpriseTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.C // SetTemplateScheduleOptions implements agpl.TemplateScheduleStore. func (*EnterpriseTemplateScheduleStore) SetTemplateScheduleOptions(ctx context.Context, db database.Store, tpl database.Template, opts agpl.TemplateScheduleOptions) (database.Template, error) { if int64(opts.DefaultTTL) == tpl.DefaultTTL && - // TODO: fix storage to use new restart requirement - int64(opts.RestartRequirement.DaysOfWeek) == 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 && @@ -56,17 +68,22 @@ func (*EnterpriseTemplateScheduleStore) SetTemplateScheduleOptions(ctx context.C return tpl, nil } + err := agpl.VerifyTemplateRestartRequirement(uint8(opts.RestartRequirement.DaysOfWeek), opts.RestartRequirement.Weeks) + if err != nil { + return database.Template{}, err + } + template, err := db.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{ - ID: tpl.ID, - UpdatedAt: database.Now(), - AllowUserAutostart: opts.UserAutostartEnabled, - AllowUserAutostop: opts.UserAutostopEnabled, - DefaultTTL: int64(opts.DefaultTTL), - // TODO: fix storage to use new restart requirement - MaxTTL: 0, - FailureTTL: int64(opts.FailureTTL), - InactivityTTL: int64(opts.InactivityTTL), - LockedTTL: int64(opts.LockedTTL), + ID: tpl.ID, + UpdatedAt: database.Now(), + AllowUserAutostart: opts.UserAutostartEnabled, + AllowUserAutostop: opts.UserAutostopEnabled, + DefaultTTL: int64(opts.DefaultTTL), + 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 database.Template{}, xerrors.Errorf("update template schedule: %w", err) diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index e75fe1d39423e..6098b18080d43 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -804,7 +804,6 @@ export interface Template { readonly description: string readonly icon: string readonly default_ttl_ms: number - readonly max_ttl_ms: number readonly created_by_id: string readonly created_by_name: string readonly allow_user_autostart: boolean From 780812c7ae877aeaa8848b4d904d874ac633b11e Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Thu, 6 Jul 2023 04:37:15 +0000 Subject: [PATCH 08/25] working --- ...000132_template_restart_requirement.up.sql | 2 +- .../provisionerdserver/provisionerdserver.go | 92 +++++++++++++------ .../provisionerdserver_test.go | 83 ++++++++++++++--- coderd/schedule/template.go | 4 +- 4 files changed, 137 insertions(+), 44 deletions(-) diff --git a/coderd/database/migrations/000132_template_restart_requirement.up.sql b/coderd/database/migrations/000132_template_restart_requirement.up.sql index fcc8fab6132fc..a9586e4252395 100644 --- a/coderd/database/migrations/000132_template_restart_requirement.up.sql +++ b/coderd/database/migrations/000132_template_restart_requirement.up.sql @@ -6,6 +6,6 @@ ALTER TABLE templates ADD COLUMN restart_requirement_weeks bigint NOT NULL DEFAULT 0; COMMENT ON COLUMN templates.restart_requirement_days_of_week IS 'A bitmap of days of week to restart the workspace on, starting with Monday as the 0th bit, and Sunday as the 6th bit. The 7th bit is unused.'; -COMMENT ON COLUMN templates.restart_requirement_weeks IS 'The number of weeks between restarts. 0 weeks means "every week", 1 week means "every other week", etc. Weeks are counted from January 2, 2023, which is the first Monday of 2023. This is to ensure workspaces are started consistently for all customers on the same n-week cycles.'; +COMMENT ON COLUMN templates.restart_requirement_weeks IS 'The number of weeks between restarts. 0 or 1 weeks means "every week", 2 week means "every second week", etc. Weeks are counted from January 2, 2023, which is the first Monday of 2023. This is to ensure workspaces are started consistently for all customers on the same n-week cycles.'; COMMIT; diff --git a/coderd/provisionerdserver/provisionerdserver.go b/coderd/provisionerdserver/provisionerdserver.go index 9d084dfa08cab..2b2026317616f 100644 --- a/coderd/provisionerdserver/provisionerdserver.go +++ b/coderd/provisionerdserver/provisionerdserver.go @@ -43,6 +43,19 @@ import ( sdkproto "github.com/coder/coder/provisionersdk/proto" ) +const ( + // restartRequirementLeeway is the duration of time before a restart + // requirement where we skip the requirement and fall back to the next + // scheduled restart. This avoids workspaces being restarted too soon. + restartRequirementLeeway = 1 * time.Hour + + // restartRequirementBuffer is the duration of time we subtract from the + // time when calculating the next scheduled restart time. This avoids issues + // where autostart happens on the hour and the scheduled quiet hours are + // also on the hour. + restartRequirementBuffer = -15 * time.Minute +) + var ( lastAcquire time.Time lastAcquireMutex sync.RWMutex @@ -961,39 +974,48 @@ func (server *Server) CompleteJob(ctx context.Context, completed *proto.Complete if userQuietHoursSchedule.Schedule != nil { loc := userQuietHoursSchedule.Schedule.Location() now := server.timeNow().In(loc) - startOfDay := truncateMidnight(now) + // Add the leeway here so we avoid checking today's quiet + // hours if the workspace was started <1h before midnight. + startOfStopDay := truncateMidnight(now.Add(restartRequirementLeeway)) // If the template schedule wants to only restart on n-th - // weeks then change the startOfDay to be the start of the - // next n-th week if this week is not an n-th week. - /* - TODO: get working and add tests - if templateSchedule.RestartRequirement.Weeks != 0 { - epoch := schedule.TemplateRestartRequirementEpoch(loc) - if startOfDay.Before(epoch) { - return xerrors.Errorf("coder server system clock is incorrect, cannot calculate template restart requirement") - } - since := startOfDay.Sub(epoch) - weeksSince := int64(since.Hours() / (24 * 7)) - if weeksSince%templateSchedule.RestartRequirement.Weeks != 0 { - startOfDay = epoch.Add(time.Duration(weeksSince/templateSchedule.RestartRequirement.Weeks+1) * 24 * 7 * time.Hour) - startOfDay = truncateMidnight(startOfDay) - } + // weeks then change the startOfDay to be the Monday of the + // next applicable week. + if templateSchedule.RestartRequirement.Weeks > 1 { + epoch := schedule.TemplateRestartRequirementEpoch(loc) + if startOfStopDay.Before(epoch) { + return xerrors.New("coder server system clock is incorrect, cannot calculate template restart requirement") + } + since := startOfStopDay.Sub(epoch) + weeksSinceEpoch := int64(since.Hours() / (24 * 7)) + requiredWeeks := templateSchedule.RestartRequirement.Weeks + weeksRemainder := weeksSinceEpoch % requiredWeeks + if weeksRemainder != 0 { + // Add (requiredWeeks - weeksSince) * 7 days to the + // current startOfStopDay, then truncate to Monday + // midnight. + // + // This sets startOfStopDay to Monday at midnight of + // the next applicable week. + y, mo, d := startOfStopDay.Date() + d += int(requiredWeeks-weeksRemainder) * 7 + startOfStopDay = time.Date(y, mo, d, 0, 0, 0, 0, loc) + startOfStopDay = truncateMondayMidnight(startOfStopDay) } - */ + } - // Determine if we should skip today because the schedule is - // too near or has already passed. + // Determine if we should skip the first day because the + // schedule is too near or has already passed. // // Allow an hour of leeway (i.e. any workspaces started // within an hour of the scheduled stop time will always // bounce to the next stop window). - todaySchedule := userQuietHoursSchedule.Schedule.Next(startOfDay) - if todaySchedule.Before(now.Add(time.Hour)) { + checkSchedule := userQuietHoursSchedule.Schedule.Next(startOfStopDay.Add(restartRequirementBuffer)) + if checkSchedule.Before(now.Add(restartRequirementLeeway)) { // Set the first stop day we try to tomorrow because // today's schedule is too close to now or has already // passed. - startOfDay = nextDayMidnight(startOfDay) + startOfStopDay = nextDayMidnight(startOfStopDay) } // Iterate from 0 to 7, check if the current startOfDay is @@ -1005,17 +1027,17 @@ func (server *Server) CompleteJob(ctx context.Context, completed *proto.Complete // We've wrapped, so somehow we couldn't find a day // in the restart requirement. This shouldn't happen // because the restart requirement has a day set. - return xerrors.Errorf("could not find suitable day for template restart requirement in the next 7 days") + return xerrors.New("could not find suitable day for template restart requirement in the next 7 days") } - if requirementDays[startOfDay.Weekday()] { + if requirementDays[startOfStopDay.Weekday()] { break } - startOfDay = nextDayMidnight(startOfDay) + startOfStopDay = nextDayMidnight(startOfStopDay) } // If the startOfDay is within an hour of now, then we add // an hour. - checkTime := startOfDay + checkTime := startOfStopDay if checkTime.Before(now.Add(time.Hour)) { checkTime = now.Add(time.Hour) } else { @@ -1023,13 +1045,13 @@ func (server *Server) CompleteJob(ctx context.Context, completed *proto.Complete // minutes to give a little leeway. This prevents // skipped stop events because autostart perfectly lines // up with autostop. - checkTime = checkTime.Add(-15 * time.Minute) + checkTime = checkTime.Add(restartRequirementBuffer) } // Get the next occurrence of the restart schedule. maxDeadline = userQuietHoursSchedule.Schedule.Next(checkTime) if maxDeadline.IsZero() { - return xerrors.Errorf("could not find next occurrence of template restart requirement in user quiet hours schedule") + return xerrors.New("could not find next occurrence of template restart requirement in user quiet hours schedule") } } @@ -1682,5 +1704,17 @@ func nextDayMidnight(t time.Time) time.Time { yy, mm, dd := t.Date() // time.Date will correctly normalize the date if it's past the end of the // month. E.g. October 32nd will be November 1st. - return time.Date(yy, mm, dd+1, 0, 0, 0, 0, t.Location()) + dd += 1 + return time.Date(yy, mm, dd, 0, 0, 0, 0, t.Location()) +} + +// truncateMondayMidnight truncates a time to the previous Monday at midnight in +// the time object's timezone. +func truncateMondayMidnight(t time.Time) time.Time { + // time.Date will correctly normalize the date if it's past the end of the + // month. E.g. October 32nd will be November 1st. + yy, mm, dd := t.Date() + dd -= int(t.Weekday() - 1) + t = time.Date(yy, mm, dd, 0, 0, 0, 0, t.Location()) + return truncateMidnight(t) } diff --git a/coderd/provisionerdserver/provisionerdserver_test.go b/coderd/provisionerdserver/provisionerdserver_test.go index 82c21c5a42f9c..f5717b56663f1 100644 --- a/coderd/provisionerdserver/provisionerdserver_test.go +++ b/coderd/provisionerdserver/provisionerdserver_test.go @@ -906,16 +906,18 @@ func TestCompleteJob(t *testing.T) { now := time.Now() - // Wednesday the 5th of July 2023 at midnight. - wednesdayMidnightUTC := time.Date(2023, 7, 5, 0, 0, 0, 0, time.UTC) + // Wednesday the 8th of February 2023 at midnight. This date was + // specifically chosen as it doesn't fall on a applicable week for both + // fortnightly and triweekly restart requirements. + wednesdayMidnightUTC := time.Date(2023, 2, 8, 0, 0, 0, 0, time.UTC) sydneyQuietHours := "CRON_TZ=Australia/Sydney 0 0 * * *" sydneyLoc, err := time.LoadLocation("Australia/Sydney") require.NoError(t, err) - // 10pm on Friday the 7th of July 2023 in Sydney. - fridayEveningSydney := time.Date(2023, 7, 7, 22, 0, 0, 0, sydneyLoc) - // 12am on Saturday the 8th of July 2023 in Sydney. - saturdayMidnightSydney := time.Date(2023, 7, 8, 0, 0, 0, 0, sydneyLoc) + // 10pm on Friday the 10th of February 2023 in Sydney. + fridayEveningSydney := time.Date(2023, 2, 10, 22, 0, 0, 0, sydneyLoc) + // 12am on Saturday the 11th of February2023 in Sydney. + saturdayMidnightSydney := time.Date(2023, 2, 11, 0, 0, 0, 0, sydneyLoc) t.Log("now", now) t.Log("wednesdayMidnightUTC", wednesdayMidnightUTC) @@ -1026,7 +1028,7 @@ func TestCompleteJob(t *testing.T) { userQuietHoursSchedule: sydneyQuietHours, templateRestartRequirement: schedule.TemplateRestartRequirement{ DaysOfWeek: 0b00100000, // Saturday - Weeks: 0, // weekly + Weeks: 1, // 1 also means weekly }, workspaceTTL: 0, transition: database.WorkspaceTransitionStart, @@ -1050,6 +1052,68 @@ func TestCompleteJob(t *testing.T) { // expectedDeadline is copied from expectedMaxDeadline. expectedMaxDeadline: saturdayMidnightSydney.In(time.UTC), }, + { + name: "TemplateRestartRequirementFortnightly/Skip", + now: wednesdayMidnightUTC, + templateAllowAutostop: true, + templateDefaultTTL: 0, + userQuietHoursSchedule: sydneyQuietHours, + templateRestartRequirement: schedule.TemplateRestartRequirement{ + DaysOfWeek: 0b00100000, // Saturday + Weeks: 2, // every 2 weeks + }, + workspaceTTL: 0, + transition: database.WorkspaceTransitionStart, + // expectedDeadline is copied from expectedMaxDeadline. + expectedMaxDeadline: saturdayMidnightSydney.AddDate(0, 0, 7).In(time.UTC), + }, + { + name: "TemplateRestartRequirementFortnightly/NoSkip", + now: wednesdayMidnightUTC.AddDate(0, 0, 7), + templateAllowAutostop: true, + templateDefaultTTL: 0, + userQuietHoursSchedule: sydneyQuietHours, + templateRestartRequirement: schedule.TemplateRestartRequirement{ + DaysOfWeek: 0b00100000, // Saturday + Weeks: 2, // every 2 weeks + }, + workspaceTTL: 0, + transition: database.WorkspaceTransitionStart, + // expectedDeadline is copied from expectedMaxDeadline. + expectedMaxDeadline: saturdayMidnightSydney.AddDate(0, 0, 7).In(time.UTC), + }, + { + name: "TemplateRestartRequirementTriweekly/Skip", + now: wednesdayMidnightUTC, + templateAllowAutostop: true, + templateDefaultTTL: 0, + userQuietHoursSchedule: sydneyQuietHours, + templateRestartRequirement: schedule.TemplateRestartRequirement{ + DaysOfWeek: 0b00100000, // Saturday + Weeks: 3, // every 3 weeks + }, + workspaceTTL: 0, + transition: database.WorkspaceTransitionStart, + // expectedDeadline is copied from expectedMaxDeadline. + // The next triweekly restart requirement happens next week + // according to the epoch. + expectedMaxDeadline: saturdayMidnightSydney.AddDate(0, 0, 7).In(time.UTC), + }, + { + name: "TemplateRestartRequirementTriweekly/NoSkip", + now: wednesdayMidnightUTC.AddDate(0, 0, 7), + templateAllowAutostop: true, + templateDefaultTTL: 0, + userQuietHoursSchedule: sydneyQuietHours, + templateRestartRequirement: schedule.TemplateRestartRequirement{ + DaysOfWeek: 0b00100000, // Saturday + Weeks: 3, // every 3 weeks + }, + workspaceTTL: 0, + transition: database.WorkspaceTransitionStart, + // expectedDeadline is copied from expectedMaxDeadline. + expectedMaxDeadline: saturdayMidnightSydney.AddDate(0, 0, 7).In(time.UTC), + }, { name: "TemplateRestartRequirementOverridesWorkspaceTTL", // now doesn't have to be UTC, but it helps us ensure that @@ -1087,11 +1151,6 @@ func TestCompleteJob(t *testing.T) { for _, c := range cases { c := c - if c.name != "TemplateRestartRequirement1HourSkip" { - // TODO: REMOVE - continue - } - t.Run(c.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/schedule/template.go b/coderd/schedule/template.go index 091919ab87c17..83073ea233383 100644 --- a/coderd/schedule/template.go +++ b/coderd/schedule/template.go @@ -40,8 +40,8 @@ type TemplateRestartRequirement struct { // // First bit is Monday, ..., seventh bit is Sunday, eighth bit is unused. DaysOfWeek uint8 - // Weeks is the amount of weeks between restarts. If zero, the workspace is - // restarted weekly in accordance with DaysOfWeek. If 1, the workspace is + // Weeks is the amount of weeks between restarts. If 0 or 1, the workspace + // is restarted weekly in accordance with DaysOfWeek. If 2, the workspace is // restarted every other week. And so forth. // // The limit for this value is 16, which is roughly 4 months. From 00e4a0f5ab4c5f75585e1f26fc8436473903bb9f Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Fri, 7 Jul 2023 14:16:07 +0000 Subject: [PATCH 09/25] tests mostly fixed --- cli/schedule_test.go | 3 +- cli/templateedit.go | 53 ++++- cli/templateedit_test.go | 188 +++++++++++++----- .../coder_templates_edit_--help.golden | 15 +- coderd/activitybump_test.go | 31 ++- coderd/apidoc/docs.go | 46 ++++- coderd/apidoc/swagger.json | 46 ++++- coderd/autobuild/lifecycle_executor_test.go | 2 +- coderd/database/dump.sql | 4 +- coderd/database/models.go | 4 +- coderd/schedule/template.go | 2 +- coderd/schedule/user.go | 4 +- coderd/templates.go | 87 ++++++-- coderd/templates_test.go | 24 ++- coderd/workspaces_test.go | 4 +- codersdk/deployment.go | 2 - codersdk/organizations.go | 6 +- codersdk/templates.go | 99 +++++++-- docs/admin/audit-logs.md | 24 +-- docs/api/schemas.md | 105 ++++++---- docs/api/templates.md | 83 +++++--- docs/cli/templates_edit.md | 24 ++- enterprise/audit/table.go | 4 +- enterprise/coderd/coderd.go | 25 +-- enterprise/coderd/coderd_test.go | 1 - .../coderd/coderdenttest/coderdenttest.go | 17 +- enterprise/coderd/schedule/user.go | 4 +- enterprise/coderd/templates_test.go | 160 +-------------- site/src/api/typesGenerated.ts | 11 +- 29 files changed, 671 insertions(+), 407 deletions(-) diff --git a/cli/schedule_test.go b/cli/schedule_test.go index a3a3a781ff578..d1e6fe2da543f 100644 --- a/cli/schedule_test.go +++ b/cli/schedule_test.go @@ -316,7 +316,8 @@ func TestScheduleOverride(t *testing.T) { stdoutBuf = &bytes.Buffer{} ) require.Zero(t, template.DefaultTTLMillis) - require.Zero(t, template.MaxTTLMillis) + require.Empty(t, template.RestartRequirement.DaysOfWeek) + require.Zero(t, template.RestartRequirement.Weeks) // Unset the workspace TTL err = client.UpdateWorkspaceTTL(ctx, workspace.ID, codersdk.UpdateWorkspaceTTLRequest{TTLMillis: nil}) diff --git a/cli/templateedit.go b/cli/templateedit.go index 53818e86b33b5..bf6950e0b20e6 100644 --- a/cli/templateedit.go +++ b/cli/templateedit.go @@ -19,7 +19,8 @@ func (r *RootCmd) templateEdit() *clibase.Cmd { description string icon string defaultTTL time.Duration - maxTTL time.Duration + restartRequirementDaysOfWeek []string + restartRequirementWeeks int64 failureTTL time.Duration inactivityTTL time.Duration allowUserCancelWorkspaceJobs bool @@ -48,7 +49,14 @@ func (r *RootCmd) templateEdit() *clibase.Cmd { } } - if maxTTL != 0 || !allowUserAutostart || !allowUserAutostop || failureTTL != 0 || inactivityTTL != 0 { + unsetRestartRequirementDaysOfWeek := len(restartRequirementDaysOfWeek) == 1 && restartRequirementDaysOfWeek[0] == "none" + requiresEntitlement := (len(restartRequirementDaysOfWeek) > 0 && !unsetRestartRequirementDaysOfWeek) || + restartRequirementWeeks > 0 || + !allowUserAutostart || + !allowUserAutostop || + failureTTL != 0 || + inactivityTTL != 0 + if requiresEntitlement { entitlements, err := client.Entitlements(inv.Context()) var sdkErr *codersdk.Error if xerrors.As(err, &sdkErr) && sdkErr.StatusCode() == http.StatusNotFound { @@ -71,14 +79,32 @@ func (r *RootCmd) templateEdit() *clibase.Cmd { return xerrors.Errorf("get workspace template: %w", err) } + // Copy the default value if the list is empty, or if the user + // specified the "none" value clear the list. + if len(restartRequirementDaysOfWeek) == 0 { + restartRequirementDaysOfWeek = template.RestartRequirement.DaysOfWeek + } + if unsetRestartRequirementDaysOfWeek { + restartRequirementDaysOfWeek = []string{} + } + + // Check that the user didn't specify a value that is not allowed. + _, err = codersdk.WeekdaysToBitmap(restartRequirementDaysOfWeek) + if err != nil { + return xerrors.Errorf("invalid restart requirement days of week: %w", err) + } + // NOTE: coderd will ignore empty fields. req := codersdk.UpdateTemplateMeta{ - Name: name, - DisplayName: displayName, - Description: description, - Icon: icon, - DefaultTTLMillis: defaultTTL.Milliseconds(), - MaxTTLMillis: maxTTL.Milliseconds(), + Name: name, + DisplayName: displayName, + Description: description, + Icon: icon, + DefaultTTLMillis: defaultTTL.Milliseconds(), + RestartRequirement: &codersdk.TemplateRestartRequirement{ + DaysOfWeek: restartRequirementDaysOfWeek, + Weeks: restartRequirementWeeks, + }, FailureTTLMillis: failureTTL.Milliseconds(), InactivityTTLMillis: inactivityTTL.Milliseconds(), AllowUserCancelWorkspaceJobs: allowUserCancelWorkspaceJobs, @@ -122,9 +148,14 @@ func (r *RootCmd) templateEdit() *clibase.Cmd { Value: clibase.DurationOf(&defaultTTL), }, { - Flag: "max-ttl", - Description: "Edit the template maximum time before shutdown - workspaces created from this template must shutdown within the given duration after starting. This is an enterprise-only feature.", - Value: clibase.DurationOf(&maxTTL), + Flag: "restart-requirement-weekdays", + Description: "Edit the template restart requirement weekdays - workspaces created from this template must be restarted on the given weekdays. To unset this value for the template (and disable the restart requirement for the template), pass 'none'.", + Value: clibase.StringArrayOf(&restartRequirementDaysOfWeek), + }, + { + Flag: "restart-requirement-weeks", + Description: "Edit the template restart requirement weeks - workspaces created from this template must be restarted on an n-weekly basis.", + Value: clibase.Int64Of(&restartRequirementWeeks), }, { Flag: "failure-ttl", diff --git a/cli/templateedit_test.go b/cli/templateedit_test.go index 384fc30e7e1ae..ff86721a75a73 100644 --- a/cli/templateedit_test.go +++ b/cli/templateedit_test.go @@ -242,7 +242,7 @@ func TestTemplateEdit(t *testing.T) { assert.Equal(t, "", updated.Icon) assert.Equal(t, "", updated.DisplayName) }) - t.Run("MaxTTL", func(t *testing.T) { + t.Run("RestartRequirement", func(t *testing.T) { t.Parallel() t.Run("BlockedAGPL", func(t *testing.T) { t.Parallel() @@ -252,33 +252,70 @@ func TestTemplateEdit(t *testing.T) { _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { ctr.DefaultTTLMillis = nil - ctr.MaxTTLMillis = nil + ctr.RestartRequirement = nil }) - // Test the cli command. - cmdArgs := []string{ - "templates", - "edit", - template.Name, - "--max-ttl", "1h", + cases := []struct { + name string + flags []string + ok bool + }{ + { + name: "Weekdays", + flags: []string{ + "--restart-requirement-weekdays", "monday", + }, + }, + { + name: "WeekdaysNoneAllowed", + flags: []string{ + "--restart-requirement-weekdays", "none", + }, + ok: true, + }, + { + name: "Weeks", + flags: []string{ + "--restart-requirement-weeks", "1", + }, + }, } - inv, root := clitest.New(t, cmdArgs...) - clitest.SetupConfig(t, client, root) - ctx := testutil.Context(t, testutil.WaitLong) - err := inv.WithContext(ctx).Run() - require.Error(t, err) - require.ErrorContains(t, err, "appears to be an AGPL deployment") + for _, c := range cases { + c := c + t.Run(c.name, func(t *testing.T) { + t.Parallel() - // Assert that the template metadata did not change. - updated, err := client.Template(context.Background(), template.ID) - require.NoError(t, err) - assert.Equal(t, template.Name, updated.Name) - assert.Equal(t, template.Description, updated.Description) - assert.Equal(t, template.Icon, updated.Icon) - assert.Equal(t, template.DisplayName, updated.DisplayName) - assert.Equal(t, template.DefaultTTLMillis, updated.DefaultTTLMillis) - assert.Equal(t, template.MaxTTLMillis, updated.MaxTTLMillis) + cmdArgs := []string{ + "templates", + "edit", + template.Name, + } + cmdArgs = append(cmdArgs, c.flags...) + inv, root := clitest.New(t, cmdArgs...) + clitest.SetupConfig(t, client, root) + + ctx := testutil.Context(t, testutil.WaitLong) + err := inv.WithContext(ctx).Run() + if c.ok { + require.NoError(t, err) + } else { + require.Error(t, err) + require.ErrorContains(t, err, "appears to be an AGPL deployment") + } + + // Assert that the template metadata did not change. + updated, err := client.Template(context.Background(), template.ID) + require.NoError(t, err) + assert.Equal(t, template.Name, updated.Name) + assert.Equal(t, template.Description, updated.Description) + assert.Equal(t, template.Icon, updated.Icon) + assert.Equal(t, template.DisplayName, updated.DisplayName) + assert.Equal(t, template.DefaultTTLMillis, updated.DefaultTTLMillis) + assert.Equal(t, template.RestartRequirement.DaysOfWeek, updated.RestartRequirement.DaysOfWeek) + assert.Equal(t, template.RestartRequirement.Weeks, updated.RestartRequirement.Weeks) + }) + } }) t.Run("BlockedNotEntitled", func(t *testing.T) { @@ -289,7 +326,7 @@ func TestTemplateEdit(t *testing.T) { _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { ctr.DefaultTTLMillis = nil - ctr.MaxTTLMillis = nil + ctr.RestartRequirement = nil }) // Make a proxy server that will return a valid entitlements @@ -319,7 +356,7 @@ func TestTemplateEdit(t *testing.T) { // Otherwise, proxy the request to the real API server. httputil.NewSingleHostReverseProxy(client.URL).ServeHTTP(w, r) })) - defer proxy.Close() + t.Cleanup(proxy.Close) // Create a new client that uses the proxy server. proxyURL, err := url.Parse(proxy.URL) @@ -327,30 +364,67 @@ func TestTemplateEdit(t *testing.T) { proxyClient := codersdk.New(proxyURL) proxyClient.SetSessionToken(client.SessionToken()) - // Test the cli command. - cmdArgs := []string{ - "templates", - "edit", - template.Name, - "--max-ttl", "1h", + cases := []struct { + name string + flags []string + ok bool + }{ + { + name: "Weekdays", + flags: []string{ + "--restart-requirement-weekdays", "monday", + }, + }, + { + name: "WeekdaysNoneAllowed", + flags: []string{ + "--restart-requirement-weekdays", "none", + }, + ok: true, + }, + { + name: "Weeks", + flags: []string{ + "--restart-requirement-weeks", "1", + }, + }, } - inv, root := clitest.New(t, cmdArgs...) - clitest.SetupConfig(t, proxyClient, root) - ctx := testutil.Context(t, testutil.WaitLong) - err = inv.WithContext(ctx).Run() - require.Error(t, err) - require.ErrorContains(t, err, "license is not entitled") + for _, c := range cases { + c := c + t.Run(c.name, func(t *testing.T) { + t.Parallel() - // Assert that the template metadata did not change. - updated, err := client.Template(context.Background(), template.ID) - require.NoError(t, err) - assert.Equal(t, template.Name, updated.Name) - assert.Equal(t, template.Description, updated.Description) - assert.Equal(t, template.Icon, updated.Icon) - assert.Equal(t, template.DisplayName, updated.DisplayName) - assert.Equal(t, template.DefaultTTLMillis, updated.DefaultTTLMillis) - assert.Equal(t, template.MaxTTLMillis, updated.MaxTTLMillis) + cmdArgs := []string{ + "templates", + "edit", + template.Name, + } + cmdArgs = append(cmdArgs, c.flags...) + inv, root := clitest.New(t, cmdArgs...) + clitest.SetupConfig(t, proxyClient, root) + + ctx := testutil.Context(t, testutil.WaitLong) + err = inv.WithContext(ctx).Run() + if c.ok { + require.NoError(t, err) + } else { + require.Error(t, err) + require.ErrorContains(t, err, "license is not entitled") + } + + // Assert that the template metadata did not change. + updated, err := client.Template(context.Background(), template.ID) + require.NoError(t, err) + assert.Equal(t, template.Name, updated.Name) + assert.Equal(t, template.Description, updated.Description) + assert.Equal(t, template.Icon, updated.Icon) + assert.Equal(t, template.DisplayName, updated.DisplayName) + assert.Equal(t, template.DefaultTTLMillis, updated.DefaultTTLMillis) + assert.Equal(t, template.RestartRequirement.DaysOfWeek, updated.RestartRequirement.DaysOfWeek) + assert.Equal(t, template.RestartRequirement.Weeks, updated.RestartRequirement.Weeks) + }) + } }) t.Run("Entitled", func(t *testing.T) { t.Parallel() @@ -360,7 +434,7 @@ func TestTemplateEdit(t *testing.T) { _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { ctr.DefaultTTLMillis = nil - ctr.MaxTTLMillis = nil + ctr.RestartRequirement = nil }) // Make a proxy server that will return a valid entitlements @@ -396,7 +470,8 @@ func TestTemplateEdit(t *testing.T) { var req codersdk.UpdateTemplateMeta err = json.Unmarshal(body, &req) require.NoError(t, err) - assert.Equal(t, time.Hour.Milliseconds(), req.MaxTTLMillis) + assert.Equal(t, req.RestartRequirement.DaysOfWeek, []string{"monday", "tuesday"}) + assert.EqualValues(t, req.RestartRequirement.Weeks, 3) r.Body = io.NopCloser(bytes.NewReader(body)) atomic.AddInt64(&updateTemplateCalled, 1) @@ -419,7 +494,8 @@ func TestTemplateEdit(t *testing.T) { "templates", "edit", template.Name, - "--max-ttl", "1h", + "--restart-requirement-weekdays", "monday,tuesday", + "--restart-requirement-weeks", "3", } inv, root := clitest.New(t, cmdArgs...) clitest.SetupConfig(t, proxyClient, root) @@ -439,7 +515,8 @@ func TestTemplateEdit(t *testing.T) { assert.Equal(t, template.Icon, updated.Icon) assert.Equal(t, template.DisplayName, updated.DisplayName) assert.Equal(t, template.DefaultTTLMillis, updated.DefaultTTLMillis) - assert.Equal(t, template.MaxTTLMillis, updated.MaxTTLMillis) + assert.Equal(t, template.RestartRequirement.DaysOfWeek, updated.RestartRequirement.DaysOfWeek) + assert.Equal(t, template.RestartRequirement.Weeks, updated.RestartRequirement.Weeks) }) }) t.Run("AllowUserScheduling", func(t *testing.T) { @@ -452,7 +529,7 @@ func TestTemplateEdit(t *testing.T) { _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { ctr.DefaultTTLMillis = nil - ctr.MaxTTLMillis = nil + ctr.RestartRequirement = nil ctr.FailureTTLMillis = nil ctr.InactivityTTLMillis = nil }) @@ -495,7 +572,8 @@ func TestTemplateEdit(t *testing.T) { assert.Equal(t, template.Icon, updated.Icon) assert.Equal(t, template.DisplayName, updated.DisplayName) assert.Equal(t, template.DefaultTTLMillis, updated.DefaultTTLMillis) - assert.Equal(t, template.MaxTTLMillis, updated.MaxTTLMillis) + assert.Equal(t, template.RestartRequirement.DaysOfWeek, updated.RestartRequirement.DaysOfWeek) + assert.Equal(t, template.RestartRequirement.Weeks, updated.RestartRequirement.Weeks) assert.Equal(t, template.AllowUserAutostart, updated.AllowUserAutostart) assert.Equal(t, template.AllowUserAutostop, updated.AllowUserAutostop) assert.Equal(t, template.FailureTTLMillis, updated.FailureTTLMillis) @@ -583,7 +661,8 @@ func TestTemplateEdit(t *testing.T) { assert.Equal(t, template.Icon, updated.Icon) assert.Equal(t, template.DisplayName, updated.DisplayName) assert.Equal(t, template.DefaultTTLMillis, updated.DefaultTTLMillis) - assert.Equal(t, template.MaxTTLMillis, updated.MaxTTLMillis) + assert.Equal(t, template.RestartRequirement.DaysOfWeek, updated.RestartRequirement.DaysOfWeek) + assert.Equal(t, template.RestartRequirement.Weeks, updated.RestartRequirement.Weeks) assert.Equal(t, template.AllowUserAutostart, updated.AllowUserAutostart) assert.Equal(t, template.AllowUserAutostop, updated.AllowUserAutostop) assert.Equal(t, template.FailureTTLMillis, updated.FailureTTLMillis) @@ -675,7 +754,8 @@ func TestTemplateEdit(t *testing.T) { assert.Equal(t, template.Icon, updated.Icon) assert.Equal(t, template.DisplayName, updated.DisplayName) assert.Equal(t, template.DefaultTTLMillis, updated.DefaultTTLMillis) - assert.Equal(t, template.MaxTTLMillis, updated.MaxTTLMillis) + assert.Equal(t, template.RestartRequirement.DaysOfWeek, updated.RestartRequirement.DaysOfWeek) + assert.Equal(t, template.RestartRequirement.Weeks, updated.RestartRequirement.Weeks) assert.Equal(t, template.AllowUserAutostart, updated.AllowUserAutostart) assert.Equal(t, template.AllowUserAutostop, updated.AllowUserAutostop) assert.Equal(t, template.FailureTTLMillis, updated.FailureTTLMillis) diff --git a/cli/testdata/coder_templates_edit_--help.golden b/cli/testdata/coder_templates_edit_--help.golden index 09c0b7209e78a..486e21e3fc3b0 100644 --- a/cli/testdata/coder_templates_edit_--help.golden +++ b/cli/testdata/coder_templates_edit_--help.golden @@ -35,14 +35,19 @@ Edit the metadata of a template by name. Specify an inactivity TTL for workspaces created from this template. This licensed feature's default is 0h (off). - --max-ttl duration - Edit the template maximum time before shutdown - workspaces created - from this template must shutdown within the given duration after - starting. This is an enterprise-only feature. - --name string Edit the template name. + --restart-requirement-weekdays string-array + Edit the template restart requirement weekdays - workspaces created + from this template must be restarted on the given weekdays. To unset + this value for the template (and disable the restart requirement for + the template), pass 'none'. + + --restart-requirement-weeks int + Edit the template restart requirement weeks - workspaces created from + this template must be restarted on an n-weekly basis. + -y, --yes bool Bypass prompts. diff --git a/coderd/activitybump_test.go b/coderd/activitybump_test.go index 6e5615fef4d45..46cb261b3501b 100644 --- a/coderd/activitybump_test.go +++ b/coderd/activitybump_test.go @@ -12,6 +12,7 @@ import ( "github.com/coder/coder/agent" "github.com/coder/coder/coderd/coderdtest" "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/database/dbtestutil" "github.com/coder/coder/coderd/schedule" "github.com/coder/coder/codersdk" "github.com/coder/coder/codersdk/agentsdk" @@ -25,14 +26,20 @@ func TestWorkspaceActivityBump(t *testing.T) { ctx := context.Background() - setupActivityTest := func(t *testing.T, maxDeadline ...time.Duration) (client *codersdk.Client, workspace codersdk.Workspace, assertBumped func(want bool)) { + // deadline allows you to forcibly set a max_deadline on the build. This + // doesn't use template restart requirements and instead edits the + // max_deadline on the build directly in the database. + setupActivityTest := func(t *testing.T, deadline ...time.Duration) (client *codersdk.Client, workspace codersdk.Workspace, assertBumped func(want bool)) { const ttl = time.Minute maxTTL := time.Duration(0) - if len(maxDeadline) > 0 { - maxTTL = maxDeadline[0] + if len(deadline) > 0 { + maxTTL = deadline[0] } + db, pubsub := dbtestutil.NewDB(t) client = coderdtest.New(t, &coderdtest.Options{ + Database: db, + Pubsub: pubsub, IncludeProvisionerDaemon: true, // Agent stats trigger the activity bump, so we want to report // very frequently in tests. @@ -42,7 +49,8 @@ func TestWorkspaceActivityBump(t *testing.T) { return schedule.TemplateScheduleOptions{ UserAutostopEnabled: true, DefaultTTL: ttl, - MaxTTL: maxTTL, + // We set max_deadline manually below. + RestartRequirement: schedule.TemplateRestartRequirement{}, }, nil }, }, @@ -79,6 +87,21 @@ func TestWorkspaceActivityBump(t *testing.T) { }) coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + // Update the max deadline. + if maxTTL != 0 { + dbBuild, err := db.GetWorkspaceBuildByID(ctx, workspace.LatestBuild.ID) + require.NoError(t, err) + + _, err = db.UpdateWorkspaceBuildByID(ctx, database.UpdateWorkspaceBuildByIDParams{ + ID: workspace.LatestBuild.ID, + UpdatedAt: database.Now(), + ProvisionerState: dbBuild.ProvisionerState, + Deadline: dbBuild.Deadline, + MaxDeadline: database.Now().Add(maxTTL), + }) + require.NoError(t, err) + } + agentClient := agentsdk.New(client.URL) agentClient.SetSessionToken(agentToken) agentCloser := agent.New(agent.Options{ diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index fc254d8d1eb16..0e1d390734f03 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -6814,14 +6814,18 @@ const docTemplate = `{ "description": "LockedTTL allows optionally specifying the max lifetime before Coder\npermanently deletes locked workspaces created from this template.", "type": "integer" }, - "max_ttl_ms": { - "description": "MaxTTLMillis allows optionally specifying the max lifetime for\nworkspaces created from this template.", - "type": "integer" - }, "name": { "description": "Name is the name of the template.", "type": "string" }, + "restart_requirement": { + "description": "RestartRequirement allows optionally specifying the restart requirement\nfor workspaces created from this template. This is an enterprise feature.", + "allOf": [ + { + "$ref": "#/definitions/codersdk.TemplateRestartRequirement" + } + ] + }, "template_version_id": { "description": "VersionID is an in-progress or completed job to use as an initial version\nof the template.\n\nThis is required on creation to enable a user-flow of validating a\ntemplate works. There is no reason the data-model cannot support empty\ntemplates, but it doesn't make sense for users.", "type": "string", @@ -8629,7 +8633,6 @@ const docTemplate = `{ "format": "date-time" }, "created_by_id": { - "description": "MaxTTLMillis is an enterprise feature. It's value is only used if your\nlicense is entitled to use the advanced template scheduling feature.\nTODO: fix and comment", "type": "string", "format": "uuid" }, @@ -8675,6 +8678,14 @@ const docTemplate = `{ "terraform" ] }, + "restart_requirement": { + "description": "RestartRequirement is an enterprise feature. Its value is only used if\nyour license is entitled to use the advanced template scheduling feature.", + "allOf": [ + { + "$ref": "#/definitions/codersdk.TemplateRestartRequirement" + } + ] + }, "updated_at": { "type": "string", "format": "date-time" @@ -8717,6 +8728,31 @@ const docTemplate = `{ } } }, + "codersdk.TemplateRestartRequirement": { + "type": "object", + "properties": { + "days_of_week": { + "description": "DaysOfWeek is a list of days of the week on which restarts are required.\nRestarts happen within the user's quiet hours (in their configured\ntimezone). If no days are specified, restarts are not required. Weekdays\ncannot be specified twice.\n\nRestarts will only happen on weekdays in this list on weeks which line up\nwith Weeks.", + "type": "array", + "items": { + "type": "string", + "enum": [ + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday" + ] + } + }, + "weeks": { + "description": "Weeks is the number of weeks between required restarts. Weeks are synced\nacross all workspaces (and Coder deployments) using modulo math on a\nhardcoded epoch week of January 2nd, 2023 (the first Monday of 2023).\nValues of 0 or 1 indicate weekly restarts. Values of 2 indicate\nfortnightly restarts, etc.", + "type": "integer" + } + } + }, "codersdk.TemplateRole": { "type": "string", "enum": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 7608c46fafd9e..6bdc8750dc5e2 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -6071,14 +6071,18 @@ "description": "LockedTTL allows optionally specifying the max lifetime before Coder\npermanently deletes locked workspaces created from this template.", "type": "integer" }, - "max_ttl_ms": { - "description": "MaxTTLMillis allows optionally specifying the max lifetime for\nworkspaces created from this template.", - "type": "integer" - }, "name": { "description": "Name is the name of the template.", "type": "string" }, + "restart_requirement": { + "description": "RestartRequirement allows optionally specifying the restart requirement\nfor workspaces created from this template. This is an enterprise feature.", + "allOf": [ + { + "$ref": "#/definitions/codersdk.TemplateRestartRequirement" + } + ] + }, "template_version_id": { "description": "VersionID is an in-progress or completed job to use as an initial version\nof the template.\n\nThis is required on creation to enable a user-flow of validating a\ntemplate works. There is no reason the data-model cannot support empty\ntemplates, but it doesn't make sense for users.", "type": "string", @@ -7772,7 +7776,6 @@ "format": "date-time" }, "created_by_id": { - "description": "MaxTTLMillis is an enterprise feature. It's value is only used if your\nlicense is entitled to use the advanced template scheduling feature.\nTODO: fix and comment", "type": "string", "format": "uuid" }, @@ -7816,6 +7819,14 @@ "type": "string", "enum": ["terraform"] }, + "restart_requirement": { + "description": "RestartRequirement is an enterprise feature. Its value is only used if\nyour license is entitled to use the advanced template scheduling feature.", + "allOf": [ + { + "$ref": "#/definitions/codersdk.TemplateRestartRequirement" + } + ] + }, "updated_at": { "type": "string", "format": "date-time" @@ -7858,6 +7869,31 @@ } } }, + "codersdk.TemplateRestartRequirement": { + "type": "object", + "properties": { + "days_of_week": { + "description": "DaysOfWeek is a list of days of the week on which restarts are required.\nRestarts happen within the user's quiet hours (in their configured\ntimezone). If no days are specified, restarts are not required. Weekdays\ncannot be specified twice.\n\nRestarts will only happen on weekdays in this list on weeks which line up\nwith Weeks.", + "type": "array", + "items": { + "type": "string", + "enum": [ + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday" + ] + } + }, + "weeks": { + "description": "Weeks is the number of weeks between required restarts. Weeks are synced\nacross all workspaces (and Coder deployments) using modulo math on a\nhardcoded epoch week of January 2nd, 2023 (the first Monday of 2023).\nValues of 0 or 1 indicate weekly restarts. Values of 2 indicate\nfortnightly restarts, etc.", + "type": "integer" + } + } + }, "codersdk.TemplateRole": { "type": "string", "enum": ["admin", "use", ""], diff --git a/coderd/autobuild/lifecycle_executor_test.go b/coderd/autobuild/lifecycle_executor_test.go index 3da342066ad12..9e9cf6428df4e 100644 --- a/coderd/autobuild/lifecycle_executor_test.go +++ b/coderd/autobuild/lifecycle_executor_test.go @@ -624,7 +624,7 @@ func TestExecutorAutostartTemplateDisabled(t *testing.T) { UserAutostartEnabled: false, UserAutostopEnabled: true, DefaultTTL: 0, - MaxTTL: 0, + RestartRequirement: schedule.TemplateRestartRequirement{}, }, nil }, }, diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 740c646156e4d..00db3a56c2750 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -570,9 +570,9 @@ COMMENT ON COLUMN templates.allow_user_autostart IS 'Allow users to specify an a COMMENT ON COLUMN templates.allow_user_autostop IS 'Allow users to specify custom autostop values for workspaces (enterprise).'; -COMMENT ON COLUMN templates.restart_requirement_days_of_week IS 'A bitmap of days of week to restart the workspace on, starting with Monday as the 0th bit, and Sunday as the 6th bit. Always little-endian. The 7th bit is unused.'; +COMMENT ON COLUMN templates.restart_requirement_days_of_week IS 'A bitmap of days of week to restart the workspace on, starting with Monday as the 0th bit, and Sunday as the 6th bit. The 7th bit is unused.'; -COMMENT ON COLUMN templates.restart_requirement_weeks IS 'The number of weeks between restarts. 0 weeks means "every week", 1 week means "every other week", etc. Weeks are counted from January 2, 2023, which is the first Monday of 2023. This is to ensure workspaces are started consistently for all customers on the same n-week cycles.'; +COMMENT ON COLUMN templates.restart_requirement_weeks IS 'The number of weeks between restarts. 0 or 1 weeks means "every week", 2 week means "every second week", etc. Weeks are counted from January 2, 2023, which is the first Monday of 2023. This is to ensure workspaces are started consistently for all customers on the same n-week cycles.'; CREATE TABLE user_links ( user_id uuid NOT NULL, diff --git a/coderd/database/models.go b/coderd/database/models.go index 860aaf5a41123..d5bfe27110afc 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1582,9 +1582,9 @@ type Template struct { FailureTTL int64 `db:"failure_ttl" json:"failure_ttl"` InactivityTTL int64 `db:"inactivity_ttl" json:"inactivity_ttl"` LockedTTL int64 `db:"locked_ttl" json:"locked_ttl"` - // A bitmap of days of week to restart the workspace on, starting with Monday as the 0th bit, and Sunday as the 6th bit. Always little-endian. The 7th bit is unused. + // A bitmap of days of week to restart the workspace on, starting with Monday as the 0th bit, and Sunday as the 6th bit. The 7th bit is unused. RestartRequirementDaysOfWeek int16 `db:"restart_requirement_days_of_week" json:"restart_requirement_days_of_week"` - // The number of weeks between restarts. 0 weeks means "every week", 1 week means "every other week", etc. Weeks are counted from January 2, 2023, which is the first Monday of 2023. This is to ensure workspaces are started consistently for all customers on the same n-week cycles. + // The number of weeks between restarts. 0 or 1 weeks means "every week", 2 week means "every second week", etc. Weeks are counted from January 2, 2023, which is the first Monday of 2023. This is to ensure workspaces are started consistently for all customers on the same n-week cycles. RestartRequirementWeeks int64 `db:"restart_requirement_weeks" json:"restart_requirement_weeks"` } diff --git a/coderd/schedule/template.go b/coderd/schedule/template.go index 83073ea233383..e17ade61f5d9c 100644 --- a/coderd/schedule/template.go +++ b/coderd/schedule/template.go @@ -85,7 +85,7 @@ func VerifyTemplateRestartRequirement(days uint8, weeks int64) error { if weeks < 0 { return xerrors.New("invalid restart requirement weeks, negative") } - if weeks > 16 { + if weeks > MaxTemplateRestartRequirementWeeks { return xerrors.New("invalid restart requirement weeks, too large") } return nil diff --git a/coderd/schedule/user.go b/coderd/schedule/user.go index e455227eae676..16700c8dfd9fe 100644 --- a/coderd/schedule/user.go +++ b/coderd/schedule/user.go @@ -21,8 +21,8 @@ type UserQuietHoursScheduleOptions struct { Schedule *Schedule UserSet bool // Duration is the duration of the quiet hours window starting when the cron - // triggers. Workspaces can be stopped for maintenance or due to max_ttl - // during this window. + // triggers. Workspaces can be stopped for maintenance or due to template + // restart requirements during this window. Duration time.Duration } diff --git a/coderd/templates.go b/coderd/templates.go index 2bfcf4d8d00ca..065db4b87043e 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -223,16 +223,18 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque } var ( - defaultTTL time.Duration - maxTTL time.Duration - failureTTL time.Duration - inactivityTTL time.Duration + defaultTTL time.Duration + restartRequirementDaysOfWeek []string + restartRequirementWeeks int64 + failureTTL time.Duration + inactivityTTL time.Duration ) if createTemplate.DefaultTTLMillis != nil { defaultTTL = time.Duration(*createTemplate.DefaultTTLMillis) * time.Millisecond } - if createTemplate.MaxTTLMillis != nil { - maxTTL = time.Duration(*createTemplate.MaxTTLMillis) * time.Millisecond + if createTemplate.RestartRequirement != nil { + restartRequirementDaysOfWeek = createTemplate.RestartRequirement.DaysOfWeek + restartRequirementWeeks = createTemplate.RestartRequirement.Weeks } if createTemplate.FailureTTLMillis != nil { failureTTL = time.Duration(*createTemplate.FailureTTLMillis) * time.Millisecond @@ -241,15 +243,24 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque inactivityTTL = time.Duration(*createTemplate.InactivityTTLMillis) * time.Millisecond } - var validErrs []codersdk.ValidationError + var ( + validErrs []codersdk.ValidationError + restartRequirementDaysOfWeekParsed uint8 + ) if defaultTTL < 0 { validErrs = append(validErrs, codersdk.ValidationError{Field: "default_ttl_ms", Detail: "Must be a positive integer."}) } - if maxTTL < 0 { - validErrs = append(validErrs, codersdk.ValidationError{Field: "max_ttl_ms", Detail: "Must be a positive integer."}) + if len(restartRequirementDaysOfWeek) > 0 { + restartRequirementDaysOfWeekParsed, err = codersdk.WeekdaysToBitmap(restartRequirementDaysOfWeek) + if err != nil { + validErrs = append(validErrs, codersdk.ValidationError{Field: "restart_requirement.days_of_week", Detail: err.Error()}) + } + } + if restartRequirementWeeks < 0 { + validErrs = append(validErrs, codersdk.ValidationError{Field: "restart_requirement.weeks", Detail: "Must be a positive integer."}) } - if maxTTL != 0 && defaultTTL > maxTTL { - validErrs = append(validErrs, codersdk.ValidationError{Field: "default_ttl_ms", Detail: "Must be less than or equal to max_ttl_ms if max_ttl_ms is set."}) + if restartRequirementWeeks > schedule.MaxTemplateRestartRequirementWeeks { + validErrs = append(validErrs, codersdk.ValidationError{Field: "restart_requirement.weeks", Detail: fmt.Sprintf("Must be less than %d.", schedule.MaxTemplateRestartRequirementWeeks)}) } if failureTTL < 0 { validErrs = append(validErrs, codersdk.ValidationError{Field: "failure_ttl_ms", Detail: "Must be a positive integer."}) @@ -309,6 +320,10 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque // Some of these values are enterprise-only, but the // TemplateScheduleStore will handle avoiding setting them if // unlicensed. + RestartRequirement: schedule.TemplateRestartRequirement{ + DaysOfWeek: restartRequirementDaysOfWeekParsed, + Weeks: restartRequirementWeeks, + }, FailureTTL: failureTTL, InactivityTTL: inactivityTTL, }) @@ -475,20 +490,44 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { defer commitAudit() aReq.Old = template + scheduleOpts, err := (*api.TemplateScheduleStore.Load()).GetTemplateScheduleOptions(ctx, api.Database, template.ID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching template schedule options.", + Detail: err.Error(), + }) + return + } + var req codersdk.UpdateTemplateMeta if !httpapi.Read(ctx, rw, r, &req) { return } - var validErrs []codersdk.ValidationError + var ( + validErrs []codersdk.ValidationError + restartRequirementDaysOfWeekParsed uint8 + ) if req.DefaultTTLMillis < 0 { validErrs = append(validErrs, codersdk.ValidationError{Field: "default_ttl_ms", Detail: "Must be a positive integer."}) } - if req.MaxTTLMillis < 0 { - validErrs = append(validErrs, codersdk.ValidationError{Field: "max_ttl_ms", Detail: "Must be a positive integer."}) + if req.RestartRequirement == nil { + req.RestartRequirement = &codersdk.TemplateRestartRequirement{ + DaysOfWeek: codersdk.BitmapToWeekdays(scheduleOpts.RestartRequirement.DaysOfWeek), + Weeks: scheduleOpts.RestartRequirement.Weeks, + } + } + if len(req.RestartRequirement.DaysOfWeek) > 0 { + restartRequirementDaysOfWeekParsed, err = codersdk.WeekdaysToBitmap(req.RestartRequirement.DaysOfWeek) + if err != nil { + validErrs = append(validErrs, codersdk.ValidationError{Field: "restart_requirement.days_of_week", Detail: err.Error()}) + } } - if req.MaxTTLMillis != 0 && req.DefaultTTLMillis > req.MaxTTLMillis { - validErrs = append(validErrs, codersdk.ValidationError{Field: "default_ttl_ms", Detail: "Must be less than or equal to max_ttl_ms if max_ttl_ms is set."}) + if req.RestartRequirement.Weeks < 0 { + validErrs = append(validErrs, codersdk.ValidationError{Field: "restart_requirement.weeks", Detail: "Must be a positive integer."}) + } + if req.RestartRequirement.Weeks > schedule.MaxTemplateRestartRequirementWeeks { + validErrs = append(validErrs, codersdk.ValidationError{Field: "restart_requirement.weeks", Detail: fmt.Sprintf("Must be less than %d.", schedule.MaxTemplateRestartRequirementWeeks)}) } if req.FailureTTLMillis < 0 { validErrs = append(validErrs, codersdk.ValidationError{Field: "failure_ttl_ms", Detail: "Must be a positive integer."}) @@ -512,7 +551,7 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { } var updated database.Template - err := api.Database.InTx(func(tx database.Store) error { + err = api.Database.InTx(func(tx database.Store) error { if req.Name == template.Name && req.Description == template.Description && req.DisplayName == template.DisplayName && @@ -521,6 +560,8 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { req.AllowUserAutostop == template.AllowUserAutostop && req.AllowUserCancelWorkspaceJobs == template.AllowUserCancelWorkspaceJobs && req.DefaultTTLMillis == time.Duration(template.DefaultTTL).Milliseconds() && + restartRequirementDaysOfWeekParsed == scheduleOpts.RestartRequirement.DaysOfWeek && + req.RestartRequirement.Weeks == scheduleOpts.RestartRequirement.Weeks && req.FailureTTLMillis == time.Duration(template.FailureTTL).Milliseconds() && req.InactivityTTLMillis == time.Duration(template.InactivityTTL).Milliseconds() && req.FailureTTLMillis == time.Duration(template.LockedTTL).Milliseconds() { @@ -553,6 +594,8 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { lockedTTL := time.Duration(req.LockedTTLMillis) * time.Millisecond if defaultTTL != time.Duration(template.DefaultTTL) || + restartRequirementDaysOfWeekParsed != scheduleOpts.RestartRequirement.DaysOfWeek || + req.RestartRequirement.Weeks != scheduleOpts.RestartRequirement.Weeks || failureTTL != time.Duration(template.FailureTTL) || inactivityTTL != time.Duration(template.InactivityTTL) || lockedTTL != time.Duration(template.LockedTTL) || @@ -565,9 +608,13 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { UserAutostartEnabled: req.AllowUserAutostart, UserAutostopEnabled: req.AllowUserAutostop, DefaultTTL: defaultTTL, - FailureTTL: failureTTL, - InactivityTTL: inactivityTTL, - LockedTTL: lockedTTL, + RestartRequirement: schedule.TemplateRestartRequirement{ + DaysOfWeek: restartRequirementDaysOfWeekParsed, + Weeks: req.RestartRequirement.Weeks, + }, + FailureTTL: failureTTL, + InactivityTTL: inactivityTTL, + LockedTTL: lockedTTL, }) if err != nil { return xerrors.Errorf("set template schedule options: %w", err) diff --git a/coderd/templates_test.go b/coderd/templates_test.go index 42106ca2244ab..817a1d74fb8bf 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -477,7 +477,7 @@ func TestPatchTemplateMeta(t *testing.T) { Description: template.Description, Icon: template.Icon, DefaultTTLMillis: 0, - MaxTTLMillis: 0, + RestartRequirement: &template.RestartRequirement, AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, FailureTTLMillis: failureTTL.Milliseconds(), InactivityTTLMillis: inactivityTTL.Milliseconds(), @@ -512,7 +512,7 @@ func TestPatchTemplateMeta(t *testing.T) { Description: template.Description, Icon: template.Icon, DefaultTTLMillis: template.DefaultTTLMillis, - MaxTTLMillis: template.MaxTTLMillis, + RestartRequirement: &template.RestartRequirement, AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, FailureTTLMillis: failureTTL.Milliseconds(), InactivityTTLMillis: inactivityTTL.Milliseconds(), @@ -571,7 +571,7 @@ func TestPatchTemplateMeta(t *testing.T) { Description: template.Description, Icon: template.Icon, DefaultTTLMillis: template.DefaultTTLMillis, - MaxTTLMillis: template.MaxTTLMillis, + RestartRequirement: &template.RestartRequirement, AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, AllowUserAutostart: allowAutostart.Load(), AllowUserAutostop: allowAutostop.Load(), @@ -603,7 +603,7 @@ func TestPatchTemplateMeta(t *testing.T) { Icon: template.Icon, // Increase the default TTL to avoid error "not modified". DefaultTTLMillis: template.DefaultTTLMillis + 1, - MaxTTLMillis: template.MaxTTLMillis, + RestartRequirement: &template.RestartRequirement, AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, AllowUserAutostart: false, AllowUserAutostop: false, @@ -702,14 +702,15 @@ func TestPatchTemplateMeta(t *testing.T) { assert.Equal(t, updated.Icon, "") }) - t.Run("MaxTTLEnterpriseOnly", func(t *testing.T) { + t.Run("RestartRequirementEnterpriseOnly", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - require.EqualValues(t, 0, template.MaxTTLMillis) + require.Empty(t, template.RestartRequirement.DaysOfWeek) + require.Zero(t, template.RestartRequirement.Weeks) req := codersdk.UpdateTemplateMeta{ Name: template.Name, DisplayName: template.DisplayName, @@ -717,7 +718,10 @@ func TestPatchTemplateMeta(t *testing.T) { Icon: template.Icon, AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, DefaultTTLMillis: time.Hour.Milliseconds(), - MaxTTLMillis: (2 * time.Hour).Milliseconds(), + RestartRequirement: &codersdk.TemplateRestartRequirement{ + DaysOfWeek: []string{"monday"}, + Weeks: 2, + }, } ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) @@ -725,11 +729,13 @@ func TestPatchTemplateMeta(t *testing.T) { updated, err := client.UpdateTemplateMeta(ctx, template.ID, req) require.NoError(t, err) - require.EqualValues(t, 0, updated.MaxTTLMillis) + require.Empty(t, updated.RestartRequirement.DaysOfWeek) + require.Zero(t, updated.RestartRequirement.Weeks) template, err = client.Template(ctx, template.ID) require.NoError(t, err) - require.EqualValues(t, 0, template.MaxTTLMillis) + require.Empty(t, template.RestartRequirement.DaysOfWeek) + require.Zero(t, template.RestartRequirement.Weeks) }) } diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index ee651dc8d626e..c7a342aa1db5c 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -1599,7 +1599,7 @@ func TestWorkspaceUpdateAutostart(t *testing.T) { UserAutostartEnabled: false, UserAutostopEnabled: false, DefaultTTL: 0, - MaxTTL: 0, + RestartRequirement: schedule.TemplateRestartRequirement{}, }, nil }, SetFn: func(_ context.Context, _ database.Store, tpl database.Template, _ schedule.TemplateScheduleOptions) (database.Template, error) { @@ -1766,7 +1766,7 @@ func TestWorkspaceUpdateTTL(t *testing.T) { UserAutostartEnabled: false, UserAutostopEnabled: false, DefaultTTL: 0, - MaxTTL: 0, + RestartRequirement: schedule.TemplateRestartRequirement{}, }, nil }, SetFn: func(_ context.Context, _ database.Store, tpl database.Template, _ schedule.TemplateScheduleOptions) (database.Template, error) { diff --git a/codersdk/deployment.go b/codersdk/deployment.go index fd65bc13a651d..c1eb3900a5397 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -45,7 +45,6 @@ const ( FeatureExternalProvisionerDaemons FeatureName = "external_provisioner_daemons" FeatureAppearance FeatureName = "appearance" FeatureAdvancedTemplateScheduling FeatureName = "advanced_template_scheduling" - FeatureUserQuietHoursSchedule FeatureName = "user_quiet_hours" FeatureWorkspaceProxy FeatureName = "workspace_proxy" ) @@ -61,7 +60,6 @@ var FeatureNames = []FeatureName{ FeatureExternalProvisionerDaemons, FeatureAppearance, FeatureAdvancedTemplateScheduling, - FeatureUserQuietHoursSchedule, FeatureWorkspaceProxy, } diff --git a/codersdk/organizations.go b/codersdk/organizations.go index bcb360132b8b9..b2f3f2a64160c 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -83,9 +83,9 @@ type CreateTemplateRequest struct { // DefaultTTLMillis allows optionally specifying the default TTL // for all workspaces created from this template. DefaultTTLMillis *int64 `json:"default_ttl_ms,omitempty"` - // MaxTTLMillis allows optionally specifying the max lifetime for - // workspaces created from this template. - MaxTTLMillis *int64 `json:"max_ttl_ms,omitempty"` + // RestartRequirement allows optionally specifying the restart requirement + // for workspaces created from this template. This is an enterprise feature. + RestartRequirement *TemplateRestartRequirement `json:"restart_requirement,omitempty"` // Allow users to cancel in-progress workspace jobs. // *bool as the default value is "true". diff --git a/codersdk/templates.go b/codersdk/templates.go index 560fb1c2d1ff1..96194777dece5 100644 --- a/codersdk/templates.go +++ b/codersdk/templates.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "net/http" + "strings" "time" "github.com/google/uuid" @@ -28,11 +29,11 @@ type Template struct { Description string `json:"description"` Icon string `json:"icon"` DefaultTTLMillis int64 `json:"default_ttl_ms"` - // MaxTTLMillis is an enterprise feature. It's value is only used if your - // license is entitled to use the advanced template scheduling feature. - // TODO: fix and comment - CreatedByID uuid.UUID `json:"created_by_id" format:"uuid"` - CreatedByName string `json:"created_by_name"` + // RestartRequirement is an enterprise feature. Its value is only used if + // your license is entitled to use the advanced template scheduling feature. + RestartRequirement TemplateRestartRequirement `json:"restart_requirement"` + CreatedByID uuid.UUID `json:"created_by_id" format:"uuid"` + CreatedByName string `json:"created_by_name"` // AllowUserAutostart and AllowUserAutostop are enterprise-only. Their // values are only used if your license is entitled to use the advanced @@ -49,6 +50,78 @@ type Template struct { LockedTTLMillis int64 `json:"locked_ttl_ms"` } +// WeekdaysToBitmap converts a list of weekdays to a bitmap in accordance with +// the schedule package's rules. The 0th bit is Monday, ..., the 6th bit is +// Sunday. The 7th bit is unused. +func WeekdaysToBitmap(days []string) (uint8, error) { + var bitmap uint8 + for _, day := range days { + switch strings.ToLower(day) { + case "monday": + bitmap |= 1 << 0 + case "tuesday": + bitmap |= 1 << 1 + case "wednesday": + bitmap |= 1 << 2 + case "thursday": + bitmap |= 1 << 3 + case "friday": + bitmap |= 1 << 4 + case "saturday": + bitmap |= 1 << 5 + case "sunday": + bitmap |= 1 << 6 + default: + return 0, xerrors.Errorf("invalid weekday %q", day) + } + } + return bitmap, nil +} + +// BitmapToWeekdays converts a bitmap to a list of weekdays in accordance with +// the schedule package's rules (see above). +func BitmapToWeekdays(bitmap uint8) []string { + var days []string + for i := 0; i < 7; i++ { + if bitmap&(1< -| Resource | | -| -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| APIKey
login, logout, register, create, delete |
FieldTracked
created_attrue
expires_attrue
hashed_secretfalse
idfalse
ip_addressfalse
last_usedtrue
lifetime_secondsfalse
login_typefalse
scopefalse
token_namefalse
updated_atfalse
user_idtrue
| -| Group
create, write, delete |
FieldTracked
avatar_urltrue
idtrue
memberstrue
nametrue
organization_idfalse
quota_allowancetrue
| -| GitSSHKey
create |
FieldTracked
created_atfalse
private_keytrue
public_keytrue
updated_atfalse
user_idtrue
| -| License
create, delete |
FieldTracked
exptrue
idfalse
jwtfalse
uploaded_attrue
uuidtrue
| -| Template
write, delete |
FieldTracked
active_version_idtrue
allow_user_autostarttrue
allow_user_autostoptrue
allow_user_cancel_workspace_jobstrue
created_atfalse
created_bytrue
default_ttltrue
deletedfalse
descriptiontrue
display_nametrue
failure_ttltrue
group_acltrue
icontrue
idtrue
inactivity_ttltrue
locked_ttltrue
nametrue
organization_idfalse
provisionertrue
restart_requirement_daystrue
restart_requirement_weekstrue
updated_atfalse
user_acltrue
| -| TemplateVersion
create, write |
FieldTracked
created_atfalse
created_bytrue
git_auth_providersfalse
idtrue
job_idfalse
nametrue
organization_idfalse
readmetrue
template_idtrue
updated_atfalse
| -| User
create, write, delete |
FieldTracked
avatar_urlfalse
created_atfalse
deletedtrue
emailtrue
hashed_passwordtrue
idtrue
last_seen_atfalse
login_typefalse
quiet_hours_scheduletrue
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| -| Workspace
create, write, delete |
FieldTracked
autostart_scheduletrue
created_atfalse
deletedfalse
idtrue
last_used_atfalse
nametrue
organization_idfalse
owner_idtrue
template_idtrue
ttltrue
updated_atfalse
| -| WorkspaceBuild
start, stop |
FieldTracked
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
idfalse
initiator_idfalse
job_idfalse
max_deadlinefalse
provisioner_statefalse
reasonfalse
template_version_idtrue
transitionfalse
updated_atfalse
workspace_idfalse
| -| WorkspaceProxy
|
FieldTracked
created_attrue
deletedfalse
display_nametrue
icontrue
idtrue
nametrue
token_hashed_secrettrue
updated_atfalse
urltrue
wildcard_hostnametrue
| +| Resource | | +| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| APIKey
login, logout, register, create, delete |
FieldTracked
created_attrue
expires_attrue
hashed_secretfalse
idfalse
ip_addressfalse
last_usedtrue
lifetime_secondsfalse
login_typefalse
scopefalse
token_namefalse
updated_atfalse
user_idtrue
| +| Group
create, write, delete |
FieldTracked
avatar_urltrue
idtrue
memberstrue
nametrue
organization_idfalse
quota_allowancetrue
| +| GitSSHKey
create |
FieldTracked
created_atfalse
private_keytrue
public_keytrue
updated_atfalse
user_idtrue
| +| License
create, delete |
FieldTracked
exptrue
idfalse
jwtfalse
uploaded_attrue
uuidtrue
| +| Template
write, delete |
FieldTracked
active_version_idtrue
allow_user_autostarttrue
allow_user_autostoptrue
allow_user_cancel_workspace_jobstrue
created_atfalse
created_bytrue
default_ttltrue
deletedfalse
descriptiontrue
display_nametrue
failure_ttltrue
group_acltrue
icontrue
idtrue
inactivity_ttltrue
locked_ttltrue
nametrue
organization_idfalse
provisionertrue
restart_requirement_days_of_weektrue
restart_requirement_weekstrue
updated_atfalse
user_acltrue
| +| TemplateVersion
create, write |
FieldTracked
created_atfalse
created_bytrue
git_auth_providersfalse
idtrue
job_idfalse
nametrue
organization_idfalse
readmetrue
template_idtrue
updated_atfalse
| +| User
create, write, delete |
FieldTracked
avatar_urlfalse
created_atfalse
deletedtrue
emailtrue
hashed_passwordtrue
idtrue
last_seen_atfalse
login_typefalse
quiet_hours_scheduletrue
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| +| Workspace
create, write, delete |
FieldTracked
autostart_scheduletrue
created_atfalse
deletedfalse
idtrue
last_used_atfalse
nametrue
organization_idfalse
owner_idtrue
template_idtrue
ttltrue
updated_atfalse
| +| WorkspaceBuild
start, stop |
FieldTracked
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
idfalse
initiator_idfalse
job_idfalse
max_deadlinefalse
provisioner_statefalse
reasonfalse
template_version_idtrue
transitionfalse
updated_atfalse
workspace_idfalse
| +| WorkspaceProxy
|
FieldTracked
created_attrue
deletedfalse
display_nametrue
icontrue
idtrue
nametrue
token_hashed_secrettrue
updated_atfalse
urltrue
wildcard_hostnametrue
| diff --git a/docs/api/schemas.md b/docs/api/schemas.md index eb6e6cb551567..05213bce3c935 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -1353,30 +1353,33 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "icon": "string", "inactivity_ttl_ms": 0, "locked_ttl_ms": 0, - "max_ttl_ms": 0, "name": "string", + "restart_requirement": { + "days_of_week": ["monday"], + "weeks": 0 + }, "template_version_id": "0ba39c92-1f1b-4c32-aa3e-9925d7713eb1" } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | -------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `allow_user_autostart` | boolean | false | | Allow user autostart allows users to set a schedule for autostarting their workspace. By default this is true. This can only be disabled when using an enterprise license. | -| `allow_user_autostop` | boolean | false | | Allow user autostop allows users to set a custom workspace TTL to use in place of the template's DefaultTTL field. By default this is true. If false, the DefaultTTL will always be used. This can only be disabled when using an enterprise license. | -| `allow_user_cancel_workspace_jobs` | boolean | false | | Allow users to cancel in-progress workspace jobs. \*bool as the default value is "true". | -| `default_ttl_ms` | integer | false | | Default ttl ms allows optionally specifying the default TTL for all workspaces created from this template. | -| `description` | string | false | | Description is a description of what the template contains. It must be less than 128 bytes. | -| `disable_everyone_group_access` | boolean | false | | Disable everyone group access allows optionally disabling the default behavior of granting the 'everyone' group access to use the template. If this is set to true, the template will not be available to all users, and must be explicitly granted to users or groups in the permissions settings of the template. | -| `display_name` | string | false | | Display name is the displayed name of the template. | -| `failure_ttl_ms` | integer | false | | Failure ttl ms allows optionally specifying the max lifetime before Coder stops all resources for failed workspaces created from this template. | -| `icon` | string | false | | Icon is a relative path or external URL that specifies an icon to be displayed in the dashboard. | -| `inactivity_ttl_ms` | integer | false | | Inactivity ttl ms allows optionally specifying the max lifetime before Coder locks inactive workspaces created from this template. | -| `locked_ttl_ms` | integer | false | | Locked ttl ms allows optionally specifying the max lifetime before Coder permanently deletes locked workspaces created from this template. | -| `max_ttl_ms` | integer | false | | Max ttl ms allows optionally specifying the max lifetime for workspaces created from this template. | -| `name` | string | true | | Name is the name of the template. | -| `template_version_id` | string | true | | Template version ID is an in-progress or completed job to use as an initial version of the template. | +| Name | Type | Required | Restrictions | Description | +| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | -------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `allow_user_autostart` | boolean | false | | Allow user autostart allows users to set a schedule for autostarting their workspace. By default this is true. This can only be disabled when using an enterprise license. | +| `allow_user_autostop` | boolean | false | | Allow user autostop allows users to set a custom workspace TTL to use in place of the template's DefaultTTL field. By default this is true. If false, the DefaultTTL will always be used. This can only be disabled when using an enterprise license. | +| `allow_user_cancel_workspace_jobs` | boolean | false | | Allow users to cancel in-progress workspace jobs. \*bool as the default value is "true". | +| `default_ttl_ms` | integer | false | | Default ttl ms allows optionally specifying the default TTL for all workspaces created from this template. | +| `description` | string | false | | Description is a description of what the template contains. It must be less than 128 bytes. | +| `disable_everyone_group_access` | boolean | false | | Disable everyone group access allows optionally disabling the default behavior of granting the 'everyone' group access to use the template. If this is set to true, the template will not be available to all users, and must be explicitly granted to users or groups in the permissions settings of the template. | +| `display_name` | string | false | | Display name is the displayed name of the template. | +| `failure_ttl_ms` | integer | false | | Failure ttl ms allows optionally specifying the max lifetime before Coder stops all resources for failed workspaces created from this template. | +| `icon` | string | false | | Icon is a relative path or external URL that specifies an icon to be displayed in the dashboard. | +| `inactivity_ttl_ms` | integer | false | | Inactivity ttl ms allows optionally specifying the max lifetime before Coder locks inactive workspaces created from this template. | +| `locked_ttl_ms` | integer | false | | Locked ttl ms allows optionally specifying the max lifetime before Coder permanently deletes locked workspaces created from this template. | +| `name` | string | true | | Name is the name of the template. | +| `restart_requirement` | [codersdk.TemplateRestartRequirement](#codersdktemplaterestartrequirement) | false | | Restart requirement allows optionally specifying the restart requirement for workspaces created from this template. This is an enterprise feature. | +| `template_version_id` | string | true | | Template version ID is an in-progress or completed job to use as an initial version of the template. | | This is required on creation to enable a user-flow of validating a template works. There is no reason the data-model cannot support empty templates, but it doesn't make sense for users. | ## codersdk.CreateTemplateVersionDryRunRequest @@ -3765,35 +3768,40 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "provisioner": "terraform", + "restart_requirement": { + "days_of_week": ["monday"], + "weeks": 0 + }, "updated_at": "2019-08-24T14:15:22Z" } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -| ---------------------------------- | ------------------------------------------------------------------ | -------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `active_user_count` | integer | false | | Active user count is set to -1 when loading. | -| `active_version_id` | string | false | | | -| `allow_user_autostart` | boolean | false | | Allow user autostart and AllowUserAutostop are enterprise-only. Their values are only used if your license is entitled to use the advanced template scheduling feature. | -| `allow_user_autostop` | boolean | false | | | -| `allow_user_cancel_workspace_jobs` | boolean | false | | | -| `build_time_stats` | [codersdk.TemplateBuildTimeStats](#codersdktemplatebuildtimestats) | false | | | -| `created_at` | string | false | | | -| `created_by_id` | string | false | | Created by ID is an enterprise feature. It's value is only used if your license is entitled to use the advanced template scheduling feature. TODO: fix and comment | -| `created_by_name` | string | false | | | -| `default_ttl_ms` | integer | false | | | -| `description` | string | false | | | -| `display_name` | string | false | | | -| `failure_ttl_ms` | integer | false | | Failure ttl ms InactivityTTLMillis, and LockedTTLMillis are enterprise-only. Their values are used if your license is entitled to use the advanced template scheduling feature. | -| `icon` | string | false | | | -| `id` | string | false | | | -| `inactivity_ttl_ms` | integer | false | | | -| `locked_ttl_ms` | integer | false | | | -| `name` | string | false | | | -| `organization_id` | string | false | | | -| `provisioner` | string | false | | | -| `updated_at` | string | false | | | +| Name | Type | Required | Restrictions | Description | +| ---------------------------------- | -------------------------------------------------------------------------- | -------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `active_user_count` | integer | false | | Active user count is set to -1 when loading. | +| `active_version_id` | string | false | | | +| `allow_user_autostart` | boolean | false | | Allow user autostart and AllowUserAutostop are enterprise-only. Their values are only used if your license is entitled to use the advanced template scheduling feature. | +| `allow_user_autostop` | boolean | false | | | +| `allow_user_cancel_workspace_jobs` | boolean | false | | | +| `build_time_stats` | [codersdk.TemplateBuildTimeStats](#codersdktemplatebuildtimestats) | false | | | +| `created_at` | string | false | | | +| `created_by_id` | string | false | | | +| `created_by_name` | string | false | | | +| `default_ttl_ms` | integer | false | | | +| `description` | string | false | | | +| `display_name` | string | false | | | +| `failure_ttl_ms` | integer | false | | Failure ttl ms InactivityTTLMillis, and LockedTTLMillis are enterprise-only. Their values are used if your license is entitled to use the advanced template scheduling feature. | +| `icon` | string | false | | | +| `id` | string | false | | | +| `inactivity_ttl_ms` | integer | false | | | +| `locked_ttl_ms` | integer | false | | | +| `name` | string | false | | | +| `organization_id` | string | false | | | +| `provisioner` | string | false | | | +| `restart_requirement` | [codersdk.TemplateRestartRequirement](#codersdktemplaterestartrequirement) | false | | Restart requirement is an enterprise feature. Its value is only used if your license is entitled to use the advanced template scheduling feature. | +| `updated_at` | string | false | | | #### Enumerated Values @@ -3848,6 +3856,23 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `tags` | array of string | false | | | | `url` | string | false | | | +## codersdk.TemplateRestartRequirement + +```json +{ + "days_of_week": ["monday"], + "weeks": 0 +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ------------------------------------------------------------------------------------- | --------------- | -------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `days_of_week` | array of string | false | | Days of week is a list of days of the week on which restarts are required. Restarts happen within the user's quiet hours (in their configured timezone). If no days are specified, restarts are not required. Weekdays cannot be specified twice. | +| Restarts will only happen on weekdays in this list on weeks which line up with Weeks. | +| `weeks` | integer | false | | Weeks is the number of weeks between required restarts. Weeks are synced across all workspaces (and Coder deployments) using modulo math on a hardcoded epoch week of January 2nd, 2023 (the first Monday of 2023). Values of 0 or 1 indicate weekly restarts. Values of 2 indicate fortnightly restarts, etc. | + ## codersdk.TemplateRole ```json diff --git a/docs/api/templates.md b/docs/api/templates.md index a10d861c6989c..36a7399bd347a 100644 --- a/docs/api/templates.md +++ b/docs/api/templates.md @@ -55,6 +55,10 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/templat "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "provisioner": "terraform", + "restart_requirement": { + "days_of_week": ["monday"], + "weeks": 0 + }, "updated_at": "2019-08-24T14:15:22Z" } ] @@ -70,33 +74,37 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/templat Status Code **200** -| Name | Type | Required | Restrictions | Description | -| ------------------------------------ | ---------------------------------------------------------------------------- | -------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `[array item]` | array | false | | | -| `» active_user_count` | integer | false | | Active user count is set to -1 when loading. | -| `» active_version_id` | string(uuid) | false | | | -| `» allow_user_autostart` | boolean | false | | Allow user autostart and AllowUserAutostop are enterprise-only. Their values are only used if your license is entitled to use the advanced template scheduling feature. | -| `» allow_user_autostop` | boolean | false | | | -| `» allow_user_cancel_workspace_jobs` | boolean | false | | | -| `» build_time_stats` | [codersdk.TemplateBuildTimeStats](schemas.md#codersdktemplatebuildtimestats) | false | | | -| `»» [any property]` | [codersdk.TransitionStats](schemas.md#codersdktransitionstats) | false | | | -| `»»» p50` | integer | false | | | -| `»»» p95` | integer | false | | | -| `» created_at` | string(date-time) | false | | | -| `» created_by_id` | string(uuid) | false | | Created by ID is an enterprise feature. It's value is only used if your license is entitled to use the advanced template scheduling feature. TODO: fix and comment | -| `» created_by_name` | string | false | | | -| `» default_ttl_ms` | integer | false | | | -| `» description` | string | false | | | -| `» display_name` | string | false | | | -| `» failure_ttl_ms` | integer | false | | Failure ttl ms InactivityTTLMillis, and LockedTTLMillis are enterprise-only. Their values are used if your license is entitled to use the advanced template scheduling feature. | -| `» icon` | string | false | | | -| `» id` | string(uuid) | false | | | -| `» inactivity_ttl_ms` | integer | false | | | -| `» locked_ttl_ms` | integer | false | | | -| `» name` | string | false | | | -| `» organization_id` | string(uuid) | false | | | -| `» provisioner` | string | false | | | -| `» updated_at` | string(date-time) | false | | | +| Name | Type | Required | Restrictions | Description | +| ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | -------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `[array item]` | array | false | | | +| `» active_user_count` | integer | false | | Active user count is set to -1 when loading. | +| `» active_version_id` | string(uuid) | false | | | +| `» allow_user_autostart` | boolean | false | | Allow user autostart and AllowUserAutostop are enterprise-only. Their values are only used if your license is entitled to use the advanced template scheduling feature. | +| `» allow_user_autostop` | boolean | false | | | +| `» allow_user_cancel_workspace_jobs` | boolean | false | | | +| `» build_time_stats` | [codersdk.TemplateBuildTimeStats](schemas.md#codersdktemplatebuildtimestats) | false | | | +| `»» [any property]` | [codersdk.TransitionStats](schemas.md#codersdktransitionstats) | false | | | +| `»»» p50` | integer | false | | | +| `»»» p95` | integer | false | | | +| `» created_at` | string(date-time) | false | | | +| `» created_by_id` | string(uuid) | false | | | +| `» created_by_name` | string | false | | | +| `» default_ttl_ms` | integer | false | | | +| `» description` | string | false | | | +| `» display_name` | string | false | | | +| `» failure_ttl_ms` | integer | false | | Failure ttl ms InactivityTTLMillis, and LockedTTLMillis are enterprise-only. Their values are used if your license is entitled to use the advanced template scheduling feature. | +| `» icon` | string | false | | | +| `» id` | string(uuid) | false | | | +| `» inactivity_ttl_ms` | integer | false | | | +| `» locked_ttl_ms` | integer | false | | | +| `» name` | string | false | | | +| `» organization_id` | string(uuid) | false | | | +| `» provisioner` | string | false | | | +| `» restart_requirement` | [codersdk.TemplateRestartRequirement](schemas.md#codersdktemplaterestartrequirement) | false | | Restart requirement is an enterprise feature. Its value is only used if your license is entitled to use the advanced template scheduling feature. | +| `»» days_of_week` | array | false | | »days of week is a list of days of the week on which restarts are required. Restarts happen within the user's quiet hours (in their configured timezone). If no days are specified, restarts are not required. Weekdays cannot be specified twice. | +| Restarts will only happen on weekdays in this list on weeks which line up with Weeks. | +| `»» weeks` | integer | false | | Weeks is the number of weeks between required restarts. Weeks are synced across all workspaces (and Coder deployments) using modulo math on a hardcoded epoch week of January 2nd, 2023 (the first Monday of 2023). Values of 0 or 1 indicate weekly restarts. Values of 2 indicate fortnightly restarts, etc. | +| `» updated_at` | string(date-time) | false | | | #### Enumerated Values @@ -135,8 +143,11 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/templa "icon": "string", "inactivity_ttl_ms": 0, "locked_ttl_ms": 0, - "max_ttl_ms": 0, "name": "string", + "restart_requirement": { + "days_of_week": ["monday"], + "weeks": 0 + }, "template_version_id": "0ba39c92-1f1b-4c32-aa3e-9925d7713eb1" } ``` @@ -183,6 +194,10 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/templa "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "provisioner": "terraform", + "restart_requirement": { + "days_of_week": ["monday"], + "weeks": 0 + }, "updated_at": "2019-08-24T14:15:22Z" } ``` @@ -310,6 +325,10 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/templat "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "provisioner": "terraform", + "restart_requirement": { + "days_of_week": ["monday"], + "weeks": 0 + }, "updated_at": "2019-08-24T14:15:22Z" } ``` @@ -639,6 +658,10 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template} \ "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "provisioner": "terraform", + "restart_requirement": { + "days_of_week": ["monday"], + "weeks": 0 + }, "updated_at": "2019-08-24T14:15:22Z" } ``` @@ -749,6 +772,10 @@ curl -X PATCH http://coder-server:8080/api/v2/templates/{template} \ "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "provisioner": "terraform", + "restart_requirement": { + "days_of_week": ["monday"], + "weeks": 0 + }, "updated_at": "2019-08-24T14:15:22Z" } ``` diff --git a/docs/cli/templates_edit.md b/docs/cli/templates_edit.md index 2d25da15b7cc1..8ed2f07805e03 100644 --- a/docs/cli/templates_edit.md +++ b/docs/cli/templates_edit.md @@ -89,14 +89,6 @@ Edit the template icon path. Specify an inactivity TTL for workspaces created from this template. This licensed feature's default is 0h (off). -### --max-ttl - -| | | -| ---- | --------------------- | -| Type | duration | - -Edit the template maximum time before shutdown - workspaces created from this template must shutdown within the given duration after starting. This is an enterprise-only feature. - ### --name | | | @@ -105,6 +97,22 @@ Edit the template maximum time before shutdown - workspaces created from this te Edit the template name. +### --restart-requirement-weekdays + +| | | +| ---- | ------------------------- | +| Type | string-array | + +Edit the template restart requirement weekdays - workspaces created from this template must be restarted on the given weekdays. To unset this value for the template (and disable the restart requirement for the template), pass 'none'. + +### --restart-requirement-weeks + +| | | +| ---- | ---------------- | +| Type | int | + +Edit the template restart requirement weeks - workspaces created from this template must be restarted on an n-weekly basis. + ### -y, --yes | | | diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index 3cdbea444da81..7772ebcb3e4a0 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -68,14 +68,14 @@ var auditableResourcesTypes = map[any]map[string]Action{ "description": ActionTrack, "icon": ActionTrack, "default_ttl": ActionTrack, + "restart_requirement_days_of_week": ActionTrack, + "restart_requirement_weeks": ActionTrack, "created_by": ActionTrack, "group_acl": ActionTrack, "user_acl": ActionTrack, "allow_user_autostart": ActionTrack, "allow_user_autostop": ActionTrack, "allow_user_cancel_workspace_jobs": ActionTrack, - "restart_requirement_days": ActionTrack, - "restart_requirement_weeks": ActionTrack, "failure_ttl": ActionTrack, "inactivity_ttl": ActionTrack, "locked_ttl": ActionTrack, diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index d31d91b609032..0b654bc3d343f 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -362,8 +362,7 @@ func (api *API) updateEntitlements(ctx context.Context) error { codersdk.FeatureMultipleGitAuth: len(api.GitAuthConfigs) > 1, codersdk.FeatureTemplateRBAC: api.RBAC, codersdk.FeatureExternalProvisionerDaemons: true, - codersdk.FeatureAdvancedTemplateScheduling: true, - codersdk.FeatureUserQuietHoursSchedule: api.DefaultQuietHoursSchedule != "", + codersdk.FeatureAdvancedTemplateScheduling: api.DefaultQuietHoursSchedule != "", codersdk.FeatureWorkspaceProxy: true, }) if err != nil { @@ -423,25 +422,21 @@ func (api *API) updateEntitlements(ctx context.Context) error { if changed, enabled := featureChanged(codersdk.FeatureAdvancedTemplateScheduling); changed { if enabled { - store := schedule.NewEnterpriseTemplateScheduleStore() - api.AGPL.TemplateScheduleStore.Store(&store) - } else { - store := agplschedule.NewAGPLTemplateScheduleStore() - api.AGPL.TemplateScheduleStore.Store(&store) - } - } + templateStore := schedule.NewEnterpriseTemplateScheduleStore() + api.AGPL.TemplateScheduleStore.Store(&templateStore) - if changed, enabled := featureChanged(codersdk.FeatureUserQuietHoursSchedule); changed { - if enabled && api.DefaultQuietHoursSchedule != "" { - store, err := schedule.NewEnterpriseUserQuietHoursScheduleStore(api.DefaultQuietHoursSchedule, api.QuietHoursWindowDuration) + quietHoursStore, err := schedule.NewEnterpriseUserQuietHoursScheduleStore(api.DefaultQuietHoursSchedule, api.QuietHoursWindowDuration) if err != nil { api.Logger.Error(ctx, "unable to set up enterprise user quiet hours schedule store, quiet hours schedules will not be applied", slog.Error(err)) } else { - api.AGPL.UserQuietHoursScheduleStore.Store(&store) + api.AGPL.UserQuietHoursScheduleStore.Store(&quietHoursStore) } } else { - store := agplschedule.NewAGPLUserQuietHoursScheduleStore() - api.AGPL.UserQuietHoursScheduleStore.Store(&store) + templateStore := agplschedule.NewAGPLTemplateScheduleStore() + api.AGPL.TemplateScheduleStore.Store(&templateStore) + + quietHoursStore := agplschedule.NewAGPLUserQuietHoursScheduleStore() + api.AGPL.UserQuietHoursScheduleStore.Store(&quietHoursStore) } } diff --git a/enterprise/coderd/coderd_test.go b/enterprise/coderd/coderd_test.go index 0f416be0fdad3..27aa2cb4c33eb 100644 --- a/enterprise/coderd/coderd_test.go +++ b/enterprise/coderd/coderd_test.go @@ -53,7 +53,6 @@ func TestEntitlements(t *testing.T) { codersdk.FeatureTemplateRBAC: 1, codersdk.FeatureExternalProvisionerDaemons: 1, codersdk.FeatureAdvancedTemplateScheduling: 1, - codersdk.FeatureUserQuietHoursSchedule: 1, codersdk.FeatureWorkspaceProxy: 1, }, GraceAt: time.Now().Add(59 * 24 * time.Hour), diff --git a/enterprise/coderd/coderdenttest/coderdenttest.go b/enterprise/coderd/coderdenttest/coderdenttest.go index 9d684ddcb0300..b9404bcad38a8 100644 --- a/enterprise/coderd/coderdenttest/coderdenttest.go +++ b/enterprise/coderd/coderdenttest/coderdenttest.go @@ -42,12 +42,13 @@ func init() { type Options struct { *coderdtest.Options - AuditLogging bool - BrowserOnly bool - EntitlementsUpdateInterval time.Duration - SCIMAPIKey []byte - UserWorkspaceQuota int - ProxyHealthInterval time.Duration + AuditLogging bool + BrowserOnly bool + EntitlementsUpdateInterval time.Duration + SCIMAPIKey []byte + UserWorkspaceQuota int + ProxyHealthInterval time.Duration + NoDefaultQuietHoursSchedule bool } // New constructs a codersdk client connected to an in-memory Enterprise API instance. @@ -66,6 +67,10 @@ func NewWithAPI(t *testing.T, options *Options) (*codersdk.Client, io.Closer, *c options.Options = &coderdtest.Options{} } 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, diff --git a/enterprise/coderd/schedule/user.go b/enterprise/coderd/schedule/user.go index 8004cfd816c76..f5f2751450bdd 100644 --- a/enterprise/coderd/schedule/user.go +++ b/enterprise/coderd/schedule/user.go @@ -95,8 +95,8 @@ func (s *enterpriseUserQuietHoursScheduleStore) SetUserQuietHoursScheduleOptions return agpl.UserQuietHoursScheduleOptions{}, xerrors.Errorf("update user quiet hours schedule: %w", err) } - // TODO: update max_ttl for all active builds for this user to clamp to the - // new schedule. + // TODO(@dean): update max_deadline for all active builds for this user to clamp to + // the new schedule. return opts, nil } diff --git a/enterprise/coderd/templates_test.go b/enterprise/coderd/templates_test.go index ca63cbc0801d1..49531fdc5228b 100644 --- a/enterprise/coderd/templates_test.go +++ b/enterprise/coderd/templates_test.go @@ -25,7 +25,7 @@ import ( func TestTemplates(t *testing.T) { t.Parallel() - t.Run("SetMaxTTL", func(t *testing.T) { + t.Run("SetRestartRequirement", func(t *testing.T) { t.Parallel() client := coderdenttest.New(t, &coderdenttest.Options{ @@ -43,26 +43,10 @@ func TestTemplates(t *testing.T) { 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) + require.Empty(t, 0, template.RestartRequirement.DaysOfWeek) + require.Zero(t, template.RestartRequirement.Weeks) - // 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, @@ -70,141 +54,19 @@ func TestTemplates(t *testing.T) { 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) - }) - - t.Run("CreateUpdateWorkspaceMaxTTL", func(t *testing.T) { - t.Parallel() - client := coderdenttest.New(t, &coderdenttest.Options{ - Options: &coderdtest.Options{ - IncludeProvisionerDaemon: true, - }, - }) - user := coderdtest.CreateFirstUser(t, client) - _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - Features: license.Features{ - codersdk.FeatureAdvancedTemplateScheduling: 1, - }, - }) - - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - exp := 24 * time.Hour.Milliseconds() - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { - ctr.DefaultTTLMillis = &exp - ctr.MaxTTLMillis = &exp - }) - coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() - - // No TTL provided should use template default - req := codersdk.CreateWorkspaceRequest{ - TemplateID: template.ID, - Name: "testing", - } - ws, err := client.CreateWorkspace(ctx, template.OrganizationID, codersdk.Me, req) - require.NoError(t, err) - require.EqualValues(t, exp, *ws.TTLMillis) - - // Editing a workspace to have a higher TTL than the template's max - // should error - exp = exp + time.Minute.Milliseconds() - err = client.UpdateWorkspaceTTL(ctx, ws.ID, codersdk.UpdateWorkspaceTTLRequest{ - TTLMillis: &exp, - }) - require.Error(t, err) - var apiErr *codersdk.Error - require.ErrorAs(t, err, &apiErr) - require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) - require.Len(t, apiErr.Validations, 1) - require.Equal(t, apiErr.Validations[0].Field, "ttl_ms") - require.Contains(t, apiErr.Validations[0].Detail, "time until shutdown must be less than or equal to the template's maximum TTL") - - // Creating workspace with TTL higher than max should error - req.Name = "testing2" - req.TTLMillis = &exp - ws, err = client.CreateWorkspace(ctx, template.OrganizationID, codersdk.Me, req) - require.Error(t, err) - apiErr = nil - require.ErrorAs(t, err, &apiErr) - require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) - require.Len(t, apiErr.Validations, 1) - require.Equal(t, apiErr.Validations[0].Field, "ttl_ms") - require.Contains(t, apiErr.Validations[0].Detail, "time until shutdown must be less than or equal to the template's maximum TTL") - }) - - t.Run("BlockDisablingAutoOffWithMaxTTL", func(t *testing.T) { - t.Parallel() - client := coderdenttest.New(t, &coderdenttest.Options{ - Options: &coderdtest.Options{ - IncludeProvisionerDaemon: true, - }, - }) - user := coderdtest.CreateFirstUser(t, client) - _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - Features: license.Features{ - codersdk.FeatureAdvancedTemplateScheduling: 1, + RestartRequirement: &codersdk.TemplateRestartRequirement{ + DaysOfWeek: []string{"monday", "saturday"}, + Weeks: 3, }, }) - - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - exp := 24 * time.Hour.Milliseconds() - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { - ctr.MaxTTLMillis = &exp - }) - coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() - - // No TTL provided should use template default - req := codersdk.CreateWorkspaceRequest{ - TemplateID: template.ID, - Name: "testing", - } - ws, err := client.CreateWorkspace(ctx, template.OrganizationID, codersdk.Me, req) - require.NoError(t, err) - require.EqualValues(t, exp, *ws.TTLMillis) - - // Editing a workspace to disable the TTL should do nothing - err = client.UpdateWorkspaceTTL(ctx, ws.ID, codersdk.UpdateWorkspaceTTLRequest{ - TTLMillis: nil, - }) require.NoError(t, err) - ws, err = client.Workspace(ctx, ws.ID) - require.NoError(t, err) - require.EqualValues(t, exp, *ws.TTLMillis) + require.Equal(t, []string{"monday", "saturday"}, updated.RestartRequirement.DaysOfWeek) + require.Equal(t, 3, updated.RestartRequirement.Weeks) - // Editing a workspace to have a TTL of 0 should do nothing - zero := int64(0) - err = client.UpdateWorkspaceTTL(ctx, ws.ID, codersdk.UpdateWorkspaceTTLRequest{ - TTLMillis: &zero, - }) - require.NoError(t, err) - ws, err = client.Workspace(ctx, ws.ID) + template, err = client.Template(ctx, template.ID) require.NoError(t, err) - require.EqualValues(t, exp, *ws.TTLMillis) + require.Equal(t, []string{"monday", "saturday"}, template.RestartRequirement.DaysOfWeek) + require.Equal(t, 3, template.RestartRequirement.Weeks) }) } diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 6098b18080d43..7714f9613a2c8 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -173,7 +173,7 @@ export interface CreateTemplateRequest { readonly icon?: string readonly template_version_id: string readonly default_ttl_ms?: number - readonly max_ttl_ms?: number + readonly restart_requirement?: TemplateRestartRequirement readonly allow_user_cancel_workspace_jobs?: boolean readonly allow_user_autostart?: boolean readonly allow_user_autostop?: boolean @@ -804,6 +804,7 @@ export interface Template { readonly description: string readonly icon: string readonly default_ttl_ms: number + readonly restart_requirement: TemplateRestartRequirement readonly created_by_id: string readonly created_by_name: string readonly allow_user_autostart: boolean @@ -842,6 +843,12 @@ export interface TemplateGroup extends Group { readonly role: TemplateRole } +// From codersdk/templates.go +export interface TemplateRestartRequirement { + readonly days_of_week: string[] + readonly weeks: number +} + // From codersdk/templates.go export interface TemplateUser extends User { readonly role: TemplateRole @@ -973,7 +980,7 @@ export interface UpdateTemplateMeta { readonly description?: string readonly icon?: string readonly default_ttl_ms?: number - readonly max_ttl_ms?: number + readonly restart_requirement?: TemplateRestartRequirement readonly allow_user_autostart?: boolean readonly allow_user_autostop?: boolean readonly allow_user_cancel_workspace_jobs?: boolean From 6cfd270d0dd1b89a7133154a7a31e67a4d8c1086 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Fri, 7 Jul 2023 14:55:31 +0000 Subject: [PATCH 10/25] working! --- coderd/database/dbfake/dbfake.go | 2 ++ coderd/templates.go | 4 ++++ enterprise/coderd/coderd.go | 2 +- enterprise/coderd/schedule/template.go | 2 +- enterprise/coderd/schedule/user.go | 3 +++ enterprise/coderd/templates_test.go | 7 ++++--- 6 files changed, 15 insertions(+), 5 deletions(-) diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go index 140a8edea1dd3..224c3b6e3aca0 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -4685,6 +4685,8 @@ func (q *fakeQuerier) UpdateTemplateScheduleByID(_ context.Context, arg database tpl.AllowUserAutostop = arg.AllowUserAutostop tpl.UpdatedAt = database.Now() tpl.DefaultTTL = arg.DefaultTTL + tpl.RestartRequirementDaysOfWeek = arg.RestartRequirementDaysOfWeek + tpl.RestartRequirementWeeks = arg.RestartRequirementWeeks tpl.FailureTTL = arg.FailureTTL tpl.InactivityTTL = arg.InactivityTTL q.templates[idx] = tpl diff --git a/coderd/templates.go b/coderd/templates.go index 065db4b87043e..4663c4eb02365 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -768,5 +768,9 @@ func (api *API) convertTemplate( FailureTTLMillis: time.Duration(template.FailureTTL).Milliseconds(), InactivityTTLMillis: time.Duration(template.InactivityTTL).Milliseconds(), LockedTTLMillis: time.Duration(template.LockedTTL).Milliseconds(), + RestartRequirement: codersdk.TemplateRestartRequirement{ + DaysOfWeek: codersdk.BitmapToWeekdays(uint8(template.RestartRequirementDaysOfWeek)), + Weeks: template.RestartRequirementWeeks, + }, } } diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 0b654bc3d343f..9d6bd175ae12f 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -427,7 +427,7 @@ func (api *API) updateEntitlements(ctx context.Context) error { quietHoursStore, err := schedule.NewEnterpriseUserQuietHoursScheduleStore(api.DefaultQuietHoursSchedule, api.QuietHoursWindowDuration) if err != nil { - api.Logger.Error(ctx, "unable to set up enterprise user quiet hours schedule store, quiet hours schedules will not be applied", slog.Error(err)) + 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) } diff --git a/enterprise/coderd/schedule/template.go b/enterprise/coderd/schedule/template.go index fbaf1c3b48bc3..03d28af7cc8c9 100644 --- a/enterprise/coderd/schedule/template.go +++ b/enterprise/coderd/schedule/template.go @@ -68,7 +68,7 @@ func (*EnterpriseTemplateScheduleStore) SetTemplateScheduleOptions(ctx context.C return tpl, nil } - err := agpl.VerifyTemplateRestartRequirement(uint8(opts.RestartRequirement.DaysOfWeek), opts.RestartRequirement.Weeks) + err := agpl.VerifyTemplateRestartRequirement(opts.RestartRequirement.DaysOfWeek, opts.RestartRequirement.Weeks) if err != nil { return database.Template{}, err } diff --git a/enterprise/coderd/schedule/user.go b/enterprise/coderd/schedule/user.go index f5f2751450bdd..6d4973ca9c0d4 100644 --- a/enterprise/coderd/schedule/user.go +++ b/enterprise/coderd/schedule/user.go @@ -56,6 +56,9 @@ func (s *enterpriseUserQuietHoursScheduleStore) parseSchedule(rawSchedule string 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()) } diff --git a/enterprise/coderd/templates_test.go b/enterprise/coderd/templates_test.go index 49531fdc5228b..4142a41b285ff 100644 --- a/enterprise/coderd/templates_test.go +++ b/enterprise/coderd/templates_test.go @@ -46,7 +46,8 @@ func TestTemplates(t *testing.T) { require.Empty(t, 0, template.RestartRequirement.DaysOfWeek) require.Zero(t, template.RestartRequirement.Weeks) - ctx := testutil.Context(t, testutil.WaitLong) + // ctx := testutil.Context(t, testutil.WaitLong) + ctx := context.Background() updated, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ Name: template.Name, DisplayName: template.DisplayName, @@ -61,12 +62,12 @@ func TestTemplates(t *testing.T) { }) require.NoError(t, err) require.Equal(t, []string{"monday", "saturday"}, updated.RestartRequirement.DaysOfWeek) - require.Equal(t, 3, updated.RestartRequirement.Weeks) + 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.Equal(t, 3, template.RestartRequirement.Weeks) + require.EqualValues(t, 3, template.RestartRequirement.Weeks) }) } From 53f5d6240ef267e3ce633c5e5ff23a656d87d7bb Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Mon, 10 Jul 2023 08:57:44 +0000 Subject: [PATCH 11/25] move autostop algorithm to schedule package --- .../provisionerdserver/provisionerdserver.go | 197 +-------- .../provisionerdserver_test.go | 197 +-------- coderd/schedule/autostop.go | 234 +++++++++++ coderd/schedule/autostop_test.go | 379 ++++++++++++++++++ enterprise/coderd/schedule/template.go | 3 +- 5 files changed, 644 insertions(+), 366 deletions(-) create mode 100644 coderd/schedule/autostop.go create mode 100644 coderd/schedule/autostop_test.go diff --git a/coderd/provisionerdserver/provisionerdserver.go b/coderd/provisionerdserver/provisionerdserver.go index 2b2026317616f..0b15936048ea9 100644 --- a/coderd/provisionerdserver/provisionerdserver.go +++ b/coderd/provisionerdserver/provisionerdserver.go @@ -43,19 +43,6 @@ import ( sdkproto "github.com/coder/coder/provisionersdk/proto" ) -const ( - // restartRequirementLeeway is the duration of time before a restart - // requirement where we skip the requirement and fall back to the next - // scheduled restart. This avoids workspaces being restarted too soon. - restartRequirementLeeway = 1 * time.Hour - - // restartRequirementBuffer is the duration of time we subtract from the - // time when calculating the next scheduled restart time. This avoids issues - // where autostart happens on the hour and the scheduled quiet hours are - // also on the hour. - restartRequirementBuffer = -15 * time.Minute -) - var ( lastAcquire time.Time lastAcquireMutex sync.RWMutex @@ -916,17 +903,9 @@ func (server *Server) CompleteJob(ctx context.Context, completed *proto.Complete var getWorkspaceError error err = server.Database.InTx(func(db database.Store) error { - var ( - // It's important we use server.timeNow() here because we want - // to be able to customize the current time from within tests. - now = server.timeNow() - // deadline is the time when the workspace will be stopped. The - // value can be bumped by user activity or manually by the user - // via the UI. - deadline time.Time - // maxDeadline is the maximum value for deadline. - maxDeadline time.Time - ) + // It's important we use server.timeNow() here because we want to be + // able to customize the current time from within tests. + now := server.timeNow() workspace, getWorkspaceError = db.GetWorkspaceByID(ctx, workspaceBuild.WorkspaceID) if getWorkspaceError != nil { @@ -937,136 +916,16 @@ func (server *Server) CompleteJob(ctx context.Context, completed *proto.Complete ) return getWorkspaceError } - if workspace.Ttl.Valid { - // When the workspace is made it copies the template's TTL, and - // the user can unset it to disable it (unless the template - // has UserAutoStopEnabled set to false, see below). - deadline = now.Add(time.Duration(workspace.Ttl.Int64)) - } - templateSchedule, err := (*server.TemplateScheduleStore.Load()).GetTemplateScheduleOptions(ctx, db, workspace.TemplateID) + autoStop, err := schedule.CalculateAutostop(ctx, schedule.CalculateAutostopParams{ + Database: db, + TemplateScheduleStore: *server.TemplateScheduleStore.Load(), + UserQuietHoursScheduleStore: *server.UserQuietHoursScheduleStore.Load(), + Now: now, + Workspace: workspace, + }) if err != nil { - return xerrors.Errorf("get template schedule options: %w", err) - } - if !templateSchedule.UserAutostopEnabled { - // The user is not permitted to set their own TTL, so use the - // template default. - deadline = time.Time{} - if templateSchedule.DefaultTTL > 0 { - deadline = now.Add(templateSchedule.DefaultTTL) - } - } - if templateSchedule.RestartRequirement.DaysOfWeek != 0 { - // The template has a restart requirement, so determine the max - // deadline of this workspace build. - - // First, get the user's quiet hours schedule (this will return - // the default if the user has not set their own schedule). - userQuietHoursSchedule, err := (*server.UserQuietHoursScheduleStore.Load()).GetUserQuietHoursScheduleOptions(ctx, db, workspace.OwnerID) - if err != nil { - return xerrors.Errorf("get user quiet hours schedule options: %w", err) - } - - // If the schedule is nil, that means the deployment isn't - // entitled to use quiet hours or the default schedule has not - // been set. In this case, do not set a max deadline on the - // workspace. - if userQuietHoursSchedule.Schedule != nil { - loc := userQuietHoursSchedule.Schedule.Location() - now := server.timeNow().In(loc) - // Add the leeway here so we avoid checking today's quiet - // hours if the workspace was started <1h before midnight. - startOfStopDay := truncateMidnight(now.Add(restartRequirementLeeway)) - - // If the template schedule wants to only restart on n-th - // weeks then change the startOfDay to be the Monday of the - // next applicable week. - if templateSchedule.RestartRequirement.Weeks > 1 { - epoch := schedule.TemplateRestartRequirementEpoch(loc) - if startOfStopDay.Before(epoch) { - return xerrors.New("coder server system clock is incorrect, cannot calculate template restart requirement") - } - since := startOfStopDay.Sub(epoch) - weeksSinceEpoch := int64(since.Hours() / (24 * 7)) - requiredWeeks := templateSchedule.RestartRequirement.Weeks - weeksRemainder := weeksSinceEpoch % requiredWeeks - if weeksRemainder != 0 { - // Add (requiredWeeks - weeksSince) * 7 days to the - // current startOfStopDay, then truncate to Monday - // midnight. - // - // This sets startOfStopDay to Monday at midnight of - // the next applicable week. - y, mo, d := startOfStopDay.Date() - d += int(requiredWeeks-weeksRemainder) * 7 - startOfStopDay = time.Date(y, mo, d, 0, 0, 0, 0, loc) - startOfStopDay = truncateMondayMidnight(startOfStopDay) - } - } - - // Determine if we should skip the first day because the - // schedule is too near or has already passed. - // - // Allow an hour of leeway (i.e. any workspaces started - // within an hour of the scheduled stop time will always - // bounce to the next stop window). - checkSchedule := userQuietHoursSchedule.Schedule.Next(startOfStopDay.Add(restartRequirementBuffer)) - if checkSchedule.Before(now.Add(restartRequirementLeeway)) { - // Set the first stop day we try to tomorrow because - // today's schedule is too close to now or has already - // passed. - startOfStopDay = nextDayMidnight(startOfStopDay) - } - - // Iterate from 0 to 7, check if the current startOfDay is - // in the restart requirement. If it isn't then add a day - // and try again. - requirementDays := templateSchedule.RestartRequirement.DaysMap() - for i := 0; i < len(schedule.DaysOfWeek)+1; i++ { - if i == len(schedule.DaysOfWeek) { - // We've wrapped, so somehow we couldn't find a day - // in the restart requirement. This shouldn't happen - // because the restart requirement has a day set. - return xerrors.New("could not find suitable day for template restart requirement in the next 7 days") - } - if requirementDays[startOfStopDay.Weekday()] { - break - } - startOfStopDay = nextDayMidnight(startOfStopDay) - } - - // If the startOfDay is within an hour of now, then we add - // an hour. - checkTime := startOfStopDay - if checkTime.Before(now.Add(time.Hour)) { - checkTime = now.Add(time.Hour) - } else { - // If it's not within an hour of now, subtract 15 - // minutes to give a little leeway. This prevents - // skipped stop events because autostart perfectly lines - // up with autostop. - checkTime = checkTime.Add(restartRequirementBuffer) - } - - // Get the next occurrence of the restart schedule. - maxDeadline = userQuietHoursSchedule.Schedule.Next(checkTime) - if maxDeadline.IsZero() { - return xerrors.New("could not find next occurrence of template restart requirement in user quiet hours schedule") - } - } - - // If the workspace doesn't have a deadline or the max deadline - // is sooner than the workspace deadline, use the max deadline - // as the actual deadline. - if deadline.IsZero() || maxDeadline.Before(deadline) { - deadline = maxDeadline - } - } - - if (!deadline.IsZero() && deadline.Before(now)) || (!maxDeadline.IsZero() && maxDeadline.Before(now)) { - // Something went wrong with the deadline calculation, so we - // should bail. - return xerrors.Errorf("deadline calculation error, computed deadline or max deadline is in the past for workspace build: deadline=%q maxDeadline=%q now=%q", deadline, maxDeadline, now) + return xerrors.Errorf("calculate auto stop: %w", err) } err = db.UpdateProvisionerJobWithCompleteByID(ctx, database.UpdateProvisionerJobWithCompleteByIDParams{ @@ -1082,8 +941,8 @@ func (server *Server) CompleteJob(ctx context.Context, completed *proto.Complete } _, err = db.UpdateWorkspaceBuildByID(ctx, database.UpdateWorkspaceBuildByIDParams{ ID: workspaceBuild.ID, - Deadline: deadline, - MaxDeadline: maxDeadline, + Deadline: autoStop.Deadline, + MaxDeadline: autoStop.MaxDeadline, ProvisionerState: jobType.WorkspaceBuild.State, UpdatedAt: now, }) @@ -1688,33 +1547,3 @@ func redactTemplateVariable(templateVariable *sdkproto.TemplateVariable) *sdkpro } return maybeRedacted } - -// truncateMidnight truncates a time to midnight in the time object's timezone. -// t.Truncate(24 * time.Hour) truncates based on the internal time and doesn't -// factor daylight savings properly. -// -// See: https://github.com/golang/go/issues/10894 -func truncateMidnight(t time.Time) time.Time { - yy, mm, dd := t.Date() - return time.Date(yy, mm, dd, 0, 0, 0, 0, t.Location()) -} - -// nextDayMidnight returns the next midnight in the time object's timezone. -func nextDayMidnight(t time.Time) time.Time { - yy, mm, dd := t.Date() - // time.Date will correctly normalize the date if it's past the end of the - // month. E.g. October 32nd will be November 1st. - dd += 1 - return time.Date(yy, mm, dd, 0, 0, 0, 0, t.Location()) -} - -// truncateMondayMidnight truncates a time to the previous Monday at midnight in -// the time object's timezone. -func truncateMondayMidnight(t time.Time) time.Time { - // time.Date will correctly normalize the date if it's past the end of the - // month. E.g. October 32nd will be November 1st. - yy, mm, dd := t.Date() - dd -= int(t.Weekday() - 1) - t = time.Date(yy, mm, dd, 0, 0, 0, 0, t.Location()) - return truncateMidnight(t) -} diff --git a/coderd/provisionerdserver/provisionerdserver_test.go b/coderd/provisionerdserver/provisionerdserver_test.go index f5717b56663f1..96024c7903b52 100644 --- a/coderd/provisionerdserver/provisionerdserver_test.go +++ b/coderd/provisionerdserver/provisionerdserver_test.go @@ -906,6 +906,11 @@ func TestCompleteJob(t *testing.T) { now := time.Now() + // NOTE: if you're looking for more in-depth deadline/max_deadline + // calculation testing, see the schedule package. The provsiionerdserver + // package calls `schedule.CalculateAutostop()` to generate the deadline + // and max_deadline. + // Wednesday the 8th of February 2023 at midnight. This date was // specifically chosen as it doesn't fall on a applicable week for both // fortnightly and triweekly restart requirements. @@ -914,23 +919,18 @@ func TestCompleteJob(t *testing.T) { sydneyQuietHours := "CRON_TZ=Australia/Sydney 0 0 * * *" sydneyLoc, err := time.LoadLocation("Australia/Sydney") require.NoError(t, err) - // 10pm on Friday the 10th of February 2023 in Sydney. - fridayEveningSydney := time.Date(2023, 2, 10, 22, 0, 0, 0, sydneyLoc) - // 12am on Saturday the 11th of February2023 in Sydney. + // 12am on Saturday the 11th of February 2023 in Sydney. saturdayMidnightSydney := time.Date(2023, 2, 11, 0, 0, 0, 0, sydneyLoc) t.Log("now", now) t.Log("wednesdayMidnightUTC", wednesdayMidnightUTC) - t.Log("fridayEveningSydney", fridayEveningSydney) t.Log("saturdayMidnightSydney", saturdayMidnightSydney) cases := []struct { - name string - now time.Time - templateAllowAutostop bool - templateDefaultTTL time.Duration - workspaceTTL time.Duration - transition database.WorkspaceTransition + name string + now time.Time + workspaceTTL time.Duration + transition database.WorkspaceTransition // These fields are only used when testing max deadline. userQuietHoursSchedule string @@ -942,8 +942,6 @@ func TestCompleteJob(t *testing.T) { { name: "OK", now: now, - templateAllowAutostop: true, - templateDefaultTTL: 0, templateRestartRequirement: schedule.TemplateRestartRequirement{}, workspaceTTL: 0, transition: database.WorkspaceTransitionStart, @@ -953,8 +951,6 @@ func TestCompleteJob(t *testing.T) { { name: "Delete", now: now, - templateAllowAutostop: true, - templateDefaultTTL: 0, templateRestartRequirement: schedule.TemplateRestartRequirement{}, workspaceTTL: 0, transition: database.WorkspaceTransitionDelete, @@ -964,178 +960,15 @@ func TestCompleteJob(t *testing.T) { { name: "WorkspaceTTL", now: now, - templateAllowAutostop: true, - templateDefaultTTL: 0, templateRestartRequirement: schedule.TemplateRestartRequirement{}, workspaceTTL: time.Hour, transition: database.WorkspaceTransitionStart, expectedDeadline: now.Add(time.Hour), expectedMaxDeadline: time.Time{}, }, - { - name: "TemplateDefaultTTLIgnored", - now: now, - templateAllowAutostop: true, - templateDefaultTTL: time.Hour, - templateRestartRequirement: schedule.TemplateRestartRequirement{}, - workspaceTTL: 0, - transition: database.WorkspaceTransitionStart, - expectedDeadline: time.Time{}, - expectedMaxDeadline: time.Time{}, - }, - { - name: "WorkspaceTTLOverridesTemplateDefaultTTL", - now: now, - templateAllowAutostop: true, - templateDefaultTTL: 2 * time.Hour, - templateRestartRequirement: schedule.TemplateRestartRequirement{}, - workspaceTTL: time.Hour, - transition: database.WorkspaceTransitionStart, - expectedDeadline: now.Add(time.Hour), - expectedMaxDeadline: time.Time{}, - }, - { - name: "TemplateBlockWorkspaceTTL", - now: now, - templateAllowAutostop: false, - templateDefaultTTL: 3 * time.Hour, - templateRestartRequirement: schedule.TemplateRestartRequirement{}, - workspaceTTL: 4 * time.Hour, - transition: database.WorkspaceTransitionStart, - expectedDeadline: now.Add(3 * time.Hour), - expectedMaxDeadline: time.Time{}, - }, { name: "TemplateRestartRequirement", now: wednesdayMidnightUTC, - templateAllowAutostop: true, - templateDefaultTTL: 0, - userQuietHoursSchedule: sydneyQuietHours, - templateRestartRequirement: schedule.TemplateRestartRequirement{ - DaysOfWeek: 0b00100000, // Saturday - Weeks: 0, // weekly - }, - workspaceTTL: 0, - transition: database.WorkspaceTransitionStart, - // expectedDeadline is copied from expectedMaxDeadline. - expectedMaxDeadline: saturdayMidnightSydney.In(time.UTC), - }, - { - name: "TemplateRestartRequirement1HourSkip", - now: saturdayMidnightSydney.Add(-59 * time.Minute), - templateAllowAutostop: true, - templateDefaultTTL: 0, - userQuietHoursSchedule: sydneyQuietHours, - templateRestartRequirement: schedule.TemplateRestartRequirement{ - DaysOfWeek: 0b00100000, // Saturday - Weeks: 1, // 1 also means weekly - }, - workspaceTTL: 0, - transition: database.WorkspaceTransitionStart, - // expectedDeadline is copied from expectedMaxDeadline. - expectedMaxDeadline: saturdayMidnightSydney.Add(7 * 24 * time.Hour).In(time.UTC), - }, - { - // The next restart requirement should be skipped if the - // workspace is started within 1 hour of it. - name: "TemplateRestartRequirementDaily", - now: fridayEveningSydney, - templateAllowAutostop: true, - templateDefaultTTL: 0, - userQuietHoursSchedule: sydneyQuietHours, - templateRestartRequirement: schedule.TemplateRestartRequirement{ - DaysOfWeek: 0b01111111, // daily - Weeks: 0, // all weeks - }, - workspaceTTL: 0, - transition: database.WorkspaceTransitionStart, - // expectedDeadline is copied from expectedMaxDeadline. - expectedMaxDeadline: saturdayMidnightSydney.In(time.UTC), - }, - { - name: "TemplateRestartRequirementFortnightly/Skip", - now: wednesdayMidnightUTC, - templateAllowAutostop: true, - templateDefaultTTL: 0, - userQuietHoursSchedule: sydneyQuietHours, - templateRestartRequirement: schedule.TemplateRestartRequirement{ - DaysOfWeek: 0b00100000, // Saturday - Weeks: 2, // every 2 weeks - }, - workspaceTTL: 0, - transition: database.WorkspaceTransitionStart, - // expectedDeadline is copied from expectedMaxDeadline. - expectedMaxDeadline: saturdayMidnightSydney.AddDate(0, 0, 7).In(time.UTC), - }, - { - name: "TemplateRestartRequirementFortnightly/NoSkip", - now: wednesdayMidnightUTC.AddDate(0, 0, 7), - templateAllowAutostop: true, - templateDefaultTTL: 0, - userQuietHoursSchedule: sydneyQuietHours, - templateRestartRequirement: schedule.TemplateRestartRequirement{ - DaysOfWeek: 0b00100000, // Saturday - Weeks: 2, // every 2 weeks - }, - workspaceTTL: 0, - transition: database.WorkspaceTransitionStart, - // expectedDeadline is copied from expectedMaxDeadline. - expectedMaxDeadline: saturdayMidnightSydney.AddDate(0, 0, 7).In(time.UTC), - }, - { - name: "TemplateRestartRequirementTriweekly/Skip", - now: wednesdayMidnightUTC, - templateAllowAutostop: true, - templateDefaultTTL: 0, - userQuietHoursSchedule: sydneyQuietHours, - templateRestartRequirement: schedule.TemplateRestartRequirement{ - DaysOfWeek: 0b00100000, // Saturday - Weeks: 3, // every 3 weeks - }, - workspaceTTL: 0, - transition: database.WorkspaceTransitionStart, - // expectedDeadline is copied from expectedMaxDeadline. - // The next triweekly restart requirement happens next week - // according to the epoch. - expectedMaxDeadline: saturdayMidnightSydney.AddDate(0, 0, 7).In(time.UTC), - }, - { - name: "TemplateRestartRequirementTriweekly/NoSkip", - now: wednesdayMidnightUTC.AddDate(0, 0, 7), - templateAllowAutostop: true, - templateDefaultTTL: 0, - userQuietHoursSchedule: sydneyQuietHours, - templateRestartRequirement: schedule.TemplateRestartRequirement{ - DaysOfWeek: 0b00100000, // Saturday - Weeks: 3, // every 3 weeks - }, - workspaceTTL: 0, - transition: database.WorkspaceTransitionStart, - // expectedDeadline is copied from expectedMaxDeadline. - expectedMaxDeadline: saturdayMidnightSydney.AddDate(0, 0, 7).In(time.UTC), - }, - { - name: "TemplateRestartRequirementOverridesWorkspaceTTL", - // now doesn't have to be UTC, but it helps us ensure that - // timezones are compared correctly in this test. - now: fridayEveningSydney.In(time.UTC), - templateAllowAutostop: true, - templateDefaultTTL: 0, - userQuietHoursSchedule: sydneyQuietHours, - templateRestartRequirement: schedule.TemplateRestartRequirement{ - DaysOfWeek: 0b00100000, // Saturday - Weeks: 0, // weekly - }, - workspaceTTL: 3 * time.Hour, - transition: database.WorkspaceTransitionStart, - // expectedDeadline is copied from expectedMaxDeadline. - expectedMaxDeadline: saturdayMidnightSydney.In(time.UTC), - }, - { - name: "TemplateRestartRequirementOverridesTemplateDefaultTTL", - now: fridayEveningSydney.In(time.UTC), - templateAllowAutostop: true, - templateDefaultTTL: 3 * time.Hour, userQuietHoursSchedule: sydneyQuietHours, templateRestartRequirement: schedule.TemplateRestartRequirement{ DaysOfWeek: 0b00100000, // Saturday @@ -1167,8 +1000,8 @@ func TestCompleteJob(t *testing.T) { GetFn: func(_ context.Context, _ database.Store, _ uuid.UUID) (schedule.TemplateScheduleOptions, error) { return schedule.TemplateScheduleOptions{ UserAutostartEnabled: false, - UserAutostopEnabled: c.templateAllowAutostop, - DefaultTTL: c.templateDefaultTTL, + UserAutostopEnabled: true, + DefaultTTL: 0, RestartRequirement: c.templateRestartRequirement, }, nil }, @@ -1207,8 +1040,9 @@ func TestCompleteJob(t *testing.T) { template, err := srv.Database.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{ ID: template.ID, UpdatedAt: database.Now(), - AllowUserAutostart: c.templateAllowAutostop, - DefaultTTL: int64(c.templateDefaultTTL), + AllowUserAutostart: false, + AllowUserAutostop: true, + DefaultTTL: 0, RestartRequirementDaysOfWeek: int16(c.templateRestartRequirement.DaysOfWeek), RestartRequirementWeeks: c.templateRestartRequirement.Weeks, }) @@ -1224,6 +1058,7 @@ func TestCompleteJob(t *testing.T) { workspace := dbgen.Workspace(t, srv.Database, database.Workspace{ TemplateID: template.ID, Ttl: workspaceTTL, + OwnerID: user.ID, }) version := dbgen.TemplateVersion(t, srv.Database, database.TemplateVersion{ TemplateID: uuid.NullUUID{ diff --git a/coderd/schedule/autostop.go b/coderd/schedule/autostop.go new file mode 100644 index 0000000000000..b43a0d8bb87bf --- /dev/null +++ b/coderd/schedule/autostop.go @@ -0,0 +1,234 @@ +package schedule + +import ( + "context" + "time" + + "golang.org/x/xerrors" + + "github.com/coder/coder/coderd/database" +) + +const ( + // restartRequirementLeeway is the duration of time before a restart + // requirement where we skip the requirement and fall back to the next + // scheduled restart. This avoids workspaces being restarted too soon. + restartRequirementLeeway = 1 * time.Hour + + // restartRequirementBuffer is the duration of time we subtract from the + // time when calculating the next scheduled restart time. This avoids issues + // where autostart happens on the hour and the scheduled quiet hours are + // also on the hour. + restartRequirementBuffer = -15 * time.Minute +) + +type CalculateAutostopParams struct { + Database database.Store + TemplateScheduleStore TemplateScheduleStore + UserQuietHoursScheduleStore UserQuietHoursScheduleStore + + Now time.Time + Workspace database.Workspace +} + +type AutostopTime struct { + // Deadline is the time when the workspace will be stopped. The value can be + // bumped by user activity or manually by the user via the UI. + Deadline time.Time + // MaxDeadline is the maximum value for deadline. + MaxDeadline time.Time +} + +// CalculateAutostop calculates the deadline and max_deadline for a workspace +// build. +// +// Deadline is the time when the workspace will be stopped, as long as it +// doesn't see any new activity (such as SSH, app requests, etc.). When activity +// is detected the deadline is bumped by the workspace's TTL (this only happens +// when activity is detected and more than 20% of the TTL has passed to save +// database queries). +// +// MaxDeadline is the maximum value for deadline. The deadline cannot be bumped +// past this value, so it denotes the absolute deadline that the workspace build +// must be stopped by. MaxDeadline is calculated using the template's "restart +// requirement" settings and the user's "quiet hours" settings to pick a time +// outside of working hours. +// +// Deadline is a cost saving measure, while max deadline is a +// compliance/updating measure. +func CalculateAutostop(ctx context.Context, params CalculateAutostopParams) (AutostopTime, error) { + var ( + db = params.Database + workspace = params.Workspace + now = params.Now + + autostop AutostopTime + ) + + if workspace.Ttl.Valid { + // When the workspace is made it copies the template's TTL, and the user + // can unset it to disable it (unless the template has + // UserAutoStopEnabled set to false, see below). + autostop.Deadline = now.Add(time.Duration(workspace.Ttl.Int64)) + } + + templateSchedule, err := params.TemplateScheduleStore.GetTemplateScheduleOptions(ctx, db, workspace.TemplateID) + if err != nil { + return autostop, xerrors.Errorf("get template schedule options: %w", err) + } + if !templateSchedule.UserAutostopEnabled { + // The user is not permitted to set their own TTL, so use the template + // default. + autostop.Deadline = time.Time{} + if templateSchedule.DefaultTTL > 0 { + autostop.Deadline = now.Add(templateSchedule.DefaultTTL) + } + } + + if templateSchedule.RestartRequirement.DaysOfWeek != 0 { + // The template has a restart requirement, so determine the max deadline + // of this workspace build. + + // First, get the user's quiet hours schedule (this will return the + // default if the user has not set their own schedule). + userQuietHoursSchedule, err := params.UserQuietHoursScheduleStore.GetUserQuietHoursScheduleOptions(ctx, db, workspace.OwnerID) + if err != nil { + return autostop, xerrors.Errorf("get user quiet hours schedule options: %w", err) + } + + // If the schedule is nil, that means the deployment isn't entitled to + // use quiet hours or the default schedule has not been set. In this + // case, do not set a max deadline on the workspace. + if userQuietHoursSchedule.Schedule != nil { + loc := userQuietHoursSchedule.Schedule.Location() + now := now.In(loc) + // Add the leeway here so we avoid checking today's quiet hours if + // the workspace was started <1h before midnight. + startOfStopDay := truncateMidnight(now.Add(restartRequirementLeeway)) + + // If the template schedule wants to only restart on n-th weeks then + // change the startOfDay to be the Monday of the next applicable + // week. + if templateSchedule.RestartRequirement.Weeks > 1 { + epoch := TemplateRestartRequirementEpoch(loc) + if startOfStopDay.Before(epoch) { + return autostop, xerrors.New("coder server system clock is incorrect, cannot calculate template restart requirement") + } + since := startOfStopDay.Sub(epoch) + weeksSinceEpoch := int64(since.Hours() / (24 * 7)) + requiredWeeks := templateSchedule.RestartRequirement.Weeks + weeksRemainder := weeksSinceEpoch % requiredWeeks + if weeksRemainder != 0 { + // Add (requiredWeeks - weeksSince) * 7 days to the current + // startOfStopDay, then truncate to Monday midnight. + // + // This sets startOfStopDay to Monday at midnight of the + // next applicable week. + y, mo, d := startOfStopDay.Date() + d += int(requiredWeeks-weeksRemainder) * 7 + startOfStopDay = time.Date(y, mo, d, 0, 0, 0, 0, loc) + startOfStopDay = truncateMondayMidnight(startOfStopDay) + } + } + + // Determine if we should skip the first day because the schedule is + // too near or has already passed. + // + // Allow an hour of leeway (i.e. any workspaces started within an + // hour of the scheduled stop time will always bounce to the next + // stop window). + checkSchedule := userQuietHoursSchedule.Schedule.Next(startOfStopDay.Add(restartRequirementBuffer)) + if checkSchedule.Before(now.Add(restartRequirementLeeway)) { + // Set the first stop day we try to tomorrow because today's + // schedule is too close to now or has already passed. + startOfStopDay = nextDayMidnight(startOfStopDay) + } + + // Iterate from 0 to 7, check if the current startOfDay is in the + // restart requirement. If it isn't then add a day and try again. + requirementDays := templateSchedule.RestartRequirement.DaysMap() + for i := 0; i < len(DaysOfWeek)+1; i++ { + if i == len(DaysOfWeek) { + // We've wrapped, so somehow we couldn't find a day in the + // restart requirement in the next week. + // + // This shouldn't be able to happen, as we've already + // checked that there is a day in the restart requirement + // above with the + // `if templateSchedule.RestartRequirement.DaysOfWeek != 0` + // check. + // + // The eighth bit shouldn't be set, as we validate the + // bitmap in the enterprise TemplateScheduleStore. + return autostop, xerrors.New("could not find suitable day for template restart requirement in the next 7 days") + } + if requirementDays[startOfStopDay.Weekday()] { + break + } + startOfStopDay = nextDayMidnight(startOfStopDay) + } + + // If the startOfDay is within an hour of now, then we add an hour. + checkTime := startOfStopDay + if checkTime.Before(now.Add(time.Hour)) { + checkTime = now.Add(time.Hour) + } else { + // If it's not within an hour of now, subtract 15 minutes to + // give a little leeway. This prevents skipped stop events + // because autostart perfectly lines up with autostop. + checkTime = checkTime.Add(restartRequirementBuffer) + } + + // Get the next occurrence of the restart schedule. + autostop.MaxDeadline = userQuietHoursSchedule.Schedule.Next(checkTime) + if autostop.MaxDeadline.IsZero() { + return autostop, xerrors.New("could not find next occurrence of template restart requirement in user quiet hours schedule") + } + } + + // If the workspace doesn't have a deadline or the max deadline is + // sooner than the workspace deadline, use the max deadline as the + // actual deadline. + if autostop.Deadline.IsZero() || autostop.MaxDeadline.Before(autostop.Deadline) { + autostop.Deadline = autostop.MaxDeadline + } + } + + if (!autostop.Deadline.IsZero() && autostop.Deadline.Before(now)) || (!autostop.MaxDeadline.IsZero() && autostop.MaxDeadline.Before(now)) { + // Something went wrong with the deadline calculation, so we should + // bail. + return autostop, xerrors.Errorf("deadline calculation error, computed deadline or max deadline is in the past for workspace build: deadline=%q maxDeadline=%q now=%q", autostop.Deadline, autostop.MaxDeadline, now) + } + + return autostop, nil +} + +// truncateMidnight truncates a time to midnight in the time object's timezone. +// t.Truncate(24 * time.Hour) truncates based on the internal time and doesn't +// factor daylight savings properly. +// +// See: https://github.com/golang/go/issues/10894 +func truncateMidnight(t time.Time) time.Time { + yy, mm, dd := t.Date() + return time.Date(yy, mm, dd, 0, 0, 0, 0, t.Location()) +} + +// nextDayMidnight returns the next midnight in the time object's timezone. +func nextDayMidnight(t time.Time) time.Time { + yy, mm, dd := t.Date() + // time.Date will correctly normalize the date if it's past the end of the + // month. E.g. October 32nd will be November 1st. + dd += 1 + return time.Date(yy, mm, dd, 0, 0, 0, 0, t.Location()) +} + +// truncateMondayMidnight truncates a time to the previous Monday at midnight in +// the time object's timezone. +func truncateMondayMidnight(t time.Time) time.Time { + // time.Date will correctly normalize the date if it's past the end of the + // month. E.g. October 32nd will be November 1st. + yy, mm, dd := t.Date() + dd -= int(t.Weekday() - 1) + t = time.Date(yy, mm, dd, 0, 0, 0, 0, t.Location()) + return truncateMidnight(t) +} diff --git a/coderd/schedule/autostop_test.go b/coderd/schedule/autostop_test.go new file mode 100644 index 0000000000000..e97f32580e058 --- /dev/null +++ b/coderd/schedule/autostop_test.go @@ -0,0 +1,379 @@ +package schedule_test + +import ( + "context" + "database/sql" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/database/dbgen" + "github.com/coder/coder/coderd/database/dbtestutil" + "github.com/coder/coder/coderd/schedule" + "github.com/coder/coder/testutil" +) + +func TestCalculateAutoStop(t *testing.T) { + t.Parallel() + + now := time.Now() + + // Wednesday the 8th of February 2023 at midnight. This date was + // specifically chosen as it doesn't fall on a applicable week for both + // fortnightly and triweekly restart requirements. + wednesdayMidnightUTC := time.Date(2023, 2, 8, 0, 0, 0, 0, time.UTC) + + sydneyQuietHours := "CRON_TZ=Australia/Sydney 0 0 * * *" + sydneyLoc, err := time.LoadLocation("Australia/Sydney") + require.NoError(t, err) + // 10pm on Friday the 10th of February 2023 in Sydney. + fridayEveningSydney := time.Date(2023, 2, 10, 22, 0, 0, 0, sydneyLoc) + // 12am on Saturday the 11th of February2023 in Sydney. + saturdayMidnightSydney := time.Date(2023, 2, 11, 0, 0, 0, 0, sydneyLoc) + + t.Log("now", now) + t.Log("wednesdayMidnightUTC", wednesdayMidnightUTC) + t.Log("fridayEveningSydney", fridayEveningSydney) + t.Log("saturdayMidnightSydney", saturdayMidnightSydney) + + cases := []struct { + name string + now time.Time + templateAllowAutostop bool + templateDefaultTTL time.Duration + templateRestartRequirement schedule.TemplateRestartRequirement + userQuietHoursSchedule string + // workspaceTTL is usually copied from the template's TTL when the + // workspace is made, so it takes precedence unless + // templateAllowAutostop is false. + workspaceTTL time.Duration + + // expectedDeadline is copied from expectedMaxDeadline if unset. + expectedDeadline time.Time + expectedMaxDeadline time.Time + errContains string + }{ + { + name: "OK", + now: now, + templateAllowAutostop: true, + templateDefaultTTL: 0, + templateRestartRequirement: schedule.TemplateRestartRequirement{}, + workspaceTTL: 0, + expectedDeadline: time.Time{}, + expectedMaxDeadline: time.Time{}, + }, + { + name: "Delete", + now: now, + templateAllowAutostop: true, + templateDefaultTTL: 0, + templateRestartRequirement: schedule.TemplateRestartRequirement{}, + workspaceTTL: 0, + expectedDeadline: time.Time{}, + expectedMaxDeadline: time.Time{}, + }, + { + name: "WorkspaceTTL", + now: now, + templateAllowAutostop: true, + templateDefaultTTL: 0, + templateRestartRequirement: schedule.TemplateRestartRequirement{}, + workspaceTTL: time.Hour, + expectedDeadline: now.Add(time.Hour), + expectedMaxDeadline: time.Time{}, + }, + { + name: "TemplateDefaultTTLIgnored", + now: now, + templateAllowAutostop: true, + templateDefaultTTL: time.Hour, + templateRestartRequirement: schedule.TemplateRestartRequirement{}, + workspaceTTL: 0, + expectedDeadline: time.Time{}, + expectedMaxDeadline: time.Time{}, + }, + { + name: "WorkspaceTTLOverridesTemplateDefaultTTL", + now: now, + templateAllowAutostop: true, + templateDefaultTTL: 2 * time.Hour, + templateRestartRequirement: schedule.TemplateRestartRequirement{}, + workspaceTTL: time.Hour, + expectedDeadline: now.Add(time.Hour), + expectedMaxDeadline: time.Time{}, + }, + { + name: "TemplateBlockWorkspaceTTL", + now: now, + templateAllowAutostop: false, + templateDefaultTTL: 3 * time.Hour, + templateRestartRequirement: schedule.TemplateRestartRequirement{}, + workspaceTTL: 4 * time.Hour, + expectedDeadline: now.Add(3 * time.Hour), + expectedMaxDeadline: time.Time{}, + }, + { + name: "TemplateRestartRequirement", + now: wednesdayMidnightUTC, + templateAllowAutostop: true, + templateDefaultTTL: 0, + userQuietHoursSchedule: sydneyQuietHours, + templateRestartRequirement: schedule.TemplateRestartRequirement{ + DaysOfWeek: 0b00100000, // Saturday + Weeks: 0, // weekly + }, + workspaceTTL: 0, + // expectedDeadline is copied from expectedMaxDeadline. + expectedMaxDeadline: saturdayMidnightSydney.In(time.UTC), + }, + { + name: "TemplateRestartRequirement1HourSkip", + now: saturdayMidnightSydney.Add(-59 * time.Minute), + templateAllowAutostop: true, + templateDefaultTTL: 0, + userQuietHoursSchedule: sydneyQuietHours, + templateRestartRequirement: schedule.TemplateRestartRequirement{ + DaysOfWeek: 0b00100000, // Saturday + Weeks: 1, // 1 also means weekly + }, + workspaceTTL: 0, + // expectedDeadline is copied from expectedMaxDeadline. + expectedMaxDeadline: saturdayMidnightSydney.Add(7 * 24 * time.Hour).In(time.UTC), + }, + { + // The next restart requirement should be skipped if the + // workspace is started within 1 hour of it. + name: "TemplateRestartRequirementDaily", + now: fridayEveningSydney, + templateAllowAutostop: true, + templateDefaultTTL: 0, + userQuietHoursSchedule: sydneyQuietHours, + templateRestartRequirement: schedule.TemplateRestartRequirement{ + DaysOfWeek: 0b01111111, // daily + Weeks: 0, // all weeks + }, + workspaceTTL: 0, + // expectedDeadline is copied from expectedMaxDeadline. + expectedMaxDeadline: saturdayMidnightSydney.In(time.UTC), + }, + { + name: "TemplateRestartRequirementFortnightly/Skip", + now: wednesdayMidnightUTC, + templateAllowAutostop: true, + templateDefaultTTL: 0, + userQuietHoursSchedule: sydneyQuietHours, + templateRestartRequirement: schedule.TemplateRestartRequirement{ + DaysOfWeek: 0b00100000, // Saturday + Weeks: 2, // every 2 weeks + }, + workspaceTTL: 0, + // expectedDeadline is copied from expectedMaxDeadline. + expectedMaxDeadline: saturdayMidnightSydney.AddDate(0, 0, 7).In(time.UTC), + }, + { + name: "TemplateRestartRequirementFortnightly/NoSkip", + now: wednesdayMidnightUTC.AddDate(0, 0, 7), + templateAllowAutostop: true, + templateDefaultTTL: 0, + userQuietHoursSchedule: sydneyQuietHours, + templateRestartRequirement: schedule.TemplateRestartRequirement{ + DaysOfWeek: 0b00100000, // Saturday + Weeks: 2, // every 2 weeks + }, + workspaceTTL: 0, + // expectedDeadline is copied from expectedMaxDeadline. + expectedMaxDeadline: saturdayMidnightSydney.AddDate(0, 0, 7).In(time.UTC), + }, + { + name: "TemplateRestartRequirementTriweekly/Skip", + now: wednesdayMidnightUTC, + templateAllowAutostop: true, + templateDefaultTTL: 0, + userQuietHoursSchedule: sydneyQuietHours, + templateRestartRequirement: schedule.TemplateRestartRequirement{ + DaysOfWeek: 0b00100000, // Saturday + Weeks: 3, // every 3 weeks + }, + workspaceTTL: 0, + // expectedDeadline is copied from expectedMaxDeadline. + // The next triweekly restart requirement happens next week + // according to the epoch. + expectedMaxDeadline: saturdayMidnightSydney.AddDate(0, 0, 7).In(time.UTC), + }, + { + name: "TemplateRestartRequirementTriweekly/NoSkip", + now: wednesdayMidnightUTC.AddDate(0, 0, 7), + templateAllowAutostop: true, + templateDefaultTTL: 0, + userQuietHoursSchedule: sydneyQuietHours, + templateRestartRequirement: schedule.TemplateRestartRequirement{ + DaysOfWeek: 0b00100000, // Saturday + Weeks: 3, // every 3 weeks + }, + workspaceTTL: 0, + // expectedDeadline is copied from expectedMaxDeadline. + expectedMaxDeadline: saturdayMidnightSydney.AddDate(0, 0, 7).In(time.UTC), + }, + { + name: "TemplateRestartRequirementOverridesWorkspaceTTL", + // now doesn't have to be UTC, but it helps us ensure that + // timezones are compared correctly in this test. + now: fridayEveningSydney.In(time.UTC), + templateAllowAutostop: true, + templateDefaultTTL: 0, + userQuietHoursSchedule: sydneyQuietHours, + templateRestartRequirement: schedule.TemplateRestartRequirement{ + DaysOfWeek: 0b00100000, // Saturday + Weeks: 0, // weekly + }, + workspaceTTL: 3 * time.Hour, + // expectedDeadline is copied from expectedMaxDeadline. + expectedMaxDeadline: saturdayMidnightSydney.In(time.UTC), + }, + { + name: "TemplateRestartRequirementOverridesTemplateDefaultTTL", + now: fridayEveningSydney.In(time.UTC), + templateAllowAutostop: true, + templateDefaultTTL: 3 * time.Hour, + userQuietHoursSchedule: sydneyQuietHours, + templateRestartRequirement: schedule.TemplateRestartRequirement{ + DaysOfWeek: 0b00100000, // Saturday + Weeks: 0, // weekly + }, + workspaceTTL: 0, + // expectedDeadline is copied from expectedMaxDeadline. + expectedMaxDeadline: saturdayMidnightSydney.In(time.UTC), + }, + { + name: "TimeBeforeEpoch", + // The epoch is 2023-01-02 in each timezone. + now: time.Date(2023, 1, 1, 23, 59, 59, 0, sydneyLoc), + templateAllowAutostop: true, + templateDefaultTTL: 0, + userQuietHoursSchedule: sydneyQuietHours, + templateRestartRequirement: schedule.TemplateRestartRequirement{ + DaysOfWeek: 0b00100000, // Saturday + Weeks: 0, // weekly + }, + workspaceTTL: 0, + errContains: "coder server system clock is incorrect", + }, + { + name: "BadRestartRequirement/NoDaysOfWeek", + // The epoch is 2023-01-02 in each timezone. + now: time.Date(2023, 1, 1, 23, 59, 59, 0, sydneyLoc), + templateAllowAutostop: true, + templateDefaultTTL: 0, + userQuietHoursSchedule: sydneyQuietHours, + templateRestartRequirement: schedule.TemplateRestartRequirement{ + DaysOfWeek: 0b10000000, // using un + Weeks: 0, // weekly + }, + workspaceTTL: 0, + errContains: "coder server system clock is incorrect", + }, + } + + for _, c := range cases { + c := c + + t.Run(c.name, func(t *testing.T) { + t.Parallel() + + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitLong) + + templateScheduleStore := schedule.MockTemplateScheduleStore{ + GetFn: func(_ context.Context, _ database.Store, _ uuid.UUID) (schedule.TemplateScheduleOptions, error) { + return schedule.TemplateScheduleOptions{ + UserAutostartEnabled: false, + UserAutostopEnabled: c.templateAllowAutostop, + DefaultTTL: c.templateDefaultTTL, + RestartRequirement: c.templateRestartRequirement, + }, nil + }, + } + + userQuietHoursScheduleStore := schedule.MockUserQuietHoursScheduleStore{ + GetFn: func(_ context.Context, _ database.Store, _ uuid.UUID) (schedule.UserQuietHoursScheduleOptions, error) { + if c.userQuietHoursSchedule == "" { + return schedule.UserQuietHoursScheduleOptions{ + Schedule: nil, + }, nil + } + + sched, err := schedule.Daily(c.userQuietHoursSchedule) + if !assert.NoError(t, err) { + return schedule.UserQuietHoursScheduleOptions{}, err + } + + return schedule.UserQuietHoursScheduleOptions{ + Schedule: sched, + UserSet: false, + Duration: 4 * time.Hour, + }, nil + }, + } + + user := dbgen.User(t, db, database.User{ + QuietHoursSchedule: c.userQuietHoursSchedule, + }) + template := dbgen.Template(t, db, database.Template{ + Name: "template", + Provisioner: database.ProvisionerTypeEcho, + }) + template, err := db.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{ + ID: template.ID, + UpdatedAt: database.Now(), + AllowUserAutostart: c.templateAllowAutostop, + RestartRequirementDaysOfWeek: int16(c.templateRestartRequirement.DaysOfWeek), + RestartRequirementWeeks: c.templateRestartRequirement.Weeks, + }) + require.NoError(t, err) + workspaceTTL := sql.NullInt64{} + if c.workspaceTTL != 0 { + workspaceTTL = sql.NullInt64{ + Int64: int64(c.workspaceTTL), + Valid: true, + } + } + workspace := dbgen.Workspace(t, db, database.Workspace{ + TemplateID: template.ID, + Ttl: workspaceTTL, + OwnerID: user.ID, + }) + + autostop, err := schedule.CalculateAutostop(ctx, schedule.CalculateAutostopParams{ + Database: db, + TemplateScheduleStore: templateScheduleStore, + UserQuietHoursScheduleStore: userQuietHoursScheduleStore, + Now: c.now, + Workspace: workspace, + }) + require.NoError(t, err) + + // If the max deadline is set, the deadline should also be set. + // Default to the max deadline if the deadline is not set. + if c.expectedDeadline.IsZero() { + c.expectedDeadline = c.expectedMaxDeadline + } + + if c.expectedDeadline.IsZero() { + require.True(t, autostop.Deadline.IsZero()) + } else { + require.WithinDuration(t, c.expectedDeadline, autostop.Deadline, 15*time.Second, "deadline does not match expected") + } + if c.expectedMaxDeadline.IsZero() { + require.True(t, autostop.MaxDeadline.IsZero()) + } else { + require.WithinDuration(t, c.expectedMaxDeadline, autostop.MaxDeadline, 15*time.Second, "max deadline does not match expected") + require.GreaterOrEqual(t, autostop.MaxDeadline.Unix(), autostop.Deadline.Unix(), "max deadline is smaller than deadline") + } + }) + } +} diff --git a/enterprise/coderd/schedule/template.go b/enterprise/coderd/schedule/template.go index 03d28af7cc8c9..d0e1fad13ed13 100644 --- a/enterprise/coderd/schedule/template.go +++ b/enterprise/coderd/schedule/template.go @@ -28,7 +28,8 @@ func (*EnterpriseTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.C return agpl.TemplateScheduleOptions{}, err } - // These extra checks have to be done before the conversion. + // 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") } From eb1c1f62be7db6c1c7dd96dabbf5087b352b8b18 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Mon, 10 Jul 2023 10:24:28 +0000 Subject: [PATCH 12/25] more tests --- coderd/schedule/autostop_test.go | 27 +-- coderd/templates_test.go | 319 ++++++++++++++++++++++++++++--- codersdk/users.go | 30 +++ enterprise/coderd/users.go | 11 +- enterprise/coderd/users_test.go | 128 +++++++++++++ site/src/api/typesGenerated.ts | 2 - 6 files changed, 462 insertions(+), 55 deletions(-) create mode 100644 enterprise/coderd/users_test.go diff --git a/coderd/schedule/autostop_test.go b/coderd/schedule/autostop_test.go index e97f32580e058..75b58eead79ab 100644 --- a/coderd/schedule/autostop_test.go +++ b/coderd/schedule/autostop_test.go @@ -251,28 +251,16 @@ func TestCalculateAutoStop(t *testing.T) { }, { name: "TimeBeforeEpoch", - // The epoch is 2023-01-02 in each timezone. - now: time.Date(2023, 1, 1, 23, 59, 59, 0, sydneyLoc), + // The epoch is 2023-01-02 in each timezone. We set the time to + // 1 second before 11pm the previous day, as this is the latest time + // we allow due to our 1h leeway logic. + now: time.Date(2023, 1, 1, 22, 59, 59, 0, sydneyLoc), templateAllowAutostop: true, templateDefaultTTL: 0, userQuietHoursSchedule: sydneyQuietHours, templateRestartRequirement: schedule.TemplateRestartRequirement{ DaysOfWeek: 0b00100000, // Saturday - Weeks: 0, // weekly - }, - workspaceTTL: 0, - errContains: "coder server system clock is incorrect", - }, - { - name: "BadRestartRequirement/NoDaysOfWeek", - // The epoch is 2023-01-02 in each timezone. - now: time.Date(2023, 1, 1, 23, 59, 59, 0, sydneyLoc), - templateAllowAutostop: true, - templateDefaultTTL: 0, - userQuietHoursSchedule: sydneyQuietHours, - templateRestartRequirement: schedule.TemplateRestartRequirement{ - DaysOfWeek: 0b10000000, // using un - Weeks: 0, // weekly + Weeks: 2, // every fortnight }, workspaceTTL: 0, errContains: "coder server system clock is incorrect", @@ -355,6 +343,11 @@ func TestCalculateAutoStop(t *testing.T) { Now: c.now, Workspace: workspace, }) + if c.errContains != "" { + require.Error(t, err) + require.ErrorContains(t, err, c.errContains) + return + } require.NoError(t, err) // If the max deadline is set, the deadline should also be set. diff --git a/coderd/templates_test.go b/coderd/templates_test.go index 817a1d74fb8bf..0d7fc4c817e52 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -245,6 +245,131 @@ func TestPostTemplateByOrganization(t *testing.T) { require.ErrorAs(t, err, &apiErr) require.Equal(t, http.StatusNotFound, apiErr.StatusCode()) }) + + t.Run("RestartRequirement", func(t *testing.T) { + t.Parallel() + + t.Run("None", func(t *testing.T) { + t.Parallel() + + var setCalled int64 + client := coderdtest.New(t, &coderdtest.Options{ + TemplateScheduleStore: schedule.MockTemplateScheduleStore{ + SetFn: func(ctx context.Context, db database.Store, template database.Template, options schedule.TemplateScheduleOptions) (database.Template, error) { + atomic.AddInt64(&setCalled, 1) + assert.Zero(t, options.RestartRequirement.DaysOfWeek) + assert.Zero(t, options.RestartRequirement.Weeks) + + return db.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{ + ID: template.ID, + UpdatedAt: database.Now(), + AllowUserAutostart: options.UserAutostartEnabled, + AllowUserAutostop: options.UserAutostopEnabled, + DefaultTTL: int64(options.DefaultTTL), + RestartRequirementDaysOfWeek: int16(options.RestartRequirement.DaysOfWeek), + RestartRequirementWeeks: options.RestartRequirement.Weeks, + FailureTTL: int64(options.FailureTTL), + InactivityTTL: int64(options.InactivityTTL), + LockedTTL: int64(options.LockedTTL), + }) + }, + }, + }) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + got, err := client.CreateTemplate(ctx, user.OrganizationID, codersdk.CreateTemplateRequest{ + Name: "testing", + VersionID: version.ID, + RestartRequirement: nil, + }) + require.NoError(t, err) + + require.EqualValues(t, 1, atomic.LoadInt64(&setCalled)) + require.Empty(t, got.RestartRequirement.DaysOfWeek) + require.Zero(t, got.RestartRequirement.Weeks) + }) + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + var setCalled int64 + client := coderdtest.New(t, &coderdtest.Options{ + TemplateScheduleStore: schedule.MockTemplateScheduleStore{ + SetFn: func(ctx context.Context, db database.Store, template database.Template, options schedule.TemplateScheduleOptions) (database.Template, error) { + atomic.AddInt64(&setCalled, 1) + assert.EqualValues(t, 0b00110000, options.RestartRequirement.DaysOfWeek) + assert.EqualValues(t, 2, options.RestartRequirement.Weeks) + + return db.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{ + ID: template.ID, + UpdatedAt: database.Now(), + AllowUserAutostart: options.UserAutostartEnabled, + AllowUserAutostop: options.UserAutostopEnabled, + DefaultTTL: int64(options.DefaultTTL), + RestartRequirementDaysOfWeek: int16(options.RestartRequirement.DaysOfWeek), + RestartRequirementWeeks: options.RestartRequirement.Weeks, + FailureTTL: int64(options.FailureTTL), + InactivityTTL: int64(options.InactivityTTL), + LockedTTL: int64(options.LockedTTL), + }) + }, + }, + }) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + got, err := client.CreateTemplate(ctx, user.OrganizationID, codersdk.CreateTemplateRequest{ + Name: "testing", + VersionID: version.ID, + RestartRequirement: &codersdk.TemplateRestartRequirement{ + // wrong order + DaysOfWeek: []string{"saturday", "friday"}, + Weeks: 2, + }, + }) + require.NoError(t, err) + + require.EqualValues(t, 1, atomic.LoadInt64(&setCalled)) + require.Equal(t, []string{"friday", "saturday"}, got.RestartRequirement.DaysOfWeek) + require.EqualValues(t, 2, got.RestartRequirement.Weeks) + + got, err = client.Template(ctx, got.ID) + require.NoError(t, err) + require.Equal(t, []string{"friday", "saturday"}, got.RestartRequirement.DaysOfWeek) + require.EqualValues(t, 2, got.RestartRequirement.Weeks) + }) + + t.Run("IgnoredUnlicensed", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + got, err := client.CreateTemplate(ctx, user.OrganizationID, codersdk.CreateTemplateRequest{ + Name: "testing", + VersionID: version.ID, + RestartRequirement: &codersdk.TemplateRestartRequirement{ + DaysOfWeek: []string{"friday", "saturday"}, + Weeks: 2, + }, + }) + require.NoError(t, err) + // ignored and use AGPL defaults + require.Empty(t, got.RestartRequirement.DaysOfWeek) + require.Zero(t, got.RestartRequirement.Weeks) + }) + }) } func TestTemplatesByOrganization(t *testing.T) { @@ -634,6 +759,7 @@ func TestPatchTemplateMeta(t *testing.T) { Description: template.Description, Icon: template.Icon, DefaultTTLMillis: template.DefaultTTLMillis, + RestartRequirement: nil, AllowUserAutostart: template.AllowUserAutostart, AllowUserAutostop: template.AllowUserAutostop, } @@ -702,40 +828,175 @@ func TestPatchTemplateMeta(t *testing.T) { assert.Equal(t, updated.Icon, "") }) - t.Run("RestartRequirementEnterpriseOnly", func(t *testing.T) { + t.Run("RestartRequirement", func(t *testing.T) { t.Parallel() - client := coderdtest.New(t, nil) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - require.Empty(t, template.RestartRequirement.DaysOfWeek) - require.Zero(t, template.RestartRequirement.Weeks) - req := 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"}, - Weeks: 2, - }, - } + t.Run("OK", func(t *testing.T) { + t.Parallel() - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() + var setCalled int64 + client := coderdtest.New(t, &coderdtest.Options{ + TemplateScheduleStore: schedule.MockTemplateScheduleStore{ + SetFn: func(ctx context.Context, db database.Store, template database.Template, options schedule.TemplateScheduleOptions) (database.Template, error) { + if atomic.AddInt64(&setCalled, 1) == 2 { + assert.EqualValues(t, 0b0110000, options.RestartRequirement.DaysOfWeek) + assert.EqualValues(t, 2, options.RestartRequirement.Weeks) + } - updated, err := client.UpdateTemplateMeta(ctx, template.ID, req) - require.NoError(t, err) - require.Empty(t, updated.RestartRequirement.DaysOfWeek) - require.Zero(t, updated.RestartRequirement.Weeks) + return db.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{ + ID: template.ID, + UpdatedAt: database.Now(), + AllowUserAutostart: options.UserAutostartEnabled, + AllowUserAutostop: options.UserAutostopEnabled, + DefaultTTL: int64(options.DefaultTTL), + RestartRequirementDaysOfWeek: int16(options.RestartRequirement.DaysOfWeek), + RestartRequirementWeeks: options.RestartRequirement.Weeks, + FailureTTL: int64(options.FailureTTL), + InactivityTTL: int64(options.InactivityTTL), + LockedTTL: int64(options.LockedTTL), + }) + }, + }, + }) + user := coderdtest.CreateFirstUser(t, client) - template, err = client.Template(ctx, template.ID) - require.NoError(t, err) - require.Empty(t, template.RestartRequirement.DaysOfWeek) - require.Zero(t, template.RestartRequirement.Weeks) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + require.EqualValues(t, 1, atomic.LoadInt64(&setCalled)) + require.Empty(t, template.RestartRequirement.DaysOfWeek) + require.Zero(t, template.RestartRequirement.Weeks) + req := codersdk.UpdateTemplateMeta{ + Name: template.Name, + DisplayName: template.DisplayName, + Description: template.Description, + Icon: template.Icon, + AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, + DefaultTTLMillis: time.Hour.Milliseconds(), + RestartRequirement: &codersdk.TemplateRestartRequirement{ + // wrong order + DaysOfWeek: []string{"saturday", "friday"}, + Weeks: 2, + }, + } + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + updated, err := client.UpdateTemplateMeta(ctx, template.ID, req) + require.NoError(t, err) + require.EqualValues(t, 2, atomic.LoadInt64(&setCalled)) + require.Equal(t, []string{"friday", "saturday"}, updated.RestartRequirement.DaysOfWeek) + require.EqualValues(t, 2, updated.RestartRequirement.Weeks) + + template, err = client.Template(ctx, template.ID) + require.NoError(t, err) + require.Equal(t, []string{"friday", "saturday"}, template.RestartRequirement.DaysOfWeek) + require.EqualValues(t, 2, template.RestartRequirement.Weeks) + }) + + t.Run("Unset", func(t *testing.T) { + t.Parallel() + + var setCalled int64 + client := coderdtest.New(t, &coderdtest.Options{ + TemplateScheduleStore: schedule.MockTemplateScheduleStore{ + SetFn: func(ctx context.Context, db database.Store, template database.Template, options schedule.TemplateScheduleOptions) (database.Template, error) { + if atomic.AddInt64(&setCalled, 1) == 2 { + assert.EqualValues(t, 0, options.RestartRequirement.DaysOfWeek) + assert.EqualValues(t, 0, options.RestartRequirement.Weeks) + } + + return db.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{ + ID: template.ID, + UpdatedAt: database.Now(), + AllowUserAutostart: options.UserAutostartEnabled, + AllowUserAutostop: options.UserAutostopEnabled, + DefaultTTL: int64(options.DefaultTTL), + RestartRequirementDaysOfWeek: int16(options.RestartRequirement.DaysOfWeek), + RestartRequirementWeeks: options.RestartRequirement.Weeks, + FailureTTL: int64(options.FailureTTL), + InactivityTTL: int64(options.InactivityTTL), + LockedTTL: int64(options.LockedTTL), + }) + }, + }, + }) + user := coderdtest.CreateFirstUser(t, client) + + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { + ctr.RestartRequirement = &codersdk.TemplateRestartRequirement{ + // wrong order + DaysOfWeek: []string{"sunday", "saturday", "friday", "thursday", "wednesday", "tuesday", "monday"}, + Weeks: 2, + } + }) + require.EqualValues(t, 1, atomic.LoadInt64(&setCalled)) + require.Equal(t, []string{"monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"}, template.RestartRequirement.DaysOfWeek) + require.EqualValues(t, 2, template.RestartRequirement.Weeks) + req := 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{}, + Weeks: 0, + }, + } + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + updated, err := client.UpdateTemplateMeta(ctx, template.ID, req) + require.NoError(t, err) + require.EqualValues(t, 2, atomic.LoadInt64(&setCalled)) + require.Empty(t, updated.RestartRequirement.DaysOfWeek) + require.EqualValues(t, 0, updated.RestartRequirement.Weeks) + + template, err = client.Template(ctx, template.ID) + require.NoError(t, err) + require.Empty(t, template.RestartRequirement.DaysOfWeek) + require.EqualValues(t, 0, template.RestartRequirement.Weeks) + }) + + t.Run("EnterpriseOnly", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + require.Empty(t, template.RestartRequirement.DaysOfWeek) + require.Zero(t, template.RestartRequirement.Weeks) + req := 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"}, + Weeks: 2, + }, + } + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + updated, err := client.UpdateTemplateMeta(ctx, template.ID, req) + require.NoError(t, err) + require.Empty(t, updated.RestartRequirement.DaysOfWeek) + require.Zero(t, updated.RestartRequirement.Weeks) + + template, err = client.Template(ctx, template.ID) + require.NoError(t, err) + require.Empty(t, template.RestartRequirement.DaysOfWeek) + require.Zero(t, template.RestartRequirement.Weeks) + }) }) } diff --git a/codersdk/users.go b/codersdk/users.go index 8db70dddb1744..106bea6b74897 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -356,6 +356,36 @@ func (c *Client) User(ctx context.Context, userIdent string) (User, error) { return user, json.NewDecoder(res.Body).Decode(&user) } +// UserQuietHoursSchedule returns the quiet hours settings for the user. This +// endpoint only exists in enterprise editions. +func (c *Client) UserQuietHoursSchedule(ctx context.Context, userIdent string) (UserQuietHoursScheduleResponse, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/quiet-hours", userIdent), nil) + if err != nil { + return UserQuietHoursScheduleResponse{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return UserQuietHoursScheduleResponse{}, ReadBodyAsError(res) + } + var resp UserQuietHoursScheduleResponse + return resp, json.NewDecoder(res.Body).Decode(&resp) +} + +// UpdateUserQuietHoursSchedule updates the quiet hours settings for the user. +// This endpoint only exists in enterprise editions. +func (c *Client) UpdateUserQuietHoursSchedule(ctx context.Context, userIdent string, req UpdateUserQuietHoursScheduleRequest) (UserQuietHoursScheduleResponse, error) { + res, err := c.Request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/users/%s/quiet-hours", userIdent), req) + if err != nil { + return UserQuietHoursScheduleResponse{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return UserQuietHoursScheduleResponse{}, ReadBodyAsError(res) + } + var resp UserQuietHoursScheduleResponse + return resp, json.NewDecoder(res.Body).Decode(&resp) +} + // Users returns all users according to the request parameters. If no parameters are set, // the default behavior is to return all users in a single page. func (c *Client) Users(ctx context.Context, req UsersRequest) (GetUsersResponse, error) { diff --git a/enterprise/coderd/users.go b/enterprise/coderd/users.go index bc3f8a2ac25de..7a23e5105989d 100644 --- a/enterprise/coderd/users.go +++ b/enterprise/coderd/users.go @@ -25,11 +25,8 @@ func (api *API) userQuietHoursSchedule(rw http.ResponseWriter, r *http.Request) user = httpmw.UserParam(r) ) - // TODO: Double query here cuz of the user param opts, err := (*api.UserQuietHoursScheduleStore.Load()).GetUserQuietHoursScheduleOptions(ctx, api.Database, user.ID) if err != nil { - // TODO: some of these errors are related to bad syntax, would be nice - // to 400 httpapi.InternalServerError(rw, err) return } @@ -44,7 +41,7 @@ func (api *API) userQuietHoursSchedule(rw http.ResponseWriter, r *http.Request) Time: opts.Schedule.Time(), Timezone: opts.Schedule.Location().String(), Duration: opts.Duration, - Next: opts.Schedule.Next(time.Now()), + Next: opts.Schedule.Next(time.Now().In(opts.Schedule.Location())), }) } @@ -79,8 +76,8 @@ func (api *API) putUserQuietHoursSchedule(rw http.ResponseWriter, r *http.Reques opts, err := (*api.UserQuietHoursScheduleStore.Load()).SetUserQuietHoursScheduleOptions(ctx, api.Database, user.ID, params.Schedule) if err != nil { - // TODO: some of these errors are related to bad syntax, would be nice - // to 400 + // TODO(@dean): some of these errors are related to bad syntax, so it + // would be nice to 400 instead httpapi.InternalServerError(rw, err) return } @@ -91,6 +88,6 @@ func (api *API) putUserQuietHoursSchedule(rw http.ResponseWriter, r *http.Reques Time: opts.Schedule.Time(), Timezone: opts.Schedule.Location().String(), Duration: opts.Duration, - Next: opts.Schedule.Next(time.Now()), + Next: opts.Schedule.Next(time.Now().In(opts.Schedule.Location())), }) } diff --git a/enterprise/coderd/users_test.go b/enterprise/coderd/users_test.go new file mode 100644 index 0000000000000..889264dfb96a7 --- /dev/null +++ b/enterprise/coderd/users_test.go @@ -0,0 +1,128 @@ +package coderd_test + +import ( + "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() + + 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.UserQuietHoursSchedule.WindowDuration.Set("8h") // default is 4h + + client := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + DeploymentValues: dv, + }, + }) + user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureAdvancedTemplateScheduling: 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.Equal(t, dv.UserQuietHoursSchedule.WindowDuration.Value(), sched1.Duration) + 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.Equal(t, dv.UserQuietHoursSchedule.WindowDuration.Value(), sched2.Duration) + 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.Equal(t, dv.UserQuietHoursSchedule.WindowDuration.Value(), sched3.Duration) + 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. +} diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 7714f9613a2c8..193fe75213811 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1398,7 +1398,6 @@ export type FeatureName = | "scim" | "template_rbac" | "user_limit" - | "user_quiet_hours" | "workspace_proxy" export const FeatureNames: FeatureName[] = [ "advanced_template_scheduling", @@ -1411,7 +1410,6 @@ export const FeatureNames: FeatureName[] = [ "scim", "template_rbac", "user_limit", - "user_quiet_hours", "workspace_proxy", ] From fd26e69bc1833d4dfc5624b1ed437d13167c015a Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Wed, 12 Jul 2023 15:18:00 +0000 Subject: [PATCH 13/25] add back max_ttl and put restart_requirement behind feature flag --- cli/templateedit.go | 16 +- cli/templateedit_test.go | 201 ++++++++++++++ .../coder_templates_edit_--help.golden | 15 +- coderd/apidoc/docs.go | 14 +- coderd/apidoc/swagger.json | 14 +- coderd/database/dbfake/dbfake.go | 1 + coderd/database/dump.sql | 1 + ...0139_template_restart_requirement.down.sql | 3 +- ...000139_template_restart_requirement.up.sql | 4 +- coderd/database/models.go | 3 +- coderd/database/queries.sql.go | 37 ++- coderd/database/queries/templates.sql | 11 +- .../provisionerdserver_test.go | 245 +++++++++++++++++- coderd/schedule/autostop.go | 21 +- coderd/schedule/autostop_test.go | 53 +++- coderd/schedule/template.go | 11 + coderd/templates.go | 25 +- coderd/templates_test.go | 123 +++++++++ coderd/workspaces.go | 39 ++- coderd/workspaces_test.go | 6 +- codersdk/deployment.go | 14 + codersdk/organizations.go | 2 + codersdk/templates.go | 4 + docs/admin/audit-logs.md | 26 +- docs/api/schemas.md | 19 +- docs/api/templates.md | 7 + docs/cli/templates_edit.md | 24 +- enterprise/audit/table.go | 1 + enterprise/coderd/coderd.go | 43 ++- enterprise/coderd/schedule/template.go | 23 +- enterprise/coderd/templates_test.go | 115 ++++++++ enterprise/coderd/users_test.go | 1 + site/src/api/typesGenerated.ts | 7 + 33 files changed, 1017 insertions(+), 112 deletions(-) diff --git a/cli/templateedit.go b/cli/templateedit.go index bf6950e0b20e6..aef719ec16993 100644 --- a/cli/templateedit.go +++ b/cli/templateedit.go @@ -19,6 +19,7 @@ func (r *RootCmd) templateEdit() *clibase.Cmd { description string icon string defaultTTL time.Duration + maxTTL time.Duration restartRequirementDaysOfWeek []string restartRequirementWeeks int64 failureTTL time.Duration @@ -54,6 +55,7 @@ func (r *RootCmd) templateEdit() *clibase.Cmd { restartRequirementWeeks > 0 || !allowUserAutostart || !allowUserAutostop || + maxTTL != 0 || failureTTL != 0 || inactivityTTL != 0 if requiresEntitlement { @@ -101,6 +103,7 @@ func (r *RootCmd) templateEdit() *clibase.Cmd { Description: description, Icon: icon, DefaultTTLMillis: defaultTTL.Milliseconds(), + MaxTTLMillis: maxTTL.Milliseconds(), RestartRequirement: &codersdk.TemplateRestartRequirement{ DaysOfWeek: restartRequirementDaysOfWeek, Weeks: restartRequirementWeeks, @@ -147,15 +150,24 @@ func (r *RootCmd) templateEdit() *clibase.Cmd { Description: "Edit the template default time before shutdown - workspaces created from this template default to this value.", Value: clibase.DurationOf(&defaultTTL), }, + { + Flag: "max-ttl", + Description: "Edit the template maximum time before shutdown - workspaces created from this template must shutdown within the given duration after starting. This is an enterprise-only feature.", + Value: clibase.DurationOf(&maxTTL), + }, { Flag: "restart-requirement-weekdays", Description: "Edit the template restart requirement weekdays - workspaces created from this template must be restarted on the given weekdays. To unset this value for the template (and disable the restart requirement for the template), pass 'none'.", - Value: clibase.StringArrayOf(&restartRequirementDaysOfWeek), + // TODO(@dean): unhide when we delete max_ttl + Hidden: true, + Value: clibase.StringArrayOf(&restartRequirementDaysOfWeek), }, { Flag: "restart-requirement-weeks", Description: "Edit the template restart requirement weeks - workspaces created from this template must be restarted on an n-weekly basis.", - Value: clibase.Int64Of(&restartRequirementWeeks), + // TODO(@dean): unhide when we delete max_ttl + Hidden: true, + Value: clibase.Int64Of(&restartRequirementWeeks), }, { Flag: "failure-ttl", diff --git a/cli/templateedit_test.go b/cli/templateedit_test.go index ff86721a75a73..4a98518de3969 100644 --- a/cli/templateedit_test.go +++ b/cli/templateedit_test.go @@ -519,6 +519,207 @@ func TestTemplateEdit(t *testing.T) { assert.Equal(t, template.RestartRequirement.Weeks, updated.RestartRequirement.Weeks) }) }) + // TODO(@dean): remove this test when we remove max_ttl + t.Run("MaxTTL", func(t *testing.T) { + t.Parallel() + t.Run("BlockedAGPL", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { + ctr.DefaultTTLMillis = nil + ctr.MaxTTLMillis = nil + }) + + // Test the cli command. + cmdArgs := []string{ + "templates", + "edit", + template.Name, + "--max-ttl", "1h", + } + inv, root := clitest.New(t, cmdArgs...) + clitest.SetupConfig(t, client, root) + + ctx := testutil.Context(t, testutil.WaitLong) + err := inv.WithContext(ctx).Run() + require.Error(t, err) + require.ErrorContains(t, err, "appears to be an AGPL deployment") + + // Assert that the template metadata did not change. + updated, err := client.Template(context.Background(), template.ID) + require.NoError(t, err) + assert.Equal(t, template.Name, updated.Name) + assert.Equal(t, template.Description, updated.Description) + assert.Equal(t, template.Icon, updated.Icon) + assert.Equal(t, template.DisplayName, updated.DisplayName) + assert.Equal(t, template.DefaultTTLMillis, updated.DefaultTTLMillis) + assert.Equal(t, template.MaxTTLMillis, updated.MaxTTLMillis) + }) + + t.Run("BlockedNotEntitled", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { + ctr.DefaultTTLMillis = nil + ctr.MaxTTLMillis = nil + }) + + // Make a proxy server that will return a valid entitlements + // response, but without advanced scheduling entitlement. + proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v2/entitlements" { + res := codersdk.Entitlements{ + Features: map[codersdk.FeatureName]codersdk.Feature{}, + Warnings: []string{}, + Errors: []string{}, + HasLicense: true, + Trial: true, + RequireTelemetry: false, + } + for _, feature := range codersdk.FeatureNames { + res.Features[feature] = codersdk.Feature{ + Entitlement: codersdk.EntitlementNotEntitled, + Enabled: false, + Limit: nil, + Actual: nil, + } + } + httpapi.Write(r.Context(), w, http.StatusOK, res) + return + } + + // Otherwise, proxy the request to the real API server. + httputil.NewSingleHostReverseProxy(client.URL).ServeHTTP(w, r) + })) + defer proxy.Close() + + // Create a new client that uses the proxy server. + proxyURL, err := url.Parse(proxy.URL) + require.NoError(t, err) + proxyClient := codersdk.New(proxyURL) + proxyClient.SetSessionToken(client.SessionToken()) + + // Test the cli command. + cmdArgs := []string{ + "templates", + "edit", + template.Name, + "--max-ttl", "1h", + } + inv, root := clitest.New(t, cmdArgs...) + clitest.SetupConfig(t, proxyClient, root) + + ctx := testutil.Context(t, testutil.WaitLong) + err = inv.WithContext(ctx).Run() + require.Error(t, err) + require.ErrorContains(t, err, "license is not entitled") + + // Assert that the template metadata did not change. + updated, err := client.Template(context.Background(), template.ID) + require.NoError(t, err) + assert.Equal(t, template.Name, updated.Name) + assert.Equal(t, template.Description, updated.Description) + assert.Equal(t, template.Icon, updated.Icon) + assert.Equal(t, template.DisplayName, updated.DisplayName) + assert.Equal(t, template.DefaultTTLMillis, updated.DefaultTTLMillis) + assert.Equal(t, template.MaxTTLMillis, updated.MaxTTLMillis) + }) + t.Run("Entitled", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { + ctr.DefaultTTLMillis = nil + ctr.MaxTTLMillis = nil + }) + + // Make a proxy server that will return a valid entitlements + // response, including a valid advanced scheduling entitlement. + var updateTemplateCalled int64 + proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v2/entitlements" { + res := codersdk.Entitlements{ + Features: map[codersdk.FeatureName]codersdk.Feature{}, + Warnings: []string{}, + Errors: []string{}, + HasLicense: true, + Trial: true, + RequireTelemetry: false, + } + for _, feature := range codersdk.FeatureNames { + var one int64 = 1 + res.Features[feature] = codersdk.Feature{ + Entitlement: codersdk.EntitlementNotEntitled, + Enabled: true, + Limit: &one, + Actual: &one, + } + } + httpapi.Write(r.Context(), w, http.StatusOK, res) + return + } + if strings.HasPrefix(r.URL.Path, "/api/v2/templates/") { + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + _ = r.Body.Close() + + var req codersdk.UpdateTemplateMeta + err = json.Unmarshal(body, &req) + require.NoError(t, err) + assert.Equal(t, time.Hour.Milliseconds(), req.MaxTTLMillis) + + r.Body = io.NopCloser(bytes.NewReader(body)) + atomic.AddInt64(&updateTemplateCalled, 1) + // We still want to call the real route. + } + + // Otherwise, proxy the request to the real API server. + httputil.NewSingleHostReverseProxy(client.URL).ServeHTTP(w, r) + })) + defer proxy.Close() + + // Create a new client that uses the proxy server. + proxyURL, err := url.Parse(proxy.URL) + require.NoError(t, err) + proxyClient := codersdk.New(proxyURL) + proxyClient.SetSessionToken(client.SessionToken()) + + // Test the cli command. + cmdArgs := []string{ + "templates", + "edit", + template.Name, + "--max-ttl", "1h", + } + inv, root := clitest.New(t, cmdArgs...) + clitest.SetupConfig(t, proxyClient, root) + + ctx := testutil.Context(t, testutil.WaitLong) + err = inv.WithContext(ctx).Run() + require.NoError(t, err) + + require.EqualValues(t, 1, atomic.LoadInt64(&updateTemplateCalled)) + + // Assert that the template metadata did not change. We verify the + // correct request gets sent to the server already. + updated, err := client.Template(context.Background(), template.ID) + require.NoError(t, err) + assert.Equal(t, template.Name, updated.Name) + assert.Equal(t, template.Description, updated.Description) + assert.Equal(t, template.Icon, updated.Icon) + assert.Equal(t, template.DisplayName, updated.DisplayName) + assert.Equal(t, template.DefaultTTLMillis, updated.DefaultTTLMillis) + assert.Equal(t, template.MaxTTLMillis, updated.MaxTTLMillis) + }) + }) t.Run("AllowUserScheduling", func(t *testing.T) { t.Parallel() t.Run("BlockedAGPL", func(t *testing.T) { diff --git a/cli/testdata/coder_templates_edit_--help.golden b/cli/testdata/coder_templates_edit_--help.golden index 486e21e3fc3b0..09c0b7209e78a 100644 --- a/cli/testdata/coder_templates_edit_--help.golden +++ b/cli/testdata/coder_templates_edit_--help.golden @@ -35,19 +35,14 @@ Edit the metadata of a template by name. Specify an inactivity TTL for workspaces created from this template. This licensed feature's default is 0h (off). + --max-ttl duration + Edit the template maximum time before shutdown - workspaces created + from this template must shutdown within the given duration after + starting. This is an enterprise-only feature. + --name string Edit the template name. - --restart-requirement-weekdays string-array - Edit the template restart requirement weekdays - workspaces created - from this template must be restarted on the given weekdays. To unset - this value for the template (and disable the restart requirement for - the template), pass 'none'. - - --restart-requirement-weeks int - Edit the template restart requirement weeks - workspaces created from - this template must be restarted on an n-weekly basis. - -y, --yes bool Bypass prompts. diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 40886200d0aef..d3bcbf26d4432 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -7030,6 +7030,10 @@ const docTemplate = `{ "description": "LockedTTLMillis allows optionally specifying the max lifetime before Coder\npermanently deletes locked workspaces created from this template.", "type": "integer" }, + "max_ttl_ms": { + "description": "TODO(@dean): remove max_ttl once restart_requirement is matured", + "type": "integer" + }, "name": { "description": "Name is the name of the template.", "type": "string" @@ -7710,14 +7714,16 @@ const docTemplate = `{ "workspace_actions", "tailnet_ha_coordinator", "convert-to-oidc", - "workspace_build_logs_ui" + "workspace_build_logs_ui", + "template_restart_requirement" ], "x-enum-varnames": [ "ExperimentMoons", "ExperimentWorkspaceActions", "ExperimentTailnetHACoordinator", "ExperimentConvertToOIDC", - "ExperimentWorkspaceBuildLogsUI" + "ExperimentWorkspaceBuildLogsUI", + "ExperimentTemplateRestartRequirement" ] }, "codersdk.Feature": { @@ -9023,6 +9029,10 @@ const docTemplate = `{ "locked_ttl_ms": { "type": "integer" }, + "max_ttl_ms": { + "description": "TODO(@dean): remove max_ttl once restart_requirement is matured", + "type": "integer" + }, "name": { "type": "string" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index b0c3c9e027acb..84d571185b6e0 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -6263,6 +6263,10 @@ "description": "LockedTTLMillis allows optionally specifying the max lifetime before Coder\npermanently deletes locked workspaces created from this template.", "type": "integer" }, + "max_ttl_ms": { + "description": "TODO(@dean): remove max_ttl once restart_requirement is matured", + "type": "integer" + }, "name": { "description": "Name is the name of the template.", "type": "string" @@ -6901,14 +6905,16 @@ "workspace_actions", "tailnet_ha_coordinator", "convert-to-oidc", - "workspace_build_logs_ui" + "workspace_build_logs_ui", + "template_restart_requirement" ], "x-enum-varnames": [ "ExperimentMoons", "ExperimentWorkspaceActions", "ExperimentTailnetHACoordinator", "ExperimentConvertToOIDC", - "ExperimentWorkspaceBuildLogsUI" + "ExperimentWorkspaceBuildLogsUI", + "ExperimentTemplateRestartRequirement" ] }, "codersdk.Feature": { @@ -8146,6 +8152,10 @@ "locked_ttl_ms": { "type": "integer" }, + "max_ttl_ms": { + "description": "TODO(@dean): remove max_ttl once restart_requirement is matured", + "type": "integer" + }, "name": { "type": "string" }, diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go index d151e14a226f4..51a49a7b3a600 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -4745,6 +4745,7 @@ func (q *FakeQuerier) UpdateTemplateScheduleByID(_ context.Context, arg database tpl.AllowUserAutostop = arg.AllowUserAutostop tpl.UpdatedAt = database.Now() tpl.DefaultTTL = arg.DefaultTTL + tpl.MaxTTL = arg.MaxTTL tpl.RestartRequirementDaysOfWeek = arg.RestartRequirementDaysOfWeek tpl.RestartRequirementWeeks = arg.RestartRequirementWeeks tpl.FailureTTL = arg.FailureTTL diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index fef5469616c66..968e488a8e327 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -561,6 +561,7 @@ CREATE TABLE templates ( group_acl jsonb DEFAULT '{}'::jsonb NOT NULL, display_name character varying(64) DEFAULT ''::character varying NOT NULL, allow_user_cancel_workspace_jobs boolean DEFAULT true NOT NULL, + max_ttl bigint DEFAULT '0'::bigint NOT NULL, allow_user_autostart boolean DEFAULT true NOT NULL, allow_user_autostop boolean DEFAULT true NOT NULL, failure_ttl bigint DEFAULT 0 NOT NULL, diff --git a/coderd/database/migrations/000139_template_restart_requirement.down.sql b/coderd/database/migrations/000139_template_restart_requirement.down.sql index 729133834461c..4a7cb544cbc05 100644 --- a/coderd/database/migrations/000139_template_restart_requirement.down.sql +++ b/coderd/database/migrations/000139_template_restart_requirement.down.sql @@ -1,4 +1,3 @@ ALTER TABLE templates DROP COLUMN restart_requirement_days_of_week, - DROP COLUMN restart_requirement_weeks, - ADD COLUMN max_ttl bigint NOT NULL DEFAULT 0; + DROP COLUMN restart_requirement_weeks; diff --git a/coderd/database/migrations/000139_template_restart_requirement.up.sql b/coderd/database/migrations/000139_template_restart_requirement.up.sql index a9586e4252395..1cafd455f3870 100644 --- a/coderd/database/migrations/000139_template_restart_requirement.up.sql +++ b/coderd/database/migrations/000139_template_restart_requirement.up.sql @@ -1,7 +1,9 @@ BEGIN; ALTER TABLE templates - DROP COLUMN max_ttl, + -- The max_ttl column will be dropped eventually when the new "restart + -- requirement" feature flag is fully rolled out. + -- DROP COLUMN max_ttl, ADD COLUMN restart_requirement_days_of_week smallint NOT NULL DEFAULT 0, ADD COLUMN restart_requirement_weeks bigint NOT NULL DEFAULT 0; diff --git a/coderd/database/models.go b/coderd/database/models.go index fad30bb217381..27d6b3fc97ea4 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1586,7 +1586,8 @@ type Template struct { // Display name is a custom, human-friendly template name that user can set. DisplayName string `db:"display_name" json:"display_name"` // Allow users to cancel in-progress workspace jobs. - AllowUserCancelWorkspaceJobs bool `db:"allow_user_cancel_workspace_jobs" json:"allow_user_cancel_workspace_jobs"` + AllowUserCancelWorkspaceJobs bool `db:"allow_user_cancel_workspace_jobs" json:"allow_user_cancel_workspace_jobs"` + MaxTTL int64 `db:"max_ttl" json:"max_ttl"` // Allow users to specify an autostart schedule for workspaces (enterprise). AllowUserAutostart bool `db:"allow_user_autostart" json:"allow_user_autostart"` // Allow users to specify custom autostop values for workspaces (enterprise). diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 26136fa408d45..5e7804791ce03 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -3638,7 +3638,7 @@ func (q *sqlQuerier) GetTemplateAverageBuildTime(ctx context.Context, arg GetTem const getTemplateByID = `-- name: GetTemplateByID :one SELECT - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl, locked_ttl, restart_requirement_days_of_week, restart_requirement_weeks + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl, locked_ttl, restart_requirement_days_of_week, restart_requirement_weeks FROM templates WHERE @@ -3667,6 +3667,7 @@ func (q *sqlQuerier) GetTemplateByID(ctx context.Context, id uuid.UUID) (Templat &i.GroupACL, &i.DisplayName, &i.AllowUserCancelWorkspaceJobs, + &i.MaxTTL, &i.AllowUserAutostart, &i.AllowUserAutostop, &i.FailureTTL, @@ -3680,7 +3681,7 @@ func (q *sqlQuerier) GetTemplateByID(ctx context.Context, id uuid.UUID) (Templat const getTemplateByOrganizationAndName = `-- name: GetTemplateByOrganizationAndName :one SELECT - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl, locked_ttl, restart_requirement_days_of_week, restart_requirement_weeks + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl, locked_ttl, restart_requirement_days_of_week, restart_requirement_weeks FROM templates WHERE @@ -3717,6 +3718,7 @@ func (q *sqlQuerier) GetTemplateByOrganizationAndName(ctx context.Context, arg G &i.GroupACL, &i.DisplayName, &i.AllowUserCancelWorkspaceJobs, + &i.MaxTTL, &i.AllowUserAutostart, &i.AllowUserAutostop, &i.FailureTTL, @@ -3729,7 +3731,7 @@ func (q *sqlQuerier) GetTemplateByOrganizationAndName(ctx context.Context, arg G } const getTemplates = `-- name: GetTemplates :many -SELECT id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl, locked_ttl, restart_requirement_days_of_week, restart_requirement_weeks FROM templates +SELECT id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl, locked_ttl, restart_requirement_days_of_week, restart_requirement_weeks FROM templates ORDER BY (name, id) ASC ` @@ -3759,6 +3761,7 @@ func (q *sqlQuerier) GetTemplates(ctx context.Context) ([]Template, error) { &i.GroupACL, &i.DisplayName, &i.AllowUserCancelWorkspaceJobs, + &i.MaxTTL, &i.AllowUserAutostart, &i.AllowUserAutostop, &i.FailureTTL, @@ -3782,7 +3785,7 @@ func (q *sqlQuerier) GetTemplates(ctx context.Context) ([]Template, error) { const getTemplatesWithFilter = `-- name: GetTemplatesWithFilter :many SELECT - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl, locked_ttl, restart_requirement_days_of_week, restart_requirement_weeks + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl, locked_ttl, restart_requirement_days_of_week, restart_requirement_weeks FROM templates WHERE @@ -3849,6 +3852,7 @@ func (q *sqlQuerier) GetTemplatesWithFilter(ctx context.Context, arg GetTemplate &i.GroupACL, &i.DisplayName, &i.AllowUserCancelWorkspaceJobs, + &i.MaxTTL, &i.AllowUserAutostart, &i.AllowUserAutostop, &i.FailureTTL, @@ -3889,7 +3893,7 @@ INSERT INTO allow_user_cancel_workspace_jobs ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl, locked_ttl, restart_requirement_days_of_week, restart_requirement_weeks + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl, locked_ttl, restart_requirement_days_of_week, restart_requirement_weeks ` type InsertTemplateParams struct { @@ -3944,6 +3948,7 @@ func (q *sqlQuerier) InsertTemplate(ctx context.Context, arg InsertTemplateParam &i.GroupACL, &i.DisplayName, &i.AllowUserCancelWorkspaceJobs, + &i.MaxTTL, &i.AllowUserAutostart, &i.AllowUserAutostop, &i.FailureTTL, @@ -3964,7 +3969,7 @@ SET WHERE id = $3 RETURNING - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl, locked_ttl, restart_requirement_days_of_week, restart_requirement_weeks + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl, locked_ttl, restart_requirement_days_of_week, restart_requirement_weeks ` type UpdateTemplateACLByIDParams struct { @@ -3993,6 +3998,7 @@ func (q *sqlQuerier) UpdateTemplateACLByID(ctx context.Context, arg UpdateTempla &i.GroupACL, &i.DisplayName, &i.AllowUserCancelWorkspaceJobs, + &i.MaxTTL, &i.AllowUserAutostart, &i.AllowUserAutostop, &i.FailureTTL, @@ -4059,7 +4065,7 @@ SET WHERE id = $1 RETURNING - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl, locked_ttl, restart_requirement_days_of_week, restart_requirement_weeks + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl, locked_ttl, restart_requirement_days_of_week, restart_requirement_weeks ` type UpdateTemplateMetaByIDParams struct { @@ -4100,6 +4106,7 @@ func (q *sqlQuerier) UpdateTemplateMetaByID(ctx context.Context, arg UpdateTempl &i.GroupACL, &i.DisplayName, &i.AllowUserCancelWorkspaceJobs, + &i.MaxTTL, &i.AllowUserAutostart, &i.AllowUserAutostop, &i.FailureTTL, @@ -4119,15 +4126,16 @@ SET allow_user_autostart = $3, allow_user_autostop = $4, default_ttl = $5, - restart_requirement_days_of_week = $6, - restart_requirement_weeks = $7, - failure_ttl = $8, - inactivity_ttl = $9, - locked_ttl = $10 + max_ttl = $6, + restart_requirement_days_of_week = $7, + restart_requirement_weeks = $8, + failure_ttl = $9, + inactivity_ttl = $10, + locked_ttl = $11 WHERE id = $1 RETURNING - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl, locked_ttl, restart_requirement_days_of_week, restart_requirement_weeks + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl, locked_ttl, restart_requirement_days_of_week, restart_requirement_weeks ` type UpdateTemplateScheduleByIDParams struct { @@ -4136,6 +4144,7 @@ type UpdateTemplateScheduleByIDParams struct { AllowUserAutostart bool `db:"allow_user_autostart" json:"allow_user_autostart"` AllowUserAutostop bool `db:"allow_user_autostop" json:"allow_user_autostop"` DefaultTTL int64 `db:"default_ttl" json:"default_ttl"` + MaxTTL int64 `db:"max_ttl" json:"max_ttl"` RestartRequirementDaysOfWeek int16 `db:"restart_requirement_days_of_week" json:"restart_requirement_days_of_week"` RestartRequirementWeeks int64 `db:"restart_requirement_weeks" json:"restart_requirement_weeks"` FailureTTL int64 `db:"failure_ttl" json:"failure_ttl"` @@ -4150,6 +4159,7 @@ func (q *sqlQuerier) UpdateTemplateScheduleByID(ctx context.Context, arg UpdateT arg.AllowUserAutostart, arg.AllowUserAutostop, arg.DefaultTTL, + arg.MaxTTL, arg.RestartRequirementDaysOfWeek, arg.RestartRequirementWeeks, arg.FailureTTL, @@ -4174,6 +4184,7 @@ func (q *sqlQuerier) UpdateTemplateScheduleByID(ctx context.Context, arg UpdateT &i.GroupACL, &i.DisplayName, &i.AllowUserCancelWorkspaceJobs, + &i.MaxTTL, &i.AllowUserAutostart, &i.AllowUserAutostop, &i.FailureTTL, diff --git a/coderd/database/queries/templates.sql b/coderd/database/queries/templates.sql index 735364bde14ea..77e98ea0123d0 100644 --- a/coderd/database/queries/templates.sql +++ b/coderd/database/queries/templates.sql @@ -118,11 +118,12 @@ SET allow_user_autostart = $3, allow_user_autostop = $4, default_ttl = $5, - restart_requirement_days_of_week = $6, - restart_requirement_weeks = $7, - failure_ttl = $8, - inactivity_ttl = $9, - locked_ttl = $10 + max_ttl = $6, + restart_requirement_days_of_week = $7, + restart_requirement_weeks = $8, + failure_ttl = $9, + inactivity_ttl = $10, + locked_ttl = $11 WHERE id = $1 RETURNING diff --git a/coderd/provisionerdserver/provisionerdserver_test.go b/coderd/provisionerdserver/provisionerdserver_test.go index 96024c7903b52..9fdbaa3e3864f 100644 --- a/coderd/provisionerdserver/provisionerdserver_test.go +++ b/coderd/provisionerdserver/provisionerdserver_test.go @@ -901,6 +901,242 @@ func TestCompleteJob(t *testing.T) { require.False(t, job.Error.Valid) }) + // TODO(@dean): remove this legacy test for MaxTTL + t.Run("WorkspaceBuildLegacy", func(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + templateAllowAutostop bool + templateDefaultTTL time.Duration + templateMaxTTL time.Duration + workspaceTTL time.Duration + transition database.WorkspaceTransition + // The TTL is actually a deadline time on the workspace_build row, + // so during the test this will be compared to be within 15 seconds + // of the expected value. + expectedTTL time.Duration + expectedMaxTTL time.Duration + }{ + { + name: "OK", + templateAllowAutostop: true, + templateDefaultTTL: 0, + templateMaxTTL: 0, + workspaceTTL: 0, + transition: database.WorkspaceTransitionStart, + expectedTTL: 0, + expectedMaxTTL: 0, + }, + { + name: "Delete", + templateAllowAutostop: true, + templateDefaultTTL: 0, + templateMaxTTL: 0, + workspaceTTL: 0, + transition: database.WorkspaceTransitionDelete, + expectedTTL: 0, + expectedMaxTTL: 0, + }, + { + name: "WorkspaceTTL", + templateAllowAutostop: true, + templateDefaultTTL: 0, + templateMaxTTL: 0, + workspaceTTL: time.Hour, + transition: database.WorkspaceTransitionStart, + expectedTTL: time.Hour, + expectedMaxTTL: 0, + }, + { + name: "TemplateDefaultTTLIgnored", + templateAllowAutostop: true, + templateDefaultTTL: time.Hour, + templateMaxTTL: 0, + workspaceTTL: 0, + transition: database.WorkspaceTransitionStart, + expectedTTL: 0, + expectedMaxTTL: 0, + }, + { + name: "WorkspaceTTLOverridesTemplateDefaultTTL", + templateAllowAutostop: true, + templateDefaultTTL: 2 * time.Hour, + templateMaxTTL: 0, + workspaceTTL: time.Hour, + transition: database.WorkspaceTransitionStart, + expectedTTL: time.Hour, + expectedMaxTTL: 0, + }, + { + name: "TemplateMaxTTL", + templateAllowAutostop: true, + templateDefaultTTL: 0, + templateMaxTTL: time.Hour, + workspaceTTL: 0, + transition: database.WorkspaceTransitionStart, + expectedTTL: time.Hour, + expectedMaxTTL: time.Hour, + }, + { + name: "TemplateMaxTTLOverridesWorkspaceTTL", + templateAllowAutostop: true, + templateDefaultTTL: 0, + templateMaxTTL: 2 * time.Hour, + workspaceTTL: 3 * time.Hour, + transition: database.WorkspaceTransitionStart, + expectedTTL: 2 * time.Hour, + expectedMaxTTL: 2 * time.Hour, + }, + { + name: "TemplateMaxTTLOverridesTemplateDefaultTTL", + templateAllowAutostop: true, + templateDefaultTTL: 3 * time.Hour, + templateMaxTTL: 2 * time.Hour, + workspaceTTL: 0, + transition: database.WorkspaceTransitionStart, + expectedTTL: 2 * time.Hour, + expectedMaxTTL: 2 * time.Hour, + }, + { + name: "TemplateBlockWorkspaceTTL", + templateAllowAutostop: false, + templateDefaultTTL: 3 * time.Hour, + templateMaxTTL: 6 * time.Hour, + workspaceTTL: 4 * time.Hour, + transition: database.WorkspaceTransitionStart, + expectedTTL: 3 * time.Hour, + expectedMaxTTL: 6 * time.Hour, + }, + } + + for _, c := range cases { + c := c + + t.Run(c.name, func(t *testing.T) { + t.Parallel() + + srv := setup(t, false) + + var store schedule.TemplateScheduleStore = schedule.MockTemplateScheduleStore{ + GetFn: func(_ context.Context, _ database.Store, _ uuid.UUID) (schedule.TemplateScheduleOptions, error) { + return schedule.TemplateScheduleOptions{ + UserAutostartEnabled: false, + UserAutostopEnabled: c.templateAllowAutostop, + DefaultTTL: c.templateDefaultTTL, + MaxTTL: c.templateMaxTTL, + UseRestartRequirement: false, + }, nil + }, + } + srv.TemplateScheduleStore.Store(&store) + + user := dbgen.User(t, srv.Database, database.User{}) + template := dbgen.Template(t, srv.Database, database.Template{ + Name: "template", + Provisioner: database.ProvisionerTypeEcho, + }) + template, err := srv.Database.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{ + ID: template.ID, + UpdatedAt: database.Now(), + AllowUserAutostart: c.templateAllowAutostop, + DefaultTTL: int64(c.templateDefaultTTL), + MaxTTL: int64(c.templateMaxTTL), + }) + require.NoError(t, err) + file := dbgen.File(t, srv.Database, database.File{CreatedBy: user.ID}) + workspaceTTL := sql.NullInt64{} + if c.workspaceTTL != 0 { + workspaceTTL = sql.NullInt64{ + Int64: int64(c.workspaceTTL), + Valid: true, + } + } + workspace := dbgen.Workspace(t, srv.Database, database.Workspace{ + TemplateID: template.ID, + Ttl: workspaceTTL, + }) + version := dbgen.TemplateVersion(t, srv.Database, database.TemplateVersion{ + TemplateID: uuid.NullUUID{ + UUID: template.ID, + Valid: true, + }, + JobID: uuid.New(), + }) + build := dbgen.WorkspaceBuild(t, srv.Database, database.WorkspaceBuild{ + WorkspaceID: workspace.ID, + TemplateVersionID: version.ID, + Transition: c.transition, + Reason: database.BuildReasonInitiator, + }) + job := dbgen.ProvisionerJob(t, srv.Database, database.ProvisionerJob{ + FileID: file.ID, + Type: database.ProvisionerJobTypeWorkspaceBuild, + Input: must(json.Marshal(provisionerdserver.WorkspaceProvisionJob{ + WorkspaceBuildID: build.ID, + })), + }) + _, err = srv.Database.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ + WorkerID: uuid.NullUUID{ + UUID: srv.ID, + Valid: true, + }, + Types: []database.ProvisionerType{database.ProvisionerTypeEcho}, + }) + require.NoError(t, err) + + publishedWorkspace := make(chan struct{}) + closeWorkspaceSubscribe, err := srv.Pubsub.Subscribe(codersdk.WorkspaceNotifyChannel(build.WorkspaceID), func(_ context.Context, _ []byte) { + close(publishedWorkspace) + }) + require.NoError(t, err) + defer closeWorkspaceSubscribe() + publishedLogs := make(chan struct{}) + closeLogsSubscribe, err := srv.Pubsub.Subscribe(provisionersdk.ProvisionerJobLogsNotifyChannel(job.ID), func(_ context.Context, _ []byte) { + close(publishedLogs) + }) + require.NoError(t, err) + defer closeLogsSubscribe() + + _, err = srv.CompleteJob(ctx, &proto.CompletedJob{ + JobId: job.ID.String(), + Type: &proto.CompletedJob_WorkspaceBuild_{ + WorkspaceBuild: &proto.CompletedJob_WorkspaceBuild{ + State: []byte{}, + Resources: []*sdkproto.Resource{{ + Name: "example", + Type: "aws_instance", + }}, + }, + }, + }) + require.NoError(t, err) + + <-publishedWorkspace + <-publishedLogs + + workspace, err = srv.Database.GetWorkspaceByID(ctx, workspace.ID) + require.NoError(t, err) + require.Equal(t, c.transition == database.WorkspaceTransitionDelete, workspace.Deleted) + + workspaceBuild, err := srv.Database.GetWorkspaceBuildByID(ctx, build.ID) + require.NoError(t, err) + + if c.expectedTTL == 0 { + require.True(t, workspaceBuild.Deadline.IsZero()) + } else { + require.WithinDuration(t, time.Now().Add(c.expectedTTL), workspaceBuild.Deadline, 15*time.Second, "deadline does not match expected") + } + if c.expectedMaxTTL == 0 { + require.True(t, workspaceBuild.MaxDeadline.IsZero()) + } else { + require.WithinDuration(t, time.Now().Add(c.expectedMaxTTL), workspaceBuild.MaxDeadline, 15*time.Second, "max deadline does not match expected") + require.GreaterOrEqual(t, workspaceBuild.MaxDeadline.Unix(), workspaceBuild.Deadline.Unix(), "max deadline is smaller than deadline") + } + }) + } + }) + t.Run("WorkspaceBuild", func(t *testing.T) { t.Parallel() @@ -999,10 +1235,11 @@ func TestCompleteJob(t *testing.T) { var templateScheduleStore schedule.TemplateScheduleStore = schedule.MockTemplateScheduleStore{ GetFn: func(_ context.Context, _ database.Store, _ uuid.UUID) (schedule.TemplateScheduleOptions, error) { return schedule.TemplateScheduleOptions{ - UserAutostartEnabled: false, - UserAutostopEnabled: true, - DefaultTTL: 0, - RestartRequirement: c.templateRestartRequirement, + UserAutostartEnabled: false, + UserAutostopEnabled: true, + DefaultTTL: 0, + UseRestartRequirement: true, + RestartRequirement: c.templateRestartRequirement, }, nil }, } diff --git a/coderd/schedule/autostop.go b/coderd/schedule/autostop.go index b43a0d8bb87bf..f2ea6ba774d0a 100644 --- a/coderd/schedule/autostop.go +++ b/coderd/schedule/autostop.go @@ -85,7 +85,15 @@ func CalculateAutostop(ctx context.Context, params CalculateAutostopParams) (Aut } } - if templateSchedule.RestartRequirement.DaysOfWeek != 0 { + // Use the old algorithm for calculating max_deadline if the instance isn't + // configured or entitled to use the new feature flag yet. + // TODO(@dean): remove this once the feature flag is enabled for all + if !templateSchedule.UseRestartRequirement && templateSchedule.MaxTTL > 0 { + autostop.MaxDeadline = now.Add(templateSchedule.MaxTTL) + } + + // TODO(@dean): remove extra conditional + if templateSchedule.UseRestartRequirement && templateSchedule.RestartRequirement.DaysOfWeek != 0 { // The template has a restart requirement, so determine the max deadline // of this workspace build. @@ -185,13 +193,12 @@ func CalculateAutostop(ctx context.Context, params CalculateAutostopParams) (Aut return autostop, xerrors.New("could not find next occurrence of template restart requirement in user quiet hours schedule") } } + } - // If the workspace doesn't have a deadline or the max deadline is - // sooner than the workspace deadline, use the max deadline as the - // actual deadline. - if autostop.Deadline.IsZero() || autostop.MaxDeadline.Before(autostop.Deadline) { - autostop.Deadline = autostop.MaxDeadline - } + // If the workspace doesn't have a deadline or the max deadline is sooner + // than the workspace deadline, use the max deadline as the actual deadline. + if !autostop.MaxDeadline.IsZero() && (autostop.Deadline.IsZero() || autostop.MaxDeadline.Before(autostop.Deadline)) { + autostop.Deadline = autostop.MaxDeadline } if (!autostop.Deadline.IsZero() && autostop.Deadline.Before(now)) || (!autostop.MaxDeadline.IsZero() && autostop.MaxDeadline.Before(now)) { diff --git a/coderd/schedule/autostop_test.go b/coderd/schedule/autostop_test.go index 75b58eead79ab..7f0afb7f4f56a 100644 --- a/coderd/schedule/autostop_test.go +++ b/coderd/schedule/autostop_test.go @@ -41,10 +41,13 @@ func TestCalculateAutoStop(t *testing.T) { t.Log("saturdayMidnightSydney", saturdayMidnightSydney) cases := []struct { - name string - now time.Time - templateAllowAutostop bool - templateDefaultTTL time.Duration + name string + now time.Time + templateAllowAutostop bool + templateDefaultTTL time.Duration + // TODO(@dean): remove max_ttl tests + useMaxTTL bool + templateMaxTTL time.Duration templateRestartRequirement schedule.TemplateRestartRequirement userQuietHoursSchedule string // workspaceTTL is usually copied from the template's TTL when the @@ -265,6 +268,38 @@ func TestCalculateAutoStop(t *testing.T) { workspaceTTL: 0, errContains: "coder server system clock is incorrect", }, + { + name: "RestartRequirementIgnoresMaxTTL", + now: fridayEveningSydney.In(time.UTC), + templateAllowAutostop: false, + templateDefaultTTL: 0, + useMaxTTL: false, + templateMaxTTL: time.Hour, // should be ignored + userQuietHoursSchedule: sydneyQuietHours, + templateRestartRequirement: schedule.TemplateRestartRequirement{ + DaysOfWeek: 0b00100000, // Saturday + Weeks: 0, // weekly + }, + workspaceTTL: 0, + // expectedDeadline is copied from expectedMaxDeadline. + expectedMaxDeadline: saturdayMidnightSydney.In(time.UTC), + }, + { + name: "MaxTTLIgnoresRestartRequirement", + now: fridayEveningSydney.In(time.UTC), + templateAllowAutostop: false, + templateDefaultTTL: 0, + useMaxTTL: true, + templateMaxTTL: time.Hour, // should NOT be ignored + userQuietHoursSchedule: sydneyQuietHours, + templateRestartRequirement: schedule.TemplateRestartRequirement{ + DaysOfWeek: 0b00100000, // Saturday + Weeks: 0, // weekly + }, + workspaceTTL: 0, + // expectedDeadline is copied from expectedMaxDeadline. + expectedMaxDeadline: fridayEveningSydney.Add(time.Hour).In(time.UTC), + }, } for _, c := range cases { @@ -279,10 +314,12 @@ func TestCalculateAutoStop(t *testing.T) { templateScheduleStore := schedule.MockTemplateScheduleStore{ GetFn: func(_ context.Context, _ database.Store, _ uuid.UUID) (schedule.TemplateScheduleOptions, error) { return schedule.TemplateScheduleOptions{ - UserAutostartEnabled: false, - UserAutostopEnabled: c.templateAllowAutostop, - DefaultTTL: c.templateDefaultTTL, - RestartRequirement: c.templateRestartRequirement, + UserAutostartEnabled: false, + UserAutostopEnabled: c.templateAllowAutostop, + DefaultTTL: c.templateDefaultTTL, + MaxTTL: c.templateMaxTTL, + UseRestartRequirement: !c.useMaxTTL, + RestartRequirement: c.templateRestartRequirement, }, nil }, } diff --git a/coderd/schedule/template.go b/coderd/schedule/template.go index e17ade61f5d9c..36c03ac02aa12 100644 --- a/coderd/schedule/template.go +++ b/coderd/schedule/template.go @@ -95,6 +95,14 @@ type TemplateScheduleOptions struct { UserAutostartEnabled bool `json:"user_autostart_enabled"` UserAutostopEnabled bool `json:"user_autostop_enabled"` DefaultTTL time.Duration `json:"default_ttl"` + // TODO(@dean): remove MaxTTL once restart_requirement is matured and the + // default + MaxTTL time.Duration `json:"max_ttl"` + // UseRestartRequirement dictates whether the restart requirement should be + // used instead of MaxTTL. This is governed by the feature flag and + // licensing. + // TODO(@dean): remove this when we remove max_tll + UseRestartRequirement bool // RestartRequirement dictates when the workspace must be restarted. This // used to be handled by MaxTTL. RestartRequirement TemplateRestartRequirement `json:"restart_requirement"` @@ -138,6 +146,8 @@ func (*agplTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.Context DefaultTTL: time.Duration(tpl.DefaultTTL), // Disregard the values in the database, since RestartRequirement, // FailureTTL, InactivityTTL, and LockedTTL are enterprise features. + UseRestartRequirement: false, + MaxTTL: 0, RestartRequirement: TemplateRestartRequirement{ DaysOfWeek: 0, Weeks: 0, @@ -160,6 +170,7 @@ func (*agplTemplateScheduleStore) SetTemplateScheduleOptions(ctx context.Context DefaultTTL: int64(opts.DefaultTTL), // Don't allow changing these settings, but keep the value in the DB (to // avoid clearing settings if the license has an issue). + MaxTTL: tpl.MaxTTL, RestartRequirementDaysOfWeek: tpl.RestartRequirementDaysOfWeek, RestartRequirementWeeks: tpl.RestartRequirementWeeks, AllowUserAutostart: tpl.AllowUserAutostart, diff --git a/coderd/templates.go b/coderd/templates.go index efdc957d0e8ac..8c87f190896a2 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -223,7 +223,9 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque } var ( - defaultTTL time.Duration + defaultTTL time.Duration + // TODO(@dean): remove max_ttl once restart_requirement is ready + maxTTL time.Duration restartRequirementDaysOfWeek []string restartRequirementWeeks int64 failureTTL time.Duration @@ -254,12 +256,21 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque if defaultTTL < 0 { validErrs = append(validErrs, codersdk.ValidationError{Field: "default_ttl_ms", Detail: "Must be a positive integer."}) } + if maxTTL < 0 { + validErrs = append(validErrs, codersdk.ValidationError{Field: "max_ttl_ms", Detail: "Must be a positive integer."}) + } + if maxTTL != 0 && defaultTTL > maxTTL { + validErrs = append(validErrs, codersdk.ValidationError{Field: "default_ttl_ms", Detail: "Must be less than or equal to max_ttl_ms if max_ttl_ms is set."}) + } if len(restartRequirementDaysOfWeek) > 0 { restartRequirementDaysOfWeekParsed, err = codersdk.WeekdaysToBitmap(restartRequirementDaysOfWeek) if err != nil { validErrs = append(validErrs, codersdk.ValidationError{Field: "restart_requirement.days_of_week", Detail: err.Error()}) } } + if createTemplate.MaxTTLMillis != nil { + maxTTL = time.Duration(*createTemplate.MaxTTLMillis) * time.Millisecond + } if restartRequirementWeeks < 0 { validErrs = append(validErrs, codersdk.ValidationError{Field: "restart_requirement.weeks", Detail: "Must be a positive integer."}) } @@ -325,6 +336,7 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque UserAutostartEnabled: allowUserAutostart, UserAutostopEnabled: allowUserAutostop, DefaultTTL: defaultTTL, + MaxTTL: maxTTL, // Some of these values are enterprise-only, but the // TemplateScheduleStore will handle avoiding setting them if // unlicensed. @@ -520,6 +532,12 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { if req.DefaultTTLMillis < 0 { validErrs = append(validErrs, codersdk.ValidationError{Field: "default_ttl_ms", Detail: "Must be a positive integer."}) } + if req.MaxTTLMillis < 0 { + validErrs = append(validErrs, codersdk.ValidationError{Field: "max_ttl_ms", Detail: "Must be a positive integer."}) + } + if req.MaxTTLMillis != 0 && req.DefaultTTLMillis > req.MaxTTLMillis { + validErrs = append(validErrs, codersdk.ValidationError{Field: "default_ttl_ms", Detail: "Must be less than or equal to max_ttl_ms if max_ttl_ms is set."}) + } if req.RestartRequirement == nil { req.RestartRequirement = &codersdk.TemplateRestartRequirement{ DaysOfWeek: codersdk.BitmapToWeekdays(scheduleOpts.RestartRequirement.DaysOfWeek), @@ -569,6 +587,7 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { req.AllowUserAutostop == template.AllowUserAutostop && req.AllowUserCancelWorkspaceJobs == template.AllowUserCancelWorkspaceJobs && req.DefaultTTLMillis == time.Duration(template.DefaultTTL).Milliseconds() && + req.MaxTTLMillis == time.Duration(template.MaxTTL).Milliseconds() && restartRequirementDaysOfWeekParsed == scheduleOpts.RestartRequirement.DaysOfWeek && req.RestartRequirement.Weeks == scheduleOpts.RestartRequirement.Weeks && req.FailureTTLMillis == time.Duration(template.FailureTTL).Milliseconds() && @@ -598,11 +617,13 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { } defaultTTL := time.Duration(req.DefaultTTLMillis) * time.Millisecond + maxTTL := time.Duration(req.MaxTTLMillis) * time.Millisecond failureTTL := time.Duration(req.FailureTTLMillis) * time.Millisecond inactivityTTL := time.Duration(req.InactivityTTLMillis) * time.Millisecond lockedTTL := time.Duration(req.LockedTTLMillis) * time.Millisecond if defaultTTL != time.Duration(template.DefaultTTL) || + maxTTL != time.Duration(template.MaxTTL) || restartRequirementDaysOfWeekParsed != scheduleOpts.RestartRequirement.DaysOfWeek || req.RestartRequirement.Weeks != scheduleOpts.RestartRequirement.Weeks || failureTTL != time.Duration(template.FailureTTL) || @@ -617,6 +638,7 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { UserAutostartEnabled: req.AllowUserAutostart, UserAutostopEnabled: req.AllowUserAutostop, DefaultTTL: defaultTTL, + MaxTTL: maxTTL, RestartRequirement: schedule.TemplateRestartRequirement{ DaysOfWeek: restartRequirementDaysOfWeekParsed, Weeks: req.RestartRequirement.Weeks, @@ -769,6 +791,7 @@ func (api *API) convertTemplate( Description: template.Description, Icon: template.Icon, DefaultTTLMillis: time.Duration(template.DefaultTTL).Milliseconds(), + MaxTTLMillis: time.Duration(template.MaxTTL).Milliseconds(), CreatedByID: template.CreatedBy, CreatedByName: createdByName, AllowUserAutostart: template.AllowUserAutostart, diff --git a/coderd/templates_test.go b/coderd/templates_test.go index 99f4cb9bdd710..7adbeaacf3414 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -266,6 +266,7 @@ func TestPostTemplateByOrganization(t *testing.T) { AllowUserAutostart: options.UserAutostartEnabled, AllowUserAutostop: options.UserAutostopEnabled, DefaultTTL: int64(options.DefaultTTL), + MaxTTL: int64(options.MaxTTL), RestartRequirementDaysOfWeek: int16(options.RestartRequirement.DaysOfWeek), RestartRequirementWeeks: options.RestartRequirement.Weeks, FailureTTL: int64(options.FailureTTL), @@ -310,6 +311,7 @@ func TestPostTemplateByOrganization(t *testing.T) { AllowUserAutostart: options.UserAutostartEnabled, AllowUserAutostop: options.UserAutostopEnabled, DefaultTTL: int64(options.DefaultTTL), + MaxTTL: int64(options.MaxTTL), RestartRequirementDaysOfWeek: int16(options.RestartRequirement.DaysOfWeek), RestartRequirementWeeks: options.RestartRequirement.Weeks, FailureTTL: int64(options.FailureTTL), @@ -557,6 +559,125 @@ func TestPatchTemplateMeta(t *testing.T) { assert.Equal(t, updated.DefaultTTLMillis, template.DefaultTTLMillis) }) + t.Run("MaxTTL", func(t *testing.T) { + t.Parallel() + + const ( + defaultTTL = 1 * time.Hour + maxTTL = 24 * time.Hour + ) + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + var setCalled int64 + client := coderdtest.New(t, &coderdtest.Options{ + TemplateScheduleStore: schedule.MockTemplateScheduleStore{ + SetFn: func(ctx context.Context, db database.Store, template database.Template, options schedule.TemplateScheduleOptions) (database.Template, error) { + if atomic.AddInt64(&setCalled, 1) == 2 { + require.Equal(t, maxTTL, options.MaxTTL) + } + + return db.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{ + ID: template.ID, + UpdatedAt: database.Now(), + AllowUserAutostart: options.UserAutostartEnabled, + AllowUserAutostop: options.UserAutostopEnabled, + DefaultTTL: int64(options.DefaultTTL), + MaxTTL: int64(options.MaxTTL), + RestartRequirementDaysOfWeek: int16(options.RestartRequirement.DaysOfWeek), + RestartRequirementWeeks: options.RestartRequirement.Weeks, + FailureTTL: int64(options.FailureTTL), + InactivityTTL: int64(options.InactivityTTL), + LockedTTL: int64(options.LockedTTL), + }) + }, + }, + }) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { + ctr.DefaultTTLMillis = ptr.Ref(24 * time.Hour.Milliseconds()) + }) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + got, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ + Name: template.Name, + DisplayName: template.DisplayName, + Description: template.Description, + Icon: template.Icon, + DefaultTTLMillis: 0, + MaxTTLMillis: maxTTL.Milliseconds(), + AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, + }) + require.NoError(t, err) + + require.EqualValues(t, 2, atomic.LoadInt64(&setCalled)) + require.EqualValues(t, 0, got.DefaultTTLMillis) + require.Equal(t, maxTTL.Milliseconds(), got.MaxTTLMillis) + }) + + t.Run("DefaultTTLBigger", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { + ctr.DefaultTTLMillis = ptr.Ref(24 * time.Hour.Milliseconds()) + }) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + _, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ + Name: template.Name, + DisplayName: template.DisplayName, + Description: template.Description, + Icon: template.Icon, + DefaultTTLMillis: (maxTTL * 2).Milliseconds(), + MaxTTLMillis: maxTTL.Milliseconds(), + AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, + }) + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + require.Len(t, sdkErr.Validations, 1) + require.Equal(t, "default_ttl_ms", sdkErr.Validations[0].Field) + require.Contains(t, sdkErr.Validations[0].Detail, "Must be less than or equal to max_ttl_ms") + }) + + t.Run("IgnoredUnlicensed", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { + ctr.DefaultTTLMillis = ptr.Ref(24 * time.Hour.Milliseconds()) + }) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + got, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ + Name: template.Name, + DisplayName: template.DisplayName, + Description: template.Description, + Icon: template.Icon, + DefaultTTLMillis: defaultTTL.Milliseconds(), + MaxTTLMillis: maxTTL.Milliseconds(), + AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, + }) + require.NoError(t, err) + require.Equal(t, defaultTTL.Milliseconds(), got.DefaultTTLMillis) + require.Zero(t, got.MaxTTLMillis) + }) + }) + t.Run("CleanupTTLs", func(t *testing.T) { t.Parallel() @@ -849,6 +970,7 @@ func TestPatchTemplateMeta(t *testing.T) { AllowUserAutostart: options.UserAutostartEnabled, AllowUserAutostop: options.UserAutostopEnabled, DefaultTTL: int64(options.DefaultTTL), + MaxTTL: int64(options.MaxTTL), RestartRequirementDaysOfWeek: int16(options.RestartRequirement.DaysOfWeek), RestartRequirementWeeks: options.RestartRequirement.Weeks, FailureTTL: int64(options.FailureTTL), @@ -912,6 +1034,7 @@ func TestPatchTemplateMeta(t *testing.T) { AllowUserAutostart: options.UserAutostartEnabled, AllowUserAutostop: options.UserAutostopEnabled, DefaultTTL: int64(options.DefaultTTL), + MaxTTL: int64(options.MaxTTL), RestartRequirementDaysOfWeek: int16(options.RestartRequirement.DaysOfWeek), RestartRequirementWeeks: options.RestartRequirement.Weeks, FailureTTL: int64(options.FailureTTL), diff --git a/coderd/workspaces.go b/coderd/workspaces.go index b04d1359d1925..0827f21ed0b38 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -31,11 +31,11 @@ import ( ) var ( - ttlMin = time.Minute //nolint:revive // min here means 'minimum' not 'minutes' - ttlMax = 7 * 24 * time.Hour + ttlMin = time.Minute //nolint:revive // min here means 'minimum' not 'minutes' + ttlMax = 4 * 7 * 24 * time.Hour // 4 weeks errTTLMin = xerrors.New("time until shutdown must be at least one minute") - errTTLMax = xerrors.New("time until shutdown must be less than 7 days") + errTTLMax = xerrors.New("time until shutdown must be less than 28 days") errDeadlineTooSoon = xerrors.New("new deadline must be at least 30 minutes in the future") errDeadlineBeforeStart = xerrors.New("new deadline must be before workspace start time") ) @@ -378,7 +378,13 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req return } - dbTTL, err := validWorkspaceTTLMillis(createWorkspace.TTLMillis, templateSchedule.DefaultTTL) + maxTTL := templateSchedule.MaxTTL + if templateSchedule.UseRestartRequirement { + // If we're using restart requirements, there isn't a max TTL. + maxTTL = 0 + } + + dbTTL, err := validWorkspaceTTLMillis(createWorkspace.TTLMillis, templateSchedule.DefaultTTL, maxTTL) if err != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Invalid Workspace Time to Shutdown.", @@ -709,10 +715,16 @@ func (api *API) putWorkspaceTTL(rw http.ResponseWriter, r *http.Request) { return codersdk.ValidationError{Field: "ttl_ms", Detail: "Custom autostop TTL is not allowed for workspaces using this template."} } + maxTTL := templateSchedule.MaxTTL + if templateSchedule.UseRestartRequirement { + // If we're using restart requirements, there isn't a max TTL. + maxTTL = 0 + } + // don't override 0 ttl with template default here because it indicates // disabled autostop var validityErr error - dbTTL, validityErr = validWorkspaceTTLMillis(req.TTLMillis, 0) + dbTTL, validityErr = validWorkspaceTTLMillis(req.TTLMillis, 0, maxTTL) if validityErr != nil { return codersdk.ValidationError{Field: "ttl_ms", Detail: validityErr.Error()} } @@ -1170,9 +1182,20 @@ func calculateDeletingAt(workspace database.Workspace, template database.Templat return ptr.Ref(workspace.LastUsedAt.Add(time.Duration(template.InactivityTTL) * time.Nanosecond)) } -func validWorkspaceTTLMillis(millis *int64, templateDefault time.Duration) (sql.NullInt64, error) { +func validWorkspaceTTLMillis(millis *int64, templateDefault, templateMax time.Duration) (sql.NullInt64, error) { + if templateDefault == 0 && templateMax != 0 || (templateMax > 0 && templateDefault > templateMax) { + templateDefault = templateMax + } + if ptr.NilOrZero(millis) { if templateDefault == 0 { + if templateMax > 0 { + return sql.NullInt64{ + Int64: int64(templateMax), + Valid: true, + }, nil + } + return sql.NullInt64{}, nil } @@ -1192,6 +1215,10 @@ func validWorkspaceTTLMillis(millis *int64, templateDefault time.Duration) (sql. return sql.NullInt64{}, errTTLMax } + if templateMax > 0 && truncated > templateMax { + return sql.NullInt64{}, xerrors.Errorf("time until shutdown must be less than or equal to the template's maximum TTL %q", templateMax.String()) + } + return sql.NullInt64{ Valid: true, Int64: int64(truncated), diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 6687fd83a3979..6a0415d11e64b 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -1837,13 +1837,13 @@ func TestWorkspaceUpdateTTL(t *testing.T) { }, { name: "maximum ttl", - ttlMillis: ptr.Ref((24 * 7 * time.Hour).Milliseconds()), + ttlMillis: ptr.Ref((4 * 24 * 7 * time.Hour).Milliseconds()), expectedError: "", }, { name: "above maximum ttl", - ttlMillis: ptr.Ref((24*7*time.Hour + time.Minute).Milliseconds()), - expectedError: "time until shutdown must be less than 7 days", + ttlMillis: ptr.Ref((4*24*7*time.Hour + time.Minute).Milliseconds()), + expectedError: "time until shutdown must be less than 28 days", }, } diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 25dc5435b51b0..15daacefd1309 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -45,6 +45,7 @@ const ( FeatureExternalProvisionerDaemons FeatureName = "external_provisioner_daemons" FeatureAppearance FeatureName = "appearance" FeatureAdvancedTemplateScheduling FeatureName = "advanced_template_scheduling" + FeatureTemplateRestartRequirement FeatureName = "template_restart_requirement" FeatureWorkspaceProxy FeatureName = "workspace_proxy" ) @@ -1796,6 +1797,19 @@ const ( ExperimentConvertToOIDC Experiment = "convert-to-oidc" ExperimentWorkspaceBuildLogsUI Experiment = "workspace_build_logs_ui" + + // ExperimentTemplateRestartRequirement allows template admins to have more + // control over when workspaces created on a template are required to + // restart, and allows users to ensure these restarts never happen during + // their business hours. + // + // Enables: + // - User quiet hours schedule settings + // - Template restart requirement settings + // - Changes the max_deadline algorithm to use restart requirement and user + // quiet hours instead of max_ttl. + ExperimentTemplateRestartRequirement Experiment = "template_restart_requirement" + // Add new experiments here! // ExperimentExample Experiment = "example" ) diff --git a/codersdk/organizations.go b/codersdk/organizations.go index 9571e8a6b4b5b..26290fd4f4761 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -84,6 +84,8 @@ type CreateTemplateRequest struct { // DefaultTTLMillis allows optionally specifying the default TTL // for all workspaces created from this template. DefaultTTLMillis *int64 `json:"default_ttl_ms,omitempty"` + // TODO(@dean): remove max_ttl once restart_requirement is matured + MaxTTLMillis *int64 `json:"max_ttl_ms,omitempty"` // RestartRequirement allows optionally specifying the restart requirement // for workspaces created from this template. This is an enterprise feature. RestartRequirement *TemplateRestartRequirement `json:"restart_requirement,omitempty"` diff --git a/codersdk/templates.go b/codersdk/templates.go index 4596a588ca558..344cb6f0caf23 100644 --- a/codersdk/templates.go +++ b/codersdk/templates.go @@ -29,6 +29,8 @@ type Template struct { Description string `json:"description"` Icon string `json:"icon"` DefaultTTLMillis int64 `json:"default_ttl_ms"` + // TODO(@dean): remove max_ttl once restart_requirement is matured + MaxTTLMillis int64 `json:"max_ttl_ms,omitempty"` // RestartRequirement is an enterprise feature. Its value is only used if // your license is entitled to use the advanced template scheduling feature. RestartRequirement TemplateRestartRequirement `json:"restart_requirement"` @@ -171,6 +173,8 @@ type UpdateTemplateMeta struct { Description string `json:"description,omitempty"` Icon string `json:"icon,omitempty"` DefaultTTLMillis int64 `json:"default_ttl_ms,omitempty"` + // TODO(@dean): remove max_ttl once restart_requirement is matured + MaxTTLMillis int64 `json:"max_ttl_ms,omitempty"` // RestartRequirement can only be set if your license includes the advanced // template scheduling feature. If you attempt to set this value while // unlicensed, it will be ignored. diff --git a/docs/admin/audit-logs.md b/docs/admin/audit-logs.md index 3f3bf040cf568..81451c94760aa 100644 --- a/docs/admin/audit-logs.md +++ b/docs/admin/audit-logs.md @@ -9,19 +9,19 @@ We track the following resources: -| Resource | | -| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| APIKey
login, logout, register, create, delete |
FieldTracked
created_attrue
expires_attrue
hashed_secretfalse
idfalse
ip_addressfalse
last_usedtrue
lifetime_secondsfalse
login_typefalse
scopefalse
token_namefalse
updated_atfalse
user_idtrue
| -| AuditOAuthConvertState
|
FieldTracked
created_attrue
expires_attrue
from_login_typetrue
to_login_typetrue
user_idtrue
| -| Group
create, write, delete |
FieldTracked
avatar_urltrue
idtrue
memberstrue
nametrue
organization_idfalse
quota_allowancetrue
| -| GitSSHKey
create |
FieldTracked
created_atfalse
private_keytrue
public_keytrue
updated_atfalse
user_idtrue
| -| License
create, delete |
FieldTracked
exptrue
idfalse
jwtfalse
uploaded_attrue
uuidtrue
| -| Template
write, delete |
FieldTracked
active_version_idtrue
allow_user_autostarttrue
allow_user_autostoptrue
allow_user_cancel_workspace_jobstrue
created_atfalse
created_bytrue
default_ttltrue
deletedfalse
descriptiontrue
display_nametrue
failure_ttltrue
group_acltrue
icontrue
idtrue
inactivity_ttltrue
locked_ttltrue
nametrue
organization_idfalse
provisionertrue
restart_requirement_days_of_weektrue
restart_requirement_weekstrue
updated_atfalse
user_acltrue
| -| TemplateVersion
create, write |
FieldTracked
created_atfalse
created_bytrue
git_auth_providersfalse
idtrue
job_idfalse
messagefalse
nametrue
organization_idfalse
readmetrue
template_idtrue
updated_atfalse
| -| User
create, write, delete |
FieldTracked
avatar_urlfalse
created_atfalse
deletedtrue
emailtrue
hashed_passwordtrue
idtrue
last_seen_atfalse
login_typetrue
quiet_hours_scheduletrue
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| -| Workspace
create, write, delete |
FieldTracked
autostart_scheduletrue
created_atfalse
deletedfalse
idtrue
last_used_atfalse
locked_attrue
nametrue
organization_idfalse
owner_idtrue
template_idtrue
ttltrue
updated_atfalse
| -| WorkspaceBuild
start, stop |
FieldTracked
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
idfalse
initiator_idfalse
job_idfalse
max_deadlinefalse
provisioner_statefalse
reasonfalse
template_version_idtrue
transitionfalse
updated_atfalse
workspace_idfalse
| -| WorkspaceProxy
|
FieldTracked
created_attrue
deletedfalse
display_nametrue
icontrue
idtrue
nametrue
token_hashed_secrettrue
updated_atfalse
urltrue
wildcard_hostnametrue
| +| Resource | | +| -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| APIKey
login, logout, register, create, delete |
FieldTracked
created_attrue
expires_attrue
hashed_secretfalse
idfalse
ip_addressfalse
last_usedtrue
lifetime_secondsfalse
login_typefalse
scopefalse
token_namefalse
updated_atfalse
user_idtrue
| +| AuditOAuthConvertState
|
FieldTracked
created_attrue
expires_attrue
from_login_typetrue
to_login_typetrue
user_idtrue
| +| Group
create, write, delete |
FieldTracked
avatar_urltrue
idtrue
memberstrue
nametrue
organization_idfalse
quota_allowancetrue
| +| GitSSHKey
create |
FieldTracked
created_atfalse
private_keytrue
public_keytrue
updated_atfalse
user_idtrue
| +| License
create, delete |
FieldTracked
exptrue
idfalse
jwtfalse
uploaded_attrue
uuidtrue
| +| Template
write, delete |
FieldTracked
active_version_idtrue
allow_user_autostarttrue
allow_user_autostoptrue
allow_user_cancel_workspace_jobstrue
created_atfalse
created_bytrue
default_ttltrue
deletedfalse
descriptiontrue
display_nametrue
failure_ttltrue
group_acltrue
icontrue
idtrue
inactivity_ttltrue
locked_ttltrue
max_ttltrue
nametrue
organization_idfalse
provisionertrue
restart_requirement_days_of_weektrue
restart_requirement_weekstrue
updated_atfalse
user_acltrue
| +| TemplateVersion
create, write |
FieldTracked
created_atfalse
created_bytrue
git_auth_providersfalse
idtrue
job_idfalse
messagefalse
nametrue
organization_idfalse
readmetrue
template_idtrue
updated_atfalse
| +| User
create, write, delete |
FieldTracked
avatar_urlfalse
created_atfalse
deletedtrue
emailtrue
hashed_passwordtrue
idtrue
last_seen_atfalse
login_typetrue
quiet_hours_scheduletrue
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| +| Workspace
create, write, delete |
FieldTracked
autostart_scheduletrue
created_atfalse
deletedfalse
idtrue
last_used_atfalse
locked_attrue
nametrue
organization_idfalse
owner_idtrue
template_idtrue
ttltrue
updated_atfalse
| +| WorkspaceBuild
start, stop |
FieldTracked
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
idfalse
initiator_idfalse
job_idfalse
max_deadlinefalse
provisioner_statefalse
reasonfalse
template_version_idtrue
transitionfalse
updated_atfalse
workspace_idfalse
| +| WorkspaceProxy
|
FieldTracked
created_attrue
deletedfalse
display_nametrue
icontrue
idtrue
nametrue
token_hashed_secrettrue
updated_atfalse
urltrue
wildcard_hostnametrue
| diff --git a/docs/api/schemas.md b/docs/api/schemas.md index d486643f97c4e..217e39dc01a60 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -1378,6 +1378,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "icon": "string", "inactivity_ttl_ms": 0, "locked_ttl_ms": 0, + "max_ttl_ms": 0, "name": "string", "restart_requirement": { "days_of_week": ["monday"], @@ -1402,6 +1403,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `icon` | string | false | | Icon is a relative path or external URL that specifies an icon to be displayed in the dashboard. | | `inactivity_ttl_ms` | integer | false | | Inactivity ttl ms allows optionally specifying the max lifetime before Coder locks inactive workspaces created from this template. | | `locked_ttl_ms` | integer | false | | Locked ttl ms allows optionally specifying the max lifetime before Coder permanently deletes locked workspaces created from this template. | +| `max_ttl_ms` | integer | false | | Max ttl ms remove max_ttl once restart_requirement is matured | | `name` | string | true | | Name is the name of the template. | | `restart_requirement` | [codersdk.TemplateRestartRequirement](#codersdktemplaterestartrequirement) | false | | Restart requirement allows optionally specifying the restart requirement for workspaces created from this template. This is an enterprise feature. | | `template_version_id` | string | true | | Template version ID is an in-progress or completed job to use as an initial version of the template. | @@ -2549,13 +2551,14 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in #### Enumerated Values -| Value | -| ------------------------- | -| `moons` | -| `workspace_actions` | -| `tailnet_ha_coordinator` | -| `convert-to-oidc` | -| `workspace_build_logs_ui` | +| Value | +| ------------------------------ | +| `moons` | +| `workspace_actions` | +| `tailnet_ha_coordinator` | +| `convert-to-oidc` | +| `workspace_build_logs_ui` | +| `template_restart_requirement` | ## codersdk.Feature @@ -3977,6 +3980,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "inactivity_ttl_ms": 0, "locked_ttl_ms": 0, + "max_ttl_ms": 0, "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "provisioner": "terraform", @@ -4009,6 +4013,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `id` | string | false | | | | `inactivity_ttl_ms` | integer | false | | | | `locked_ttl_ms` | integer | false | | | +| `max_ttl_ms` | integer | false | | Max ttl ms remove max_ttl once restart_requirement is matured | | `name` | string | false | | | | `organization_id` | string | false | | | | `provisioner` | string | false | | | diff --git a/docs/api/templates.md b/docs/api/templates.md index 0afb39b0ab416..6dc1fc6af0dda 100644 --- a/docs/api/templates.md +++ b/docs/api/templates.md @@ -52,6 +52,7 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/templat "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "inactivity_ttl_ms": 0, "locked_ttl_ms": 0, + "max_ttl_ms": 0, "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "provisioner": "terraform", @@ -97,6 +98,7 @@ Status Code **200** | `» id` | string(uuid) | false | | | | `» inactivity_ttl_ms` | integer | false | | | | `» locked_ttl_ms` | integer | false | | | +| `» max_ttl_ms` | integer | false | | Max ttl ms remove max_ttl once restart_requirement is matured | | `» name` | string | false | | | | `» organization_id` | string(uuid) | false | | | | `» provisioner` | string | false | | | @@ -143,6 +145,7 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/templa "icon": "string", "inactivity_ttl_ms": 0, "locked_ttl_ms": 0, + "max_ttl_ms": 0, "name": "string", "restart_requirement": { "days_of_week": ["monday"], @@ -191,6 +194,7 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/templa "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "inactivity_ttl_ms": 0, "locked_ttl_ms": 0, + "max_ttl_ms": 0, "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "provisioner": "terraform", @@ -322,6 +326,7 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/templat "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "inactivity_ttl_ms": 0, "locked_ttl_ms": 0, + "max_ttl_ms": 0, "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "provisioner": "terraform", @@ -659,6 +664,7 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template} \ "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "inactivity_ttl_ms": 0, "locked_ttl_ms": 0, + "max_ttl_ms": 0, "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "provisioner": "terraform", @@ -773,6 +779,7 @@ curl -X PATCH http://coder-server:8080/api/v2/templates/{template} \ "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "inactivity_ttl_ms": 0, "locked_ttl_ms": 0, + "max_ttl_ms": 0, "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "provisioner": "terraform", diff --git a/docs/cli/templates_edit.md b/docs/cli/templates_edit.md index 8ed2f07805e03..2d25da15b7cc1 100644 --- a/docs/cli/templates_edit.md +++ b/docs/cli/templates_edit.md @@ -89,6 +89,14 @@ Edit the template icon path. Specify an inactivity TTL for workspaces created from this template. This licensed feature's default is 0h (off). +### --max-ttl + +| | | +| ---- | --------------------- | +| Type | duration | + +Edit the template maximum time before shutdown - workspaces created from this template must shutdown within the given duration after starting. This is an enterprise-only feature. + ### --name | | | @@ -97,22 +105,6 @@ Specify an inactivity TTL for workspaces created from this template. This licens Edit the template name. -### --restart-requirement-weekdays - -| | | -| ---- | ------------------------- | -| Type | string-array | - -Edit the template restart requirement weekdays - workspaces created from this template must be restarted on the given weekdays. To unset this value for the template (and disable the restart requirement for the template), pass 'none'. - -### --restart-requirement-weeks - -| | | -| ---- | ---------------- | -| Type | int | - -Edit the template restart requirement weeks - workspaces created from this template must be restarted on an n-weekly basis. - ### -y, --yes | | | diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index 718e4ddc25ed0..e54d33de485e5 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -70,6 +70,7 @@ 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, diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 569a693dcfb9c..32d281150214b 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -394,7 +394,10 @@ func (api *API) updateEntitlements(ctx context.Context) error { codersdk.FeatureMultipleGitAuth: len(api.GitAuthConfigs) > 1, codersdk.FeatureTemplateRBAC: api.RBAC, codersdk.FeatureExternalProvisionerDaemons: true, - codersdk.FeatureAdvancedTemplateScheduling: api.DefaultQuietHoursSchedule != "", + codersdk.FeatureAdvancedTemplateScheduling: true, + // FeatureTemplateRestartRequirement depends on + // FeatureAdvancedTemplateScheduling. + codersdk.FeatureTemplateRestartRequirement: api.DefaultQuietHoursSchedule != "", codersdk.FeatureWorkspaceProxy: true, }) if err != nil { @@ -414,6 +417,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) (changed bool, enabled bool) { if api.entitlements.Features == nil { return true, entitlements.Features[featureName].Enabled @@ -455,8 +470,27 @@ func (api *API) updateEntitlements(ctx context.Context) error { if changed, enabled := featureChanged(codersdk.FeatureAdvancedTemplateScheduling); changed { if enabled { templateStore := schedule.NewEnterpriseTemplateScheduleStore() + templateStoreInterface := agplschedule.TemplateScheduleStore(templateStore) + api.AGPL.TemplateScheduleStore.Store(&templateStoreInterface) + + } else { + templateStore := agplschedule.NewAGPLTemplateScheduleStore() api.AGPL.TemplateScheduleStore.Store(&templateStore) + quietHoursStore := agplschedule.NewAGPLUserQuietHoursScheduleStore() + api.AGPL.UserQuietHoursScheduleStore.Store(&quietHoursStore) + } + } + + if changed, enabled := featureChanged(codersdk.FeatureTemplateRestartRequirement); changed { + 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, api.QuietHoursWindowDuration) 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)) @@ -464,8 +498,11 @@ func (api *API) updateEntitlements(ctx context.Context) error { api.AGPL.UserQuietHoursScheduleStore.Store(&quietHoursStore) } } else { - templateStore := agplschedule.NewAGPLTemplateScheduleStore() - api.AGPL.TemplateScheduleStore.Store(&templateStore) + templateStore := *(api.AGPL.TemplateScheduleStore.Load()) + enterpriseTemplateStore, ok := templateStore.(*schedule.EnterpriseTemplateScheduleStore) + if ok { + enterpriseTemplateStore.UseRestartRequirement.Store(false) + } quietHoursStore := agplschedule.NewAGPLUserQuietHoursScheduleStore() api.AGPL.UserQuietHoursScheduleStore.Store(&quietHoursStore) diff --git a/enterprise/coderd/schedule/template.go b/enterprise/coderd/schedule/template.go index d0e1fad13ed13..c84cf5872eaa3 100644 --- a/enterprise/coderd/schedule/template.go +++ b/enterprise/coderd/schedule/template.go @@ -2,6 +2,7 @@ package schedule import ( "context" + "sync/atomic" "time" "github.com/google/uuid" @@ -13,16 +14,22 @@ import ( // EnterpriseTemplateScheduleStore provides an agpl.TemplateScheduleStore that // has all fields implemented for enterprise customers. -type EnterpriseTemplateScheduleStore struct{} +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() agpl.TemplateScheduleStore { +func NewEnterpriseTemplateScheduleStore() *EnterpriseTemplateScheduleStore { return &EnterpriseTemplateScheduleStore{} } // GetTemplateScheduleOptions implements agpl.TemplateScheduleStore. -func (*EnterpriseTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.Context, db database.Store, templateID uuid.UUID) (agpl.TemplateScheduleOptions, error) { +func (s *EnterpriseTemplateScheduleStore) GetTemplateScheduleOptions(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 @@ -42,9 +49,11 @@ func (*EnterpriseTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.C } return agpl.TemplateScheduleOptions{ - UserAutostartEnabled: tpl.AllowUserAutostart, - UserAutostopEnabled: tpl.AllowUserAutostop, - DefaultTTL: time.Duration(tpl.DefaultTTL), + 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, @@ -58,6 +67,7 @@ func (*EnterpriseTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.C // SetTemplateScheduleOptions implements agpl.TemplateScheduleStore. func (*EnterpriseTemplateScheduleStore) SetTemplateScheduleOptions(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 && @@ -80,6 +90,7 @@ func (*EnterpriseTemplateScheduleStore) SetTemplateScheduleOptions(ctx context.C 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), diff --git a/enterprise/coderd/templates_test.go b/enterprise/coderd/templates_test.go index 669478c38f15b..ec5033d4f690e 100644 --- a/enterprise/coderd/templates_test.go +++ b/enterprise/coderd/templates_test.go @@ -25,6 +25,121 @@ import ( func TestTemplates(t *testing.T) { t.Parallel() + // TODO(@dean): remove legacy max_ttl tests + t.Run("CreateUpdateWorkspaceMaxTTL", 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) + exp := 24 * time.Hour.Milliseconds() + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { + ctr.DefaultTTLMillis = &exp + ctr.MaxTTLMillis = &exp + }) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + // No TTL provided should use template default + req := codersdk.CreateWorkspaceRequest{ + TemplateID: template.ID, + Name: "testing", + } + 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 + // should error + exp = exp + time.Minute.Milliseconds() + err = client.UpdateWorkspaceTTL(ctx, ws.ID, codersdk.UpdateWorkspaceTTLRequest{ + TTLMillis: &exp, + }) + require.Error(t, err) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) + require.Len(t, apiErr.Validations, 1) + require.Equal(t, apiErr.Validations[0].Field, "ttl_ms") + require.Contains(t, apiErr.Validations[0].Detail, "time until shutdown must be less than or equal to the template's maximum TTL") + + // Creating workspace with TTL higher than max should error + req.Name = "testing2" + req.TTLMillis = &exp + ws, err = client.CreateWorkspace(ctx, template.OrganizationID, codersdk.Me, req) + require.Error(t, err) + apiErr = nil + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) + require.Len(t, apiErr.Validations, 1) + require.Equal(t, apiErr.Validations[0].Field, "ttl_ms") + require.Contains(t, apiErr.Validations[0].Detail, "time until shutdown must be less than or equal to the template's maximum TTL") + }) + + t.Run("BlockDisablingAutoOffWithMaxTTL", 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) + exp := 24 * time.Hour.Milliseconds() + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { + ctr.MaxTTLMillis = &exp + }) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + // No TTL provided should use template default + req := codersdk.CreateWorkspaceRequest{ + TemplateID: template.ID, + Name: "testing", + } + 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 + err = client.UpdateWorkspaceTTL(ctx, ws.ID, codersdk.UpdateWorkspaceTTLRequest{ + TTLMillis: nil, + }) + require.NoError(t, err) + ws, err = client.Workspace(ctx, ws.ID) + require.NoError(t, err) + require.EqualValues(t, exp, *ws.TTLMillis) + + // Editing a workspace to have a TTL of 0 should do nothing + zero := int64(0) + err = client.UpdateWorkspaceTTL(ctx, ws.ID, codersdk.UpdateWorkspaceTTLRequest{ + TTLMillis: &zero, + }) + require.NoError(t, err) + ws, err = client.Workspace(ctx, ws.ID) + require.NoError(t, err) + require.EqualValues(t, exp, *ws.TTLMillis) + }) + t.Run("SetRestartRequirement", func(t *testing.T) { t.Parallel() diff --git a/enterprise/coderd/users_test.go b/enterprise/coderd/users_test.go index b2d4d5aeeff52..aecbea4703e26 100644 --- a/enterprise/coderd/users_test.go +++ b/enterprise/coderd/users_test.go @@ -40,6 +40,7 @@ func TestUserQuietHours(t *testing.T) { LicenseOptions: &coderdenttest.LicenseOptions{ Features: license.Features{ codersdk.FeatureAdvancedTemplateScheduling: 1, + codersdk.FeatureTemplateRestartRequirement: 1, }, }, }) diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 75494f8800dae..49c9e4f4dd584 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -180,6 +180,7 @@ export interface CreateTemplateRequest { readonly icon?: string readonly template_version_id: string readonly default_ttl_ms?: number + readonly max_ttl_ms?: number readonly restart_requirement?: TemplateRestartRequirement readonly allow_user_cancel_workspace_jobs?: boolean readonly allow_user_autostart?: boolean @@ -865,6 +866,7 @@ export interface Template { readonly description: string readonly icon: string readonly default_ttl_ms: number + readonly max_ttl_ms?: number readonly restart_requirement: TemplateRestartRequirement readonly created_by_id: string readonly created_by_name: string @@ -1042,6 +1044,7 @@ export interface UpdateTemplateMeta { readonly description?: string readonly icon?: string readonly default_ttl_ms?: number + readonly max_ttl_ms?: number readonly restart_requirement?: TemplateRestartRequirement readonly allow_user_autostart?: boolean readonly allow_user_autostop?: boolean @@ -1461,12 +1464,14 @@ export type Experiment = | "convert-to-oidc" | "moons" | "tailnet_ha_coordinator" + | "template_restart_requirement" | "workspace_actions" | "workspace_build_logs_ui" export const Experiments: Experiment[] = [ "convert-to-oidc", "moons", "tailnet_ha_coordinator", + "template_restart_requirement", "workspace_actions", "workspace_build_logs_ui", ] @@ -1482,6 +1487,7 @@ export type FeatureName = | "multiple_git_auth" | "scim" | "template_rbac" + | "template_restart_requirement" | "user_limit" | "workspace_proxy" export const FeatureNames: FeatureName[] = [ @@ -1494,6 +1500,7 @@ export const FeatureNames: FeatureName[] = [ "multiple_git_auth", "scim", "template_rbac", + "template_restart_requirement", "user_limit", "workspace_proxy", ] From cb9428e53da0fb92afce5972c13056ebafd040a9 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Wed, 12 Jul 2023 15:38:20 +0000 Subject: [PATCH 14/25] fixup! add back max_ttl and put restart_requirement behind feature flag --- coderd/database/modelqueries.go | 3 +++ codersdk/templates.go | 2 +- site/src/api/typesGenerated.ts | 2 +- .../TemplateSettingsPage.test.tsx | 4 ++++ .../TemplateScheduleForm/TemplateScheduleForm.tsx | 5 +++++ 5 files changed, 14 insertions(+), 2 deletions(-) diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 9b06bcaadc68f..a511b7b3375c8 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -71,6 +71,7 @@ func (q *sqlQuerier) GetAuthorizedTemplates(ctx context.Context, arg GetTemplate &i.ActiveVersionID, &i.Description, &i.DefaultTTL, + &i.MaxTTL, &i.CreatedBy, &i.Icon, &i.UserACL, @@ -82,6 +83,8 @@ func (q *sqlQuerier) GetAuthorizedTemplates(ctx context.Context, arg GetTemplate &i.FailureTTL, &i.InactivityTTL, &i.LockedTTL, + &i.RestartRequirementDaysOfWeek, + &i.RestartRequirementWeeks, ); err != nil { return nil, xerrors.Errorf("scan: %w", err) } diff --git a/codersdk/templates.go b/codersdk/templates.go index 344cb6f0caf23..91feb51b59eba 100644 --- a/codersdk/templates.go +++ b/codersdk/templates.go @@ -30,7 +30,7 @@ type Template struct { Icon string `json:"icon"` DefaultTTLMillis int64 `json:"default_ttl_ms"` // TODO(@dean): remove max_ttl once restart_requirement is matured - MaxTTLMillis int64 `json:"max_ttl_ms,omitempty"` + MaxTTLMillis int64 `json:"max_ttl_ms"` // RestartRequirement is an enterprise feature. Its value is only used if // your license is entitled to use the advanced template scheduling feature. RestartRequirement TemplateRestartRequirement `json:"restart_requirement"` diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 49c9e4f4dd584..3ee5d91b24df7 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -866,7 +866,7 @@ export interface Template { readonly description: string readonly icon: string readonly default_ttl_ms: number - readonly max_ttl_ms?: number + readonly max_ttl_ms: number readonly restart_requirement: TemplateRestartRequirement readonly created_by_id: string readonly created_by_name: string diff --git a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx index 922fef150a469..597ad0a15387e 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx @@ -26,6 +26,10 @@ const validFormValues: FormValues = { allow_user_cancel_workspace_jobs: false, allow_user_autostart: false, allow_user_autostop: false, + restart_requirement: { + days_of_week: [], + weeks: 1, + }, failure_ttl_ms: 0, inactivity_ttl_ms: 0, locked_ttl_ms: 0, diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/TemplateScheduleForm.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/TemplateScheduleForm.tsx index 5a0a4f819e1b1..d29ededf79f03 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/TemplateScheduleForm.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/TemplateScheduleForm.tsx @@ -70,6 +70,11 @@ export const TemplateScheduleForm: FC = ({ ? template.locked_ttl_ms / MS_DAY_CONVERSION : 0, + restart_requirement: { + days_of_week: template.restart_requirement.days_of_week, + weeks: template.restart_requirement.weeks, + }, + allow_user_autostart: template.allow_user_autostart, allow_user_autostop: template.allow_user_autostop, failure_cleanup_enabled: From 024233a5afcd892477a0b0e9868340c961667b88 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Wed, 12 Jul 2023 15:47:00 +0000 Subject: [PATCH 15/25] fixup! add back max_ttl and put restart_requirement behind feature flag --- coderd/database/modelqueries.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index a511b7b3375c8..7eff0eb4738ed 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -71,13 +71,13 @@ func (q *sqlQuerier) GetAuthorizedTemplates(ctx context.Context, arg GetTemplate &i.ActiveVersionID, &i.Description, &i.DefaultTTL, - &i.MaxTTL, &i.CreatedBy, &i.Icon, &i.UserACL, &i.GroupACL, &i.DisplayName, &i.AllowUserCancelWorkspaceJobs, + &i.MaxTTL, &i.AllowUserAutostart, &i.AllowUserAutostop, &i.FailureTTL, From c7ef9cb351dc530aab922ca57824105245aee5d4 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Wed, 12 Jul 2023 16:01:03 +0000 Subject: [PATCH 16/25] fixup! add back max_ttl and put restart_requirement behind feature flag --- coderd/schedule/autostop.go | 2 +- coderd/schedule/autostop_test.go | 14 +++++++++----- enterprise/coderd/coderd.go | 4 ---- site/src/testHelpers/entities.ts | 4 ++++ 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/coderd/schedule/autostop.go b/coderd/schedule/autostop.go index f2ea6ba774d0a..3bfdb24e27038 100644 --- a/coderd/schedule/autostop.go +++ b/coderd/schedule/autostop.go @@ -225,7 +225,7 @@ func nextDayMidnight(t time.Time) time.Time { yy, mm, dd := t.Date() // time.Date will correctly normalize the date if it's past the end of the // month. E.g. October 32nd will be November 1st. - dd += 1 + dd++ return time.Date(yy, mm, dd, 0, 0, 0, 0, t.Location()) } diff --git a/coderd/schedule/autostop_test.go b/coderd/schedule/autostop_test.go index 7f0afb7f4f56a..aa92a35849a61 100644 --- a/coderd/schedule/autostop_test.go +++ b/coderd/schedule/autostop_test.go @@ -345,12 +345,15 @@ func TestCalculateAutoStop(t *testing.T) { }, } + org := dbgen.Organization(t, db, database.Organization{}) user := dbgen.User(t, db, database.User{ QuietHoursSchedule: c.userQuietHoursSchedule, }) template := dbgen.Template(t, db, database.Template{ - Name: "template", - Provisioner: database.ProvisionerTypeEcho, + Name: "template", + Provisioner: database.ProvisionerTypeEcho, + OrganizationID: org.ID, + CreatedBy: user.ID, }) template, err := db.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{ ID: template.ID, @@ -368,9 +371,10 @@ func TestCalculateAutoStop(t *testing.T) { } } workspace := dbgen.Workspace(t, db, database.Workspace{ - TemplateID: template.ID, - Ttl: workspaceTTL, - OwnerID: user.ID, + TemplateID: template.ID, + OrganizationID: org.ID, + OwnerID: user.ID, + Ttl: workspaceTTL, }) autostop, err := schedule.CalculateAutostop(ctx, schedule.CalculateAutostopParams{ diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 32d281150214b..89cd575b27550 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -472,13 +472,9 @@ func (api *API) updateEntitlements(ctx context.Context) error { templateStore := schedule.NewEnterpriseTemplateScheduleStore() templateStoreInterface := agplschedule.TemplateScheduleStore(templateStore) api.AGPL.TemplateScheduleStore.Store(&templateStoreInterface) - } else { templateStore := agplschedule.NewAGPLTemplateScheduleStore() api.AGPL.TemplateScheduleStore.Store(&templateStore) - - quietHoursStore := agplschedule.NewAGPLUserQuietHoursScheduleStore() - api.AGPL.UserQuietHoursScheduleStore.Store(&quietHoursStore) } } diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 4f6321c940eab..71304790f0965 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -421,6 +421,10 @@ export const MockTemplate: TypesGen.Template = { description: "This is a test description.", default_ttl_ms: 24 * 60 * 60 * 1000, max_ttl_ms: 2 * 24 * 60 * 60 * 1000, + restart_requirement: { + days_of_week: [], + weeks: 1, + }, created_by_id: "test-creator-id", created_by_name: "test_creator", icon: "/icon/code.svg", From 4c70ade873e56ddfa5969fd4bcd0c063878a9a67 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Thu, 13 Jul 2023 17:08:10 +0000 Subject: [PATCH 17/25] fixup! add back max_ttl and put restart_requirement behind feature flag --- cli/templateedit_test.go | 36 ++++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/cli/templateedit_test.go b/cli/templateedit_test.go index 4a98518de3969..0548877e925f9 100644 --- a/cli/templateedit_test.go +++ b/cli/templateedit_test.go @@ -354,7 +354,11 @@ func TestTemplateEdit(t *testing.T) { } // Otherwise, proxy the request to the real API server. - httputil.NewSingleHostReverseProxy(client.URL).ServeHTTP(w, r) + rp := httputil.NewSingleHostReverseProxy(client.URL) + rp.Transport = &http.Transport{ + DisableKeepAlives: true, + } + rp.ServeHTTP(w, r) })) t.Cleanup(proxy.Close) @@ -479,7 +483,11 @@ func TestTemplateEdit(t *testing.T) { } // Otherwise, proxy the request to the real API server. - httputil.NewSingleHostReverseProxy(client.URL).ServeHTTP(w, r) + rp := httputil.NewSingleHostReverseProxy(client.URL) + rp.Transport = &http.Transport{ + DisableKeepAlives: true, + } + rp.ServeHTTP(w, r) })) defer proxy.Close() @@ -595,7 +603,11 @@ func TestTemplateEdit(t *testing.T) { } // Otherwise, proxy the request to the real API server. - httputil.NewSingleHostReverseProxy(client.URL).ServeHTTP(w, r) + rp := httputil.NewSingleHostReverseProxy(client.URL) + rp.Transport = &http.Transport{ + DisableKeepAlives: true, + } + rp.ServeHTTP(w, r) })) defer proxy.Close() @@ -682,7 +694,11 @@ func TestTemplateEdit(t *testing.T) { } // Otherwise, proxy the request to the real API server. - httputil.NewSingleHostReverseProxy(client.URL).ServeHTTP(w, r) + rp := httputil.NewSingleHostReverseProxy(client.URL) + rp.Transport = &http.Transport{ + DisableKeepAlives: true, + } + rp.ServeHTTP(w, r) })) defer proxy.Close() @@ -814,7 +830,11 @@ func TestTemplateEdit(t *testing.T) { } // Otherwise, proxy the request to the real API server. - httputil.NewSingleHostReverseProxy(client.URL).ServeHTTP(w, r) + rp := httputil.NewSingleHostReverseProxy(client.URL) + rp.Transport = &http.Transport{ + DisableKeepAlives: true, + } + rp.ServeHTTP(w, r) })) defer proxy.Close() @@ -919,7 +939,11 @@ func TestTemplateEdit(t *testing.T) { } // Otherwise, proxy the request to the real API server. - httputil.NewSingleHostReverseProxy(client.URL).ServeHTTP(w, r) + rp := httputil.NewSingleHostReverseProxy(client.URL) + rp.Transport = &http.Transport{ + DisableKeepAlives: true, + } + rp.ServeHTTP(w, r) })) defer proxy.Close() From 554e837f3c2fea59d2cb3db892506664ebd5c83f Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Thu, 13 Jul 2023 17:47:16 +0000 Subject: [PATCH 18/25] Disable quiet hours endpoint if not entitled --- enterprise/coderd/coderd.go | 6 +- enterprise/coderd/users.go | 30 ++++ enterprise/coderd/users_test.go | 283 +++++++++++++++++++++----------- 3 files changed, 221 insertions(+), 98 deletions(-) diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 89cd575b27550..b477adc081efc 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -237,7 +237,7 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { }) r.Route("/users/{user}/quiet-hours", func(r chi.Router) { r.Use( - // TODO: enabled MW? + api.restartRequirementEnabledMW, apiKeyMiddleware, httpmw.ExtractUserParam(options.Database, false), ) @@ -494,6 +494,10 @@ func (api *API) updateEntitlements(ctx context.Context) error { 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 { diff --git a/enterprise/coderd/users.go b/enterprise/coderd/users.go index 7a23e5105989d..b1000f21bbf48 100644 --- a/enterprise/coderd/users.go +++ b/enterprise/coderd/users.go @@ -11,6 +11,36 @@ import ( "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 diff --git a/enterprise/coderd/users_test.go b/enterprise/coderd/users_test.go index aecbea4703e26..5882ae557c4d0 100644 --- a/enterprise/coderd/users_test.go +++ b/enterprise/coderd/users_test.go @@ -1,6 +1,7 @@ package coderd_test import ( + "net/http" "testing" "time" @@ -17,112 +18,200 @@ import ( func TestUserQuietHours(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) + 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) - } - - dv := coderdtest.DeploymentValues(t) - dv.UserQuietHoursSchedule.DefaultSchedule.Set(defaultQuietHoursSchedule) - dv.UserQuietHoursSchedule.WindowDuration.Set("8h") // default is 4h - - client, user := coderdenttest.New(t, &coderdenttest.Options{ - Options: &coderdtest.Options{ - DeploymentValues: dv, - }, - LicenseOptions: &coderdenttest.LicenseOptions{ - Features: license.Features{ - codersdk.FeatureAdvancedTemplateScheduling: 1, - codersdk.FeatureTemplateRestartRequirement: 1, + 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.UserQuietHoursSchedule.WindowDuration.Set("8h") // default is 4h + 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.Equal(t, dv.UserQuietHoursSchedule.WindowDuration.Value(), sched1.Duration) - 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) + // 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) - } - - 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.Equal(t, dv.UserQuietHoursSchedule.WindowDuration.Value(), sched2.Duration) - 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.Equal(t, dv.UserQuietHoursSchedule.WindowDuration.Value(), sched3.Duration) - 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.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.Equal(t, dv.UserQuietHoursSchedule.WindowDuration.Value(), sched1.Duration) + 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.Equal(t, dv.UserQuietHoursSchedule.WindowDuration.Value(), sched2.Duration) + 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.Equal(t, dv.UserQuietHoursSchedule.WindowDuration.Value(), sched3.Duration) + 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. }) - 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") + t.Run("NotEntitled", func(t *testing.T) { + t.Parallel() - // 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") + dv := coderdtest.DeploymentValues(t) + dv.UserQuietHoursSchedule.DefaultSchedule.Set("CRON_TZ=America/Chicago 0 0 * * *") + dv.Experiments.Set(string(codersdk.ExperimentTemplateRestartRequirement)) - // 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 * * *", + 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()) }) - 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 * * *", + + 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()) }) - 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("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()) + }) } From eb46ae246f59ce8214d16322dbc7784ec99fd861 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Thu, 13 Jul 2023 19:14:26 +0000 Subject: [PATCH 19/25] add DST and week calculation tests --- coderd/schedule/autostop.go | 120 ++++++++++++++++++---- coderd/schedule/autostop_test.go | 170 +++++++++++++++++++++++++++++++ 2 files changed, 272 insertions(+), 18 deletions(-) diff --git a/coderd/schedule/autostop.go b/coderd/schedule/autostop.go index 3bfdb24e27038..e7e1deb26b3ac 100644 --- a/coderd/schedule/autostop.go +++ b/coderd/schedule/autostop.go @@ -118,24 +118,9 @@ func CalculateAutostop(ctx context.Context, params CalculateAutostopParams) (Aut // change the startOfDay to be the Monday of the next applicable // week. if templateSchedule.RestartRequirement.Weeks > 1 { - epoch := TemplateRestartRequirementEpoch(loc) - if startOfStopDay.Before(epoch) { - return autostop, xerrors.New("coder server system clock is incorrect, cannot calculate template restart requirement") - } - since := startOfStopDay.Sub(epoch) - weeksSinceEpoch := int64(since.Hours() / (24 * 7)) - requiredWeeks := templateSchedule.RestartRequirement.Weeks - weeksRemainder := weeksSinceEpoch % requiredWeeks - if weeksRemainder != 0 { - // Add (requiredWeeks - weeksSince) * 7 days to the current - // startOfStopDay, then truncate to Monday midnight. - // - // This sets startOfStopDay to Monday at midnight of the - // next applicable week. - y, mo, d := startOfStopDay.Date() - d += int(requiredWeeks-weeksRemainder) * 7 - startOfStopDay = time.Date(y, mo, d, 0, 0, 0, 0, loc) - startOfStopDay = truncateMondayMidnight(startOfStopDay) + startOfStopDay, err = GetNextApplicableMondayOfNWeeks(startOfStopDay, templateSchedule.RestartRequirement.Weeks) + if err != nil { + return autostop, xerrors.Errorf("determine start of stop week: %w", err) } } @@ -239,3 +224,102 @@ func truncateMondayMidnight(t time.Time) time.Time { t = time.Date(yy, mm, dd, 0, 0, 0, 0, t.Location()) return truncateMidnight(t) } + +// WeeksSinceEpoch gets the weeks since the epoch for a given time. This is a +// 0-indexed number of weeks since the epoch (Monday). +// +// The timezone embedded in the time object is used to determine the epoch. +func WeeksSinceEpoch(now time.Time) (int64, error) { + epoch := TemplateRestartRequirementEpoch(now.Location()) + if now.Before(epoch) { + return 0, xerrors.New("coder server system clock is incorrect, cannot calculate template restart requirement") + } + + // This calculation needs to be done using YearDay, as dividing by the + // amount of hours is impacted by daylight savings. Even though daylight + // savings is usually only an hour difference, this calculation is used to + // get the current week number and could result in an entire week getting + // skipped if the calculation is off by an hour. + // + // Old naive algorithm: weeksSinceEpoch := int64(since.Hours() / (24 * 7)) + + // Get days since epoch. Start with a negative number of days, as we want to + // subtract the YearDay() of the epoch itself. + days := -epoch.YearDay() + for i := epoch.Year(); i < now.Year(); i++ { + startOfNextYear := time.Date(i+1, 1, 1, 0, 0, 0, 0, now.Location()) + if startOfNextYear.Year() != i+1 { + return 0, xerrors.New("overflow calculating weeks since epoch") + } + endOfThisYear := startOfNextYear.AddDate(0, 0, -1) + if endOfThisYear.Year() != i { + return 0, xerrors.New("overflow calculating weeks since epoch") + } + + days += endOfThisYear.YearDay() + } + // Add this year's days. + days += now.YearDay() + + // Ensure that the number of days is positive. + if days < 0 { + return 0, xerrors.New("overflow calculating weeks since epoch") + } + + // Divide by 7 to get the number of weeks. + weeksSinceEpoch := int64(days / 7) + return weeksSinceEpoch, nil +} + +// GetMondayOfWeek gets the Monday (0:00) of the n-th week since epoch. +func GetMondayOfWeek(loc *time.Location, n int64) (time.Time, error) { + if n < 0 { + return time.Time{}, xerrors.New("weeks since epoch must be positive") + } + epoch := TemplateRestartRequirementEpoch(loc) + monday := epoch.AddDate(0, 0, int(n*7)) + + y, m, d := monday.Date() + monday = time.Date(y, m, d, 0, 0, 0, 0, loc) + if monday.Weekday() != time.Monday { + return time.Time{}, xerrors.Errorf("calculated incorrect Monday for week %v since epoch (actual weekday %q)", n, monday.Weekday()) + } + return monday, nil +} + +// GetNextApplicableMondayOfNWeeks gets the next Monday (0:00) of the next week +// divisible by n since epoch. If the next applicable week is invalid for any +// reason, the week after will be used instead (up to 2 attempts). +// +// If the current week is divisible by n, then the provided time is returned as +// is. +// +// The timezone embedded in the time object is used to determine the epoch. +func GetNextApplicableMondayOfNWeeks(now time.Time, n int64) (time.Time, error) { + // Get the current week number. + weeksSinceEpoch, err := WeeksSinceEpoch(now) + if err != nil { + return time.Time{}, xerrors.Errorf("get current week number: %w", err) + } + + // Get the next week divisible by n. + remainder := weeksSinceEpoch % n + week := weeksSinceEpoch + (n - remainder) + if remainder == 0 { + return now, nil + } + + // Loop until we find a week that doesn't fail. + var lastErr error + for i := int64(0); i < 3; i++ { + monday, err := GetMondayOfWeek(now.Location(), week+i) + if err != nil { + lastErr = err + continue + } + + return monday, nil + } + + return time.Time{}, xerrors.Errorf("get next applicable Monday of %v weeks: %w", n, lastErr) +} diff --git a/coderd/schedule/autostop_test.go b/coderd/schedule/autostop_test.go index aa92a35849a61..c994cbbd1399c 100644 --- a/coderd/schedule/autostop_test.go +++ b/coderd/schedule/autostop_test.go @@ -3,6 +3,7 @@ package schedule_test import ( "context" "database/sql" + "fmt" "testing" "time" @@ -40,6 +41,32 @@ func TestCalculateAutoStop(t *testing.T) { t.Log("fridayEveningSydney", fridayEveningSydney) t.Log("saturdayMidnightSydney", saturdayMidnightSydney) + dstIn := time.Date(2023, 10, 1, 2, 0, 0, 0, sydneyLoc) // 1 hour backward + dstInQuietHours := "CRON_TZ=Australia/Sydney 30 2 * * *" // never + // The expected behavior is that we will pick the next time that falls on + // quiet hours after the DST transition. In this case, it will be the same + // time the next day. + dstInQuietHoursExpectedTime := time.Date(2023, 10, 2, 2, 30, 0, 0, sydneyLoc) + beforeDstIn := time.Date(2023, 10, 1, 0, 0, 0, 0, sydneyLoc) + saturdayMidnightAfterDstIn := time.Date(2023, 10, 7, 0, 0, 0, 0, sydneyLoc) + + // Wednesday after DST starts. + duringDst := time.Date(2023, 10, 4, 0, 0, 0, 0, sydneyLoc) + saturdayMidnightAfterDuringDst := saturdayMidnightAfterDstIn + + dstOut := time.Date(2024, 4, 7, 3, 0, 0, 0, sydneyLoc) // 1 hour forward + dstOutQuietHours := "CRON_TZ=Australia/Sydney 30 3 * * *" // twice + dstOutQuietHoursExpectedTime := time.Date(2024, 4, 7, 3, 30, 0, 0, sydneyLoc) // in reality, this is the first occurrence + beforeDstOut := time.Date(2024, 4, 7, 0, 0, 0, 0, sydneyLoc) + saturdayMidnightAfterDstOut := time.Date(2024, 4, 13, 0, 0, 0, 0, sydneyLoc) + + t.Log("dstIn", dstIn) + t.Log("beforeDstIn", beforeDstIn) + t.Log("saturdayMidnightAfterDstIn", saturdayMidnightAfterDstIn) + t.Log("dstOut", dstOut) + t.Log("beforeDstOut", beforeDstOut) + t.Log("saturdayMidnightAfterDstOut", saturdayMidnightAfterDstOut) + cases := []struct { name string now time.Time @@ -268,6 +295,78 @@ func TestCalculateAutoStop(t *testing.T) { workspaceTTL: 0, errContains: "coder server system clock is incorrect", }, + { + name: "DaylightSavings/OK", + now: duringDst, + templateAllowAutostop: true, + templateDefaultTTL: 0, + userQuietHoursSchedule: sydneyQuietHours, + templateRestartRequirement: schedule.TemplateRestartRequirement{ + DaysOfWeek: 0b00100000, // Saturday + Weeks: 1, // weekly + }, + workspaceTTL: 0, + // expectedDeadline is copied from expectedMaxDeadline. + expectedMaxDeadline: saturdayMidnightAfterDuringDst, + }, + { + name: "DaylightSavings/SwitchMidWeek/In", + now: beforeDstIn, + templateAllowAutostop: true, + templateDefaultTTL: 0, + userQuietHoursSchedule: sydneyQuietHours, + templateRestartRequirement: schedule.TemplateRestartRequirement{ + DaysOfWeek: 0b00100000, // Saturday + Weeks: 1, // weekly + }, + workspaceTTL: 0, + // expectedDeadline is copied from expectedMaxDeadline. + expectedMaxDeadline: saturdayMidnightAfterDstIn, + }, + { + name: "DaylightSavings/SwitchMidWeek/Out", + now: beforeDstOut, + templateAllowAutostop: true, + templateDefaultTTL: 0, + userQuietHoursSchedule: sydneyQuietHours, + templateRestartRequirement: schedule.TemplateRestartRequirement{ + DaysOfWeek: 0b00100000, // Saturday + Weeks: 1, // weekly + }, + workspaceTTL: 0, + // expectedDeadline is copied from expectedMaxDeadline. + expectedMaxDeadline: saturdayMidnightAfterDstOut, + }, + { + name: "DaylightSavings/QuietHoursFallsOnDstSwitch/In", + now: beforeDstIn.Add(-24 * time.Hour), + templateAllowAutostop: true, + templateDefaultTTL: 0, + userQuietHoursSchedule: dstInQuietHours, + templateRestartRequirement: schedule.TemplateRestartRequirement{ + DaysOfWeek: 0b01000000, // Sunday + Weeks: 1, // weekly + }, + workspaceTTL: 0, + // expectedDeadline is copied from expectedMaxDeadline. + expectedMaxDeadline: dstInQuietHoursExpectedTime, + }, + { + name: "DaylightSavings/QuietHoursFallsOnDstSwitch/Out", + now: beforeDstOut.Add(-24 * time.Hour), + templateAllowAutostop: true, + templateDefaultTTL: 0, + userQuietHoursSchedule: dstOutQuietHours, + templateRestartRequirement: schedule.TemplateRestartRequirement{ + DaysOfWeek: 0b01000000, // Sunday + Weeks: 1, // weekly + }, + workspaceTTL: 0, + // expectedDeadline is copied from expectedMaxDeadline. + expectedMaxDeadline: dstOutQuietHoursExpectedTime, + }, + + // TODO(@dean): remove max_ttl tests { name: "RestartRequirementIgnoresMaxTTL", now: fridayEveningSydney.In(time.UTC), @@ -411,3 +510,74 @@ func TestCalculateAutoStop(t *testing.T) { }) } } + +func TestFindWeek(t *testing.T) { + t.Parallel() + + timezones := []string{ + "UTC", + "America/Los_Angeles", + "America/New_York", + "Europe/Dublin", + "Europe/London", + "Europe/Paris", + "Asia/Kolkata", // India (UTC+5:30) + "Asia/Tokyo", + "Australia/Sydney", + "Australia/Brisbane", + } + + for _, tz := range timezones { + tz := tz + t.Run("Loc/"+tz, func(t *testing.T) { + t.Parallel() + + loc, err := time.LoadLocation(tz) + require.NoError(t, err) + + now := time.Now().In(loc) + currentWeek, err := schedule.WeeksSinceEpoch(now) + require.NoError(t, err) + + currentWeekMondayExpected := now.AddDate(0, 0, -int(now.Weekday())+1) + y, m, d := currentWeekMondayExpected.Date() + currentWeekMondayExpected = time.Date(y, m, d, 0, 0, 0, 0, loc) + currentWeekMonday, err := schedule.GetMondayOfWeek(now.Location(), currentWeek) + require.NoError(t, err) + require.Equal(t, currentWeekMondayExpected, currentWeekMonday) + + t.Log("now", now) + t.Log("currentWeek", currentWeek) + t.Log("currentMonday", currentWeekMonday) + + // Loop through every single Monday and Sunday for the next 100 + // years and make sure the week calculations are correct. + for i := int64(1); i < 52*100; i++ { + msg := fmt.Sprintf("week %d", i) + + monday := currentWeekMonday.AddDate(0, 0, int(i*7)) + y, m, d := monday.Date() + monday = time.Date(y, m, d, 0, 0, 0, 0, loc) + require.Equal(t, monday.Weekday(), time.Monday, msg) + t.Log(msg, "monday", monday) + + week, err := schedule.WeeksSinceEpoch(monday) + require.NoError(t, err, msg) + require.Equal(t, currentWeek+i, week, msg) + + gotMonday, err := schedule.GetMondayOfWeek(monday.Location(), week) + require.NoError(t, err, msg) + require.Equal(t, monday, gotMonday, msg) + + // Check that we get the same week number for late Sunday. + sunday := time.Date(y, m, d+6, 23, 59, 59, 0, loc) + require.Equal(t, sunday.Weekday(), time.Sunday, msg) + t.Log(msg, "sunday", sunday) + + week, err = schedule.WeeksSinceEpoch(sunday) + require.NoError(t, err, msg) + require.Equal(t, currentWeek+i, week, msg) + } + }) + } +} From 6af3e33f6ce4394eee2aaeab63f2094df3375d9f Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Thu, 13 Jul 2023 19:32:54 +0000 Subject: [PATCH 20/25] fixup! add DST and week calculation tests --- coderd/schedule/autostop.go | 11 ----------- enterprise/cli/licenses.go | 2 +- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/coderd/schedule/autostop.go b/coderd/schedule/autostop.go index e7e1deb26b3ac..3f89e35b50183 100644 --- a/coderd/schedule/autostop.go +++ b/coderd/schedule/autostop.go @@ -214,17 +214,6 @@ func nextDayMidnight(t time.Time) time.Time { return time.Date(yy, mm, dd, 0, 0, 0, 0, t.Location()) } -// truncateMondayMidnight truncates a time to the previous Monday at midnight in -// the time object's timezone. -func truncateMondayMidnight(t time.Time) time.Time { - // time.Date will correctly normalize the date if it's past the end of the - // month. E.g. October 32nd will be November 1st. - yy, mm, dd := t.Date() - dd -= int(t.Weekday() - 1) - t = time.Date(yy, mm, dd, 0, 0, 0, 0, t.Location()) - return truncateMidnight(t) -} - // WeeksSinceEpoch gets the weeks since the epoch for a given time. This is a // 0-indexed number of weeks since the epoch (Monday). // diff --git a/enterprise/cli/licenses.go b/enterprise/cli/licenses.go index 4258081df3e24..e4bf3e0731636 100644 --- a/enterprise/cli/licenses.go +++ b/enterprise/cli/licenses.go @@ -10,12 +10,12 @@ import ( "strings" "time" + "github.com/google/uuid" "golang.org/x/xerrors" "github.com/coder/coder/cli/clibase" "github.com/coder/coder/cli/cliui" "github.com/coder/coder/codersdk" - "github.com/google/uuid" ) var jwtRegexp = regexp.MustCompile(`^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$`) From 96f5e2e70e526bf42ba7a2c97f540ed54c522118 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Sun, 16 Jul 2023 22:10:47 +0000 Subject: [PATCH 21/25] rename interface methods, merge migrations --- coderd/autobuild/lifecycle_executor.go | 2 +- coderd/database/dump.sql | 2 ++ ...138_template_restart_requirement.down.sql} | 6 +++++ ...00138_template_restart_requirement.up.sql} | 5 ++++ .../000138_user_quiet_hours_schedule.down.sql | 1 - .../000138_user_quiet_hours_schedule.up.sql | 2 -- coderd/database/models.go | 27 ++++++++++--------- coderd/schedule/autostop.go | 4 +-- coderd/schedule/mock.go | 16 +++++------ coderd/schedule/template.go | 8 +++--- coderd/schedule/user.go | 26 +++++++++--------- coderd/templates.go | 6 ++--- coderd/workspaces.go | 6 ++--- enterprise/coderd/schedule/template.go | 8 +++--- enterprise/coderd/schedule/user.go | 4 +-- enterprise/coderd/users.go | 4 +-- 16 files changed, 69 insertions(+), 58 deletions(-) rename coderd/database/migrations/{000139_template_restart_requirement.down.sql => 000138_template_restart_requirement.down.sql} (60%) rename coderd/database/migrations/{000139_template_restart_requirement.up.sql => 000138_template_restart_requirement.up.sql} (74%) delete mode 100644 coderd/database/migrations/000138_user_quiet_hours_schedule.down.sql delete mode 100644 coderd/database/migrations/000138_user_quiet_hours_schedule.up.sql diff --git a/coderd/autobuild/lifecycle_executor.go b/coderd/autobuild/lifecycle_executor.go index d3dc80814b2b8..48942ede42aa3 100644 --- a/coderd/autobuild/lifecycle_executor.go +++ b/coderd/autobuild/lifecycle_executor.go @@ -142,7 +142,7 @@ func (e *Executor) runOnce(t time.Time) Stats { log.Warn(e.ctx, "get latest workspace build", slog.Error(err)) return nil } - templateSchedule, err := (*(e.templateScheduleStore.Load())).GetTemplateScheduleOptions(e.ctx, tx, ws.TemplateID) + templateSchedule, err := (*(e.templateScheduleStore.Load())).Get(e.ctx, tx, ws.TemplateID) if err != nil { log.Warn(e.ctx, "get template schedule options", slog.Error(err)) return nil diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 968e488a8e327..4eb6d467069b7 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -610,6 +610,8 @@ CREATE TABLE users ( quiet_hours_schedule text DEFAULT ''::text NOT NULL ); +COMMENT ON COLUMN users.quiet_hours_schedule IS 'Daily (!) cron schedule (with optional CRON_TZ) signifying the start of the user''s quiet hours. If empty, the default quiet hours on the instance is used instead.'; + CREATE UNLOGGED TABLE workspace_agent_metadata ( workspace_agent_id uuid NOT NULL, display_name character varying(127) NOT NULL, diff --git a/coderd/database/migrations/000139_template_restart_requirement.down.sql b/coderd/database/migrations/000138_template_restart_requirement.down.sql similarity index 60% rename from coderd/database/migrations/000139_template_restart_requirement.down.sql rename to coderd/database/migrations/000138_template_restart_requirement.down.sql index 4a7cb544cbc05..edb2d0b61a03c 100644 --- a/coderd/database/migrations/000139_template_restart_requirement.down.sql +++ b/coderd/database/migrations/000138_template_restart_requirement.down.sql @@ -1,3 +1,9 @@ +BEGIN; + ALTER TABLE templates DROP COLUMN restart_requirement_days_of_week, DROP COLUMN restart_requirement_weeks; + +ALTER TABLE users DROP COLUMN quiet_hours_schedule; + +COMMIT; diff --git a/coderd/database/migrations/000139_template_restart_requirement.up.sql b/coderd/database/migrations/000138_template_restart_requirement.up.sql similarity index 74% rename from coderd/database/migrations/000139_template_restart_requirement.up.sql rename to coderd/database/migrations/000138_template_restart_requirement.up.sql index 1cafd455f3870..a959982d65c7f 100644 --- a/coderd/database/migrations/000139_template_restart_requirement.up.sql +++ b/coderd/database/migrations/000138_template_restart_requirement.up.sql @@ -10,4 +10,9 @@ ALTER TABLE templates COMMENT ON COLUMN templates.restart_requirement_days_of_week IS 'A bitmap of days of week to restart the workspace on, starting with Monday as the 0th bit, and Sunday as the 6th bit. The 7th bit is unused.'; COMMENT ON COLUMN templates.restart_requirement_weeks IS 'The number of weeks between restarts. 0 or 1 weeks means "every week", 2 week means "every second week", etc. Weeks are counted from January 2, 2023, which is the first Monday of 2023. This is to ensure workspaces are started consistently for all customers on the same n-week cycles.'; +ALTER TABLE users + ADD COLUMN quiet_hours_schedule text NOT NULL DEFAULT ''; + +COMMENT ON COLUMN users.quiet_hours_schedule IS 'Daily (!) cron schedule (with optional CRON_TZ) signifying the start of the user''s quiet hours. If empty, the default quiet hours on the instance is used instead.'; + COMMIT; diff --git a/coderd/database/migrations/000138_user_quiet_hours_schedule.down.sql b/coderd/database/migrations/000138_user_quiet_hours_schedule.down.sql deleted file mode 100644 index 491e2fa41997c..0000000000000 --- a/coderd/database/migrations/000138_user_quiet_hours_schedule.down.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE users DROP COLUMN quiet_hours_schedule; diff --git a/coderd/database/migrations/000138_user_quiet_hours_schedule.up.sql b/coderd/database/migrations/000138_user_quiet_hours_schedule.up.sql deleted file mode 100644 index f544e73e320fb..0000000000000 --- a/coderd/database/migrations/000138_user_quiet_hours_schedule.up.sql +++ /dev/null @@ -1,2 +0,0 @@ --- empty schedule means use the default if entitled -ALTER TABLE users ADD COLUMN quiet_hours_schedule text NOT NULL DEFAULT ''; diff --git a/coderd/database/models.go b/coderd/database/models.go index 0acd47c2ebd86..b83c3bb088ffd 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1672,19 +1672,20 @@ type TemplateVersionVariable struct { } type User struct { - ID uuid.UUID `db:"id" json:"id"` - Email string `db:"email" json:"email"` - Username string `db:"username" json:"username"` - HashedPassword []byte `db:"hashed_password" json:"hashed_password"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` - Status UserStatus `db:"status" json:"status"` - RBACRoles pq.StringArray `db:"rbac_roles" json:"rbac_roles"` - LoginType LoginType `db:"login_type" json:"login_type"` - AvatarURL sql.NullString `db:"avatar_url" json:"avatar_url"` - Deleted bool `db:"deleted" json:"deleted"` - LastSeenAt time.Time `db:"last_seen_at" json:"last_seen_at"` - QuietHoursSchedule string `db:"quiet_hours_schedule" json:"quiet_hours_schedule"` + ID uuid.UUID `db:"id" json:"id"` + Email string `db:"email" json:"email"` + Username string `db:"username" json:"username"` + HashedPassword []byte `db:"hashed_password" json:"hashed_password"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + Status UserStatus `db:"status" json:"status"` + RBACRoles pq.StringArray `db:"rbac_roles" json:"rbac_roles"` + LoginType LoginType `db:"login_type" json:"login_type"` + AvatarURL sql.NullString `db:"avatar_url" json:"avatar_url"` + Deleted bool `db:"deleted" json:"deleted"` + LastSeenAt time.Time `db:"last_seen_at" json:"last_seen_at"` + // Daily (!) cron schedule (with optional CRON_TZ) signifying the start of the user's quiet hours. If empty, the default quiet hours on the instance is used instead. + QuietHoursSchedule string `db:"quiet_hours_schedule" json:"quiet_hours_schedule"` } type UserLink struct { diff --git a/coderd/schedule/autostop.go b/coderd/schedule/autostop.go index 3f89e35b50183..e300e6ea659d6 100644 --- a/coderd/schedule/autostop.go +++ b/coderd/schedule/autostop.go @@ -72,7 +72,7 @@ func CalculateAutostop(ctx context.Context, params CalculateAutostopParams) (Aut autostop.Deadline = now.Add(time.Duration(workspace.Ttl.Int64)) } - templateSchedule, err := params.TemplateScheduleStore.GetTemplateScheduleOptions(ctx, db, workspace.TemplateID) + templateSchedule, err := params.TemplateScheduleStore.Get(ctx, db, workspace.TemplateID) if err != nil { return autostop, xerrors.Errorf("get template schedule options: %w", err) } @@ -99,7 +99,7 @@ func CalculateAutostop(ctx context.Context, params CalculateAutostopParams) (Aut // First, get the user's quiet hours schedule (this will return the // default if the user has not set their own schedule). - userQuietHoursSchedule, err := params.UserQuietHoursScheduleStore.GetUserQuietHoursScheduleOptions(ctx, db, workspace.OwnerID) + userQuietHoursSchedule, err := params.UserQuietHoursScheduleStore.Get(ctx, db, workspace.OwnerID) if err != nil { return autostop, xerrors.Errorf("get user quiet hours schedule options: %w", err) } diff --git a/coderd/schedule/mock.go b/coderd/schedule/mock.go index 04237e9b7ffd1..4a22197b57dc4 100644 --- a/coderd/schedule/mock.go +++ b/coderd/schedule/mock.go @@ -15,20 +15,20 @@ type MockTemplateScheduleStore struct { var _ TemplateScheduleStore = MockTemplateScheduleStore{} -func (m MockTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.Context, db database.Store, templateID uuid.UUID) (TemplateScheduleOptions, error) { +func (m MockTemplateScheduleStore) Get(ctx context.Context, db database.Store, templateID uuid.UUID) (TemplateScheduleOptions, error) { if m.GetFn != nil { return m.GetFn(ctx, db, templateID) } - return NewAGPLTemplateScheduleStore().GetTemplateScheduleOptions(ctx, db, templateID) + return NewAGPLTemplateScheduleStore().Get(ctx, db, templateID) } -func (m MockTemplateScheduleStore) SetTemplateScheduleOptions(ctx context.Context, db database.Store, template database.Template, options TemplateScheduleOptions) (database.Template, error) { +func (m MockTemplateScheduleStore) Set(ctx context.Context, db database.Store, template database.Template, options TemplateScheduleOptions) (database.Template, error) { if m.SetFn != nil { return m.SetFn(ctx, db, template, options) } - return NewAGPLTemplateScheduleStore().SetTemplateScheduleOptions(ctx, db, template, options) + return NewAGPLTemplateScheduleStore().Set(ctx, db, template, options) } type MockUserQuietHoursScheduleStore struct { @@ -38,18 +38,18 @@ type MockUserQuietHoursScheduleStore struct { var _ UserQuietHoursScheduleStore = MockUserQuietHoursScheduleStore{} -func (m MockUserQuietHoursScheduleStore) GetUserQuietHoursScheduleOptions(ctx context.Context, db database.Store, userID uuid.UUID) (UserQuietHoursScheduleOptions, error) { +func (m MockUserQuietHoursScheduleStore) Get(ctx context.Context, db database.Store, userID uuid.UUID) (UserQuietHoursScheduleOptions, error) { if m.GetFn != nil { return m.GetFn(ctx, db, userID) } - return NewAGPLUserQuietHoursScheduleStore().GetUserQuietHoursScheduleOptions(ctx, db, userID) + return NewAGPLUserQuietHoursScheduleStore().Get(ctx, db, userID) } -func (m MockUserQuietHoursScheduleStore) SetUserQuietHoursScheduleOptions(ctx context.Context, db database.Store, userID uuid.UUID, schedule string) (UserQuietHoursScheduleOptions, error) { +func (m MockUserQuietHoursScheduleStore) Set(ctx context.Context, db database.Store, userID uuid.UUID, schedule string) (UserQuietHoursScheduleOptions, error) { if m.SetFn != nil { return m.SetFn(ctx, db, userID, schedule) } - return NewAGPLUserQuietHoursScheduleStore().SetUserQuietHoursScheduleOptions(ctx, db, userID, schedule) + return NewAGPLUserQuietHoursScheduleStore().Set(ctx, db, userID, schedule) } diff --git a/coderd/schedule/template.go b/coderd/schedule/template.go index 36c03ac02aa12..160867363557a 100644 --- a/coderd/schedule/template.go +++ b/coderd/schedule/template.go @@ -120,8 +120,8 @@ type TemplateScheduleOptions struct { // TemplateScheduleStore provides an interface for retrieving template // scheduling options set by the template/site admin. type TemplateScheduleStore interface { - GetTemplateScheduleOptions(ctx context.Context, db database.Store, templateID uuid.UUID) (TemplateScheduleOptions, error) - SetTemplateScheduleOptions(ctx context.Context, db database.Store, template database.Template, opts TemplateScheduleOptions) (database.Template, error) + Get(ctx context.Context, db database.Store, templateID uuid.UUID) (TemplateScheduleOptions, error) + Set(ctx context.Context, db database.Store, template database.Template, opts TemplateScheduleOptions) (database.Template, error) } type agplTemplateScheduleStore struct{} @@ -132,7 +132,7 @@ func NewAGPLTemplateScheduleStore() TemplateScheduleStore { return &agplTemplateScheduleStore{} } -func (*agplTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.Context, db database.Store, templateID uuid.UUID) (TemplateScheduleOptions, error) { +func (*agplTemplateScheduleStore) Get(ctx context.Context, db database.Store, templateID uuid.UUID) (TemplateScheduleOptions, error) { tpl, err := db.GetTemplateByID(ctx, templateID) if err != nil { return TemplateScheduleOptions{}, err @@ -158,7 +158,7 @@ func (*agplTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.Context }, nil } -func (*agplTemplateScheduleStore) SetTemplateScheduleOptions(ctx context.Context, db database.Store, tpl database.Template, opts TemplateScheduleOptions) (database.Template, error) { +func (*agplTemplateScheduleStore) Set(ctx context.Context, db database.Store, tpl database.Template, opts TemplateScheduleOptions) (database.Template, error) { if int64(opts.DefaultTTL) == tpl.DefaultTTL { // Avoid updating the UpdatedAt timestamp if nothing will be changed. return tpl, nil diff --git a/coderd/schedule/user.go b/coderd/schedule/user.go index 16700c8dfd9fe..10c246cbb7b59 100644 --- a/coderd/schedule/user.go +++ b/coderd/schedule/user.go @@ -27,17 +27,17 @@ type UserQuietHoursScheduleOptions struct { } type UserQuietHoursScheduleStore interface { - // GetUserQuietHoursScheduleOptions retrieves the quiet hours schedule for - // the given user. If the user has not set a custom schedule, the default - // schedule will be returned. If quiet hours schedules are not entitled or - // disabled instance-wide, this will return a nil schedule. - GetUserQuietHoursScheduleOptions(ctx context.Context, db database.Store, userID uuid.UUID) (UserQuietHoursScheduleOptions, error) - // SetUserQuietHoursScheduleOptions sets the quiet hours schedule for the - // given user. If the given schedule is an empty string, the user's custom - // schedule will be cleared and the default schedule will be used from now - // on. If quiet hours schedules are not entitled or disabled instance-wide, - // this will do nothing and return a nil schedule. - SetUserQuietHoursScheduleOptions(ctx context.Context, db database.Store, userID uuid.UUID, rawSchedule string) (UserQuietHoursScheduleOptions, error) + // Get retrieves the quiet hours schedule for the given user. If the user + // has not set a custom schedule, the default schedule will be returned. If + // quiet hours schedules are not entitled or disabled instance-wide, this + // will return a nil schedule. + Get(ctx context.Context, db database.Store, userID uuid.UUID) (UserQuietHoursScheduleOptions, error) + // Set sets the quiet hours schedule for the given user. If the given + // schedule is an empty string, the user's custom schedule will be cleared + // and the default schedule will be used from now on. If quiet hours + // schedules are not entitled or disabled instance-wide, this will do + // nothing and return a nil schedule. + Set(ctx context.Context, db database.Store, userID uuid.UUID, rawSchedule string) (UserQuietHoursScheduleOptions, error) } type agplUserQuietHoursScheduleStore struct{} @@ -48,7 +48,7 @@ func NewAGPLUserQuietHoursScheduleStore() UserQuietHoursScheduleStore { return &agplUserQuietHoursScheduleStore{} } -func (*agplUserQuietHoursScheduleStore) GetUserQuietHoursScheduleOptions(_ context.Context, _ database.Store, _ uuid.UUID) (UserQuietHoursScheduleOptions, error) { +func (*agplUserQuietHoursScheduleStore) Get(_ context.Context, _ database.Store, _ uuid.UUID) (UserQuietHoursScheduleOptions, error) { // User quiet hours windows are not supported in AGPL. return UserQuietHoursScheduleOptions{ Schedule: nil, @@ -57,7 +57,7 @@ func (*agplUserQuietHoursScheduleStore) GetUserQuietHoursScheduleOptions(_ conte }, nil } -func (*agplUserQuietHoursScheduleStore) SetUserQuietHoursScheduleOptions(_ context.Context, _ database.Store, _ uuid.UUID, _ string) (UserQuietHoursScheduleOptions, error) { +func (*agplUserQuietHoursScheduleStore) Set(_ context.Context, _ database.Store, _ uuid.UUID, _ string) (UserQuietHoursScheduleOptions, error) { // User quiet hours windows are not supported in AGPL. return UserQuietHoursScheduleOptions{ Schedule: nil, diff --git a/coderd/templates.go b/coderd/templates.go index 40e70aadc9109..aa2946d4022c1 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -332,7 +332,7 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque return xerrors.Errorf("insert template: %s", err) } - dbTemplate, err = (*api.TemplateScheduleStore.Load()).SetTemplateScheduleOptions(ctx, tx, dbTemplate, schedule.TemplateScheduleOptions{ + dbTemplate, err = (*api.TemplateScheduleStore.Load()).Set(ctx, tx, dbTemplate, schedule.TemplateScheduleOptions{ UserAutostartEnabled: allowUserAutostart, UserAutostopEnabled: allowUserAutostop, DefaultTTL: defaultTTL, @@ -512,7 +512,7 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { defer commitAudit() aReq.Old = template - scheduleOpts, err := (*api.TemplateScheduleStore.Load()).GetTemplateScheduleOptions(ctx, api.Database, template.ID) + scheduleOpts, err := (*api.TemplateScheduleStore.Load()).Get(ctx, api.Database, template.ID) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error fetching template schedule options.", @@ -632,7 +632,7 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { lockedTTL != time.Duration(template.LockedTTL) || req.AllowUserAutostart != template.AllowUserAutostart || req.AllowUserAutostop != template.AllowUserAutostop { - updated, err = (*api.TemplateScheduleStore.Load()).SetTemplateScheduleOptions(ctx, tx, updated, schedule.TemplateScheduleOptions{ + updated, err = (*api.TemplateScheduleStore.Load()).Set(ctx, tx, updated, schedule.TemplateScheduleOptions{ // Some of these values are enterprise-only, but the // TemplateScheduleStore will handle avoiding setting them if // unlicensed. diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 9496f88c7cbee..1310bc9c9c298 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -369,7 +369,7 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req return } - templateSchedule, err := (*api.TemplateScheduleStore.Load()).GetTemplateScheduleOptions(ctx, api.Database, template.ID) + templateSchedule, err := (*api.TemplateScheduleStore.Load()).Get(ctx, api.Database, template.ID) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error fetching template schedule.", @@ -640,7 +640,7 @@ func (api *API) putWorkspaceAutostart(rw http.ResponseWriter, r *http.Request) { } // Check if the template allows users to configure autostart. - templateSchedule, err := (*api.TemplateScheduleStore.Load()).GetTemplateScheduleOptions(ctx, api.Database, workspace.TemplateID) + templateSchedule, err := (*api.TemplateScheduleStore.Load()).Get(ctx, api.Database, workspace.TemplateID) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error getting template schedule options.", @@ -707,7 +707,7 @@ func (api *API) putWorkspaceTTL(rw http.ResponseWriter, r *http.Request) { var dbTTL sql.NullInt64 err := api.Database.InTx(func(s database.Store) error { - templateSchedule, err := (*api.TemplateScheduleStore.Load()).GetTemplateScheduleOptions(ctx, s, workspace.TemplateID) + templateSchedule, err := (*api.TemplateScheduleStore.Load()).Get(ctx, s, workspace.TemplateID) if err != nil { return xerrors.Errorf("get template schedule: %w", err) } diff --git a/enterprise/coderd/schedule/template.go b/enterprise/coderd/schedule/template.go index c84cf5872eaa3..3a6ceb4acbd44 100644 --- a/enterprise/coderd/schedule/template.go +++ b/enterprise/coderd/schedule/template.go @@ -28,8 +28,8 @@ func NewEnterpriseTemplateScheduleStore() *EnterpriseTemplateScheduleStore { return &EnterpriseTemplateScheduleStore{} } -// GetTemplateScheduleOptions implements agpl.TemplateScheduleStore. -func (s *EnterpriseTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.Context, db database.Store, templateID uuid.UUID) (agpl.TemplateScheduleOptions, error) { +// 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 @@ -64,8 +64,8 @@ func (s *EnterpriseTemplateScheduleStore) GetTemplateScheduleOptions(ctx context }, nil } -// SetTemplateScheduleOptions implements agpl.TemplateScheduleStore. -func (*EnterpriseTemplateScheduleStore) SetTemplateScheduleOptions(ctx context.Context, db database.Store, tpl database.Template, opts agpl.TemplateScheduleOptions) (database.Template, error) { +// 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 && diff --git a/enterprise/coderd/schedule/user.go b/enterprise/coderd/schedule/user.go index 6d4973ca9c0d4..44c3f96e638d4 100644 --- a/enterprise/coderd/schedule/user.go +++ b/enterprise/coderd/schedule/user.go @@ -70,7 +70,7 @@ func (s *enterpriseUserQuietHoursScheduleStore) parseSchedule(rawSchedule string }, nil } -func (s *enterpriseUserQuietHoursScheduleStore) GetUserQuietHoursScheduleOptions(ctx context.Context, db database.Store, userID uuid.UUID) (agpl.UserQuietHoursScheduleOptions, error) { +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) @@ -79,7 +79,7 @@ func (s *enterpriseUserQuietHoursScheduleStore) GetUserQuietHoursScheduleOptions return s.parseSchedule(user.QuietHoursSchedule) } -func (s *enterpriseUserQuietHoursScheduleStore) SetUserQuietHoursScheduleOptions(ctx context.Context, db database.Store, userID uuid.UUID, rawSchedule string) (agpl.UserQuietHoursScheduleOptions, error) { +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 diff --git a/enterprise/coderd/users.go b/enterprise/coderd/users.go index b1000f21bbf48..20627b64f70d7 100644 --- a/enterprise/coderd/users.go +++ b/enterprise/coderd/users.go @@ -55,7 +55,7 @@ func (api *API) userQuietHoursSchedule(rw http.ResponseWriter, r *http.Request) user = httpmw.UserParam(r) ) - opts, err := (*api.UserQuietHoursScheduleStore.Load()).GetUserQuietHoursScheduleOptions(ctx, api.Database, user.ID) + opts, err := (*api.UserQuietHoursScheduleStore.Load()).Get(ctx, api.Database, user.ID) if err != nil { httpapi.InternalServerError(rw, err) return @@ -104,7 +104,7 @@ func (api *API) putUserQuietHoursSchedule(rw http.ResponseWriter, r *http.Reques return } - opts, err := (*api.UserQuietHoursScheduleStore.Load()).SetUserQuietHoursScheduleOptions(ctx, api.Database, user.ID, params.Schedule) + 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 From cd7713871b98fd8e0b9b4898181b355c8fd425c9 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Sun, 16 Jul 2023 22:44:37 +0000 Subject: [PATCH 22/25] steven comments and test fix --- cli/templateedit.go | 19 ++++++++++++------- coderd/schedule/autostop.go | 15 +++++++++++++++ coderd/schedule/autostop_test.go | 12 +++++++++++- 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/cli/templateedit.go b/cli/templateedit.go index aef719ec16993..6c8173c452817 100644 --- a/cli/templateedit.go +++ b/cli/templateedit.go @@ -3,6 +3,7 @@ package cli import ( "fmt" "net/http" + "strings" "time" "golang.org/x/xerrors" @@ -90,12 +91,6 @@ func (r *RootCmd) templateEdit() *clibase.Cmd { restartRequirementDaysOfWeek = []string{} } - // Check that the user didn't specify a value that is not allowed. - _, err = codersdk.WeekdaysToBitmap(restartRequirementDaysOfWeek) - if err != nil { - return xerrors.Errorf("invalid restart requirement days of week: %w", err) - } - // NOTE: coderd will ignore empty fields. req := codersdk.UpdateTemplateMeta{ Name: name, @@ -160,7 +155,17 @@ func (r *RootCmd) templateEdit() *clibase.Cmd { Description: "Edit the template restart requirement weekdays - workspaces created from this template must be restarted on the given weekdays. To unset this value for the template (and disable the restart requirement for the template), pass 'none'.", // TODO(@dean): unhide when we delete max_ttl Hidden: true, - Value: clibase.StringArrayOf(&restartRequirementDaysOfWeek), + Value: clibase.Validate(clibase.StringArrayOf(&restartRequirementDaysOfWeek), func(value *clibase.StringArray) error { + v := value.GetSlice() + if len(v) == 1 && v[0] == "none" { + return nil + } + _, err := codersdk.WeekdaysToBitmap(v) + if err != nil { + return xerrors.Errorf("invalid restart requirement days of week %q: %w", strings.Join(v, ","), err) + } + return nil + }), }, { Flag: "restart-requirement-weeks", diff --git a/coderd/schedule/autostop.go b/coderd/schedule/autostop.go index e300e6ea659d6..96b8c07bab60a 100644 --- a/coderd/schedule/autostop.go +++ b/coderd/schedule/autostop.go @@ -13,12 +13,27 @@ const ( // restartRequirementLeeway is the duration of time before a restart // requirement where we skip the requirement and fall back to the next // scheduled restart. This avoids workspaces being restarted too soon. + // + // E.g. If the workspace is started within an hour of the quiet hours, we + // will skip the restart requirement and use the next scheduled restart + // requirement. restartRequirementLeeway = 1 * time.Hour // restartRequirementBuffer is the duration of time we subtract from the // time when calculating the next scheduled restart time. This avoids issues // where autostart happens on the hour and the scheduled quiet hours are // also on the hour. + // + // E.g. If the workspace is started at 12am (perhaps due to scheduled + // autostart) and the quiet hours is also 12am, the workspace will skip + // the day it's supposed to stop and use the next day instead. This is + // because getting the next cron schedule time will never include the + // time fed to the calculation (i.e. it's not inclusive). This happens + // because we always check for the next cron time by rounding down to + // midnight. + // + // This resolves that problem by subtracting 15 minutes from midnight + // when we check the next cron time. restartRequirementBuffer = -15 * time.Minute ) diff --git a/coderd/schedule/autostop_test.go b/coderd/schedule/autostop_test.go index c994cbbd1399c..15dcc423efdea 100644 --- a/coderd/schedule/autostop_test.go +++ b/coderd/schedule/autostop_test.go @@ -539,11 +539,21 @@ func TestFindWeek(t *testing.T) { currentWeek, err := schedule.WeeksSinceEpoch(now) require.NoError(t, err) - currentWeekMondayExpected := now.AddDate(0, 0, -int(now.Weekday())+1) + diffMonday := now.Weekday() - time.Monday + if now.Weekday() == time.Sunday { + // Sunday is 0, but Monday is the first day of the week in the + // code. + diffMonday = 6 + } + currentWeekMondayExpected := now.AddDate(0, 0, -int(diffMonday)) + require.Equal(t, time.Monday, currentWeekMondayExpected.Weekday()) y, m, d := currentWeekMondayExpected.Date() + // Change to midnight. currentWeekMondayExpected = time.Date(y, m, d, 0, 0, 0, 0, loc) + currentWeekMonday, err := schedule.GetMondayOfWeek(now.Location(), currentWeek) require.NoError(t, err) + require.Equal(t, time.Monday, currentWeekMonday.Weekday()) require.Equal(t, currentWeekMondayExpected, currentWeekMonday) t.Log("now", now) From 87b065b8552a76ffce10a0c4945f472b5e0278d7 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Mon, 17 Jul 2023 00:46:03 +0000 Subject: [PATCH 23/25] fixup! steven comments and test fix --- coderd/provisionerdserver/provisionerdserver_test.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/coderd/provisionerdserver/provisionerdserver_test.go b/coderd/provisionerdserver/provisionerdserver_test.go index 9fdbaa3e3864f..1ee97aa7ab62b 100644 --- a/coderd/provisionerdserver/provisionerdserver_test.go +++ b/coderd/provisionerdserver/provisionerdserver_test.go @@ -1531,6 +1531,10 @@ func setup(t *testing.T, ignoreLogErrors bool) *provisionerdserver.Server { UserQuietHoursScheduleStore: testUserQuietHoursScheduleStore(), Tracer: trace.NewNoopTracerProvider().Tracer("noop"), DeploymentValues: &codersdk.DeploymentValues{}, + + // Negative values cause the debounce to never kick in. Tests that want + // to test debounce can override this value. + AcquireJobDebounce: -time.Minute, } } From 86581124f921172515c3928444f2cbd5574bbe39 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Wed, 19 Jul 2023 00:31:41 +0000 Subject: [PATCH 24/25] remove duration --- cli/testdata/coder_server_--help.golden | 5 ----- cli/testdata/server-config.yaml.golden | 4 ---- coderd/apidoc/docs.go | 7 ------- coderd/apidoc/swagger.json | 7 ------- .../provisionerdserver_test.go | 1 - coderd/schedule/autostop.go | 5 ++++- coderd/schedule/autostop_test.go | 1 - coderd/schedule/template.go | 11 ----------- coderd/schedule/user.go | 7 ------- codersdk/deployment.go | 16 ++++------------ codersdk/users.go | 2 -- docs/api/enterprise.md | 4 ---- docs/api/general.md | 3 +-- docs/api/schemas.md | 18 ++++++------------ docs/cli/server.md | 11 ----------- enterprise/cli/server.go | 1 - .../cli/testdata/coder_server_--help.golden | 5 ----- enterprise/coderd/coderd.go | 8 ++------ .../coderd/coderdenttest/coderdenttest.go | 1 - enterprise/coderd/schedule/user.go | 9 +-------- enterprise/coderd/users.go | 2 -- enterprise/coderd/users_test.go | 4 ---- site/src/api/typesGenerated.ts | 3 --- 23 files changed, 18 insertions(+), 117 deletions(-) diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index 4cdb8005e8e2a..0f77d42b76ec3 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -388,11 +388,6 @@ workspaces stopping during the day due to template max TTL. one hour and minute can be specified (ranges or comma separated values are not supported). - --quiet-hours-window-duration duration, $CODER_QUIET_HOURS_WINDOW_DURATION (default: 4h0m0s) - The duration of quiet hours windows when triggered by cron. Workspaces - can only be stopped due to max TTL during this window. Must be at - least 1 hour. - ⚠️ 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. diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index 772bf1a5fe634..1800fb24ac51a 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -378,7 +378,3 @@ userQuietHoursSchedule: # values are not supported). # (default: , type: string) defaultQuietHoursSchedule: "" - # The duration of quiet hours windows when triggered by cron. Workspaces can only - # be stopped due to max TTL during this window. Must be at least 1 hour. - # (default: 4h0m0s, type: duration) - quietHoursWindowDuration: 4h0m0s diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 66f90455a66e9..40f82318f9110 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -9660,19 +9660,12 @@ const docTemplate = `{ "properties": { "default_schedule": { "type": "string" - }, - "window_duration": { - "type": "integer" } } }, "codersdk.UserQuietHoursScheduleResponse": { "type": "object", "properties": { - "duration": { - "description": "Duration is the duration of the quiet hours window.", - "type": "integer" - }, "next": { "description": "Next is the next time that the quiet hours window will start.", "type": "string", diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index bc7f9775c6beb..4080c20fbd202 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -8732,19 +8732,12 @@ "properties": { "default_schedule": { "type": "string" - }, - "window_duration": { - "type": "integer" } } }, "codersdk.UserQuietHoursScheduleResponse": { "type": "object", "properties": { - "duration": { - "description": "Duration is the duration of the quiet hours window.", - "type": "integer" - }, "next": { "description": "Next is the next time that the quiet hours window will start.", "type": "string", diff --git a/coderd/provisionerdserver/provisionerdserver_test.go b/coderd/provisionerdserver/provisionerdserver_test.go index 1ee97aa7ab62b..26d264eabff34 100644 --- a/coderd/provisionerdserver/provisionerdserver_test.go +++ b/coderd/provisionerdserver/provisionerdserver_test.go @@ -1261,7 +1261,6 @@ func TestCompleteJob(t *testing.T) { return schedule.UserQuietHoursScheduleOptions{ Schedule: sched, UserSet: false, - Duration: 4 * time.Hour, }, nil }, } diff --git a/coderd/schedule/autostop.go b/coderd/schedule/autostop.go index 96b8c07bab60a..6934640045506 100644 --- a/coderd/schedule/autostop.go +++ b/coderd/schedule/autostop.go @@ -286,6 +286,8 @@ func GetMondayOfWeek(loc *time.Location, n int64) (time.Time, error) { y, m, d := monday.Date() monday = time.Date(y, m, d, 0, 0, 0, 0, loc) if monday.Weekday() != time.Monday { + // This condition should never be hit, but we have a check for it just + // in case. return time.Time{}, xerrors.Errorf("calculated incorrect Monday for week %v since epoch (actual weekday %q)", n, monday.Weekday()) } return monday, nil @@ -313,7 +315,8 @@ func GetNextApplicableMondayOfNWeeks(now time.Time, n int64) (time.Time, error) return now, nil } - // Loop until we find a week that doesn't fail. + // Loop until we find a week that doesn't fail. This should never loop, but + // we account for failures just in case. var lastErr error for i := int64(0); i < 3; i++ { monday, err := GetMondayOfWeek(now.Location(), week+i) diff --git a/coderd/schedule/autostop_test.go b/coderd/schedule/autostop_test.go index 15dcc423efdea..3a7fc105a2ebe 100644 --- a/coderd/schedule/autostop_test.go +++ b/coderd/schedule/autostop_test.go @@ -439,7 +439,6 @@ func TestCalculateAutoStop(t *testing.T) { return schedule.UserQuietHoursScheduleOptions{ Schedule: sched, UserSet: false, - Duration: 4 * time.Hour, }, nil }, } diff --git a/coderd/schedule/template.go b/coderd/schedule/template.go index 160867363557a..826d1c26ead1b 100644 --- a/coderd/schedule/template.go +++ b/coderd/schedule/template.go @@ -52,17 +52,6 @@ type TemplateRestartRequirement struct { Weeks int64 } -// Days returns the days of the week that the workspace must be restarted. -func (r TemplateRestartRequirement) Days() []time.Weekday { - days := make([]time.Weekday, 0, 7) - for i, day := range DaysOfWeek { - if r.DaysOfWeek&(1<duration | -| Environment | $CODER_QUIET_HOURS_WINDOW_DURATION | -| YAML | userQuietHoursSchedule.quietHoursWindowDuration | -| Default | 4h0m0s | - -The duration of quiet hours windows when triggered by cron. Workspaces can only be stopped due to max TTL during this window. Must be at least 1 hour. - ### --redirect-to-access-url | | | diff --git a/enterprise/cli/server.go b/enterprise/cli/server.go index 3386a459de476..b0561a0de1850 100644 --- a/enterprise/cli/server.go +++ b/enterprise/cli/server.go @@ -66,7 +66,6 @@ func (r *RootCmd) server() *clibase.Cmd { DERPServerRegionID: int(options.DeploymentValues.DERP.Server.RegionID.Value()), ProxyHealthInterval: options.DeploymentValues.ProxyHealthStatusInterval.Value(), DefaultQuietHoursSchedule: options.DeploymentValues.UserQuietHoursSchedule.DefaultSchedule.Value(), - QuietHoursWindowDuration: options.DeploymentValues.UserQuietHoursSchedule.WindowDuration.Value(), } api, err := coderd.New(ctx, o) diff --git a/enterprise/cli/testdata/coder_server_--help.golden b/enterprise/cli/testdata/coder_server_--help.golden index 4cdb8005e8e2a..0f77d42b76ec3 100644 --- a/enterprise/cli/testdata/coder_server_--help.golden +++ b/enterprise/cli/testdata/coder_server_--help.golden @@ -388,11 +388,6 @@ workspaces stopping during the day due to template max TTL. one hour and minute can be specified (ranges or comma separated values are not supported). - --quiet-hours-window-duration duration, $CODER_QUIET_HOURS_WINDOW_DURATION (default: 4h0m0s) - The duration of quiet hours windows when triggered by cron. Workspaces - can only be stopped due to max TTL during this window. Must be at - least 1 hour. - ⚠️ 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. diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index b477adc081efc..9b195830cae5f 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -53,9 +53,6 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { if options.Options.Authorizer == nil { options.Options.Authorizer = rbac.NewCachingAuthorizer(options.PrometheusRegistry) } - if options.QuietHoursWindowDuration < time.Hour { - return nil, xerrors.Errorf("quiet hours window duration (%v) must be at least 1 hour", options.QuietHoursWindowDuration) - } ctx, cancelFunc := context.WithCancel(ctx) api := &API{ @@ -340,8 +337,7 @@ type Options struct { DERPServerRegionID int // Used for user quiet hours schedules. - DefaultQuietHoursSchedule string // cron schedule, if empty user quiet hours schedules are disabled - QuietHoursWindowDuration time.Duration // how long each window should last + DefaultQuietHoursSchedule string // cron schedule, if empty user quiet hours schedules are disabled EntitlementsUpdateInterval time.Duration ProxyHealthInterval time.Duration @@ -487,7 +483,7 @@ func (api *API) updateEntitlements(ctx context.Context) error { } enterpriseTemplateStore.UseRestartRequirement.Store(true) - quietHoursStore, err := schedule.NewEnterpriseUserQuietHoursScheduleStore(api.DefaultQuietHoursSchedule, api.QuietHoursWindowDuration) + 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 { diff --git a/enterprise/coderd/coderdenttest/coderdenttest.go b/enterprise/coderd/coderdenttest/coderdenttest.go index 7a54e3d622223..e8eed329a29d0 100644 --- a/enterprise/coderd/coderdenttest/coderdenttest.go +++ b/enterprise/coderd/coderdenttest/coderdenttest.go @@ -92,7 +92,6 @@ func NewWithAPI(t *testing.T, options *Options) ( Keys: Keys, ProxyHealthInterval: options.ProxyHealthInterval, DefaultQuietHoursSchedule: oop.DeploymentValues.UserQuietHoursSchedule.DefaultSchedule.Value(), - QuietHoursWindowDuration: oop.DeploymentValues.UserQuietHoursSchedule.WindowDuration.Value(), }) require.NoError(t, err) setHandler(coderAPI.AGPL.RootHandler) diff --git a/enterprise/coderd/schedule/user.go b/enterprise/coderd/schedule/user.go index 44c3f96e638d4..c7d76f86119c8 100644 --- a/enterprise/coderd/schedule/user.go +++ b/enterprise/coderd/schedule/user.go @@ -3,7 +3,6 @@ package schedule import ( "context" "strings" - "time" "github.com/google/uuid" "golang.org/x/xerrors" @@ -17,22 +16,17 @@ import ( // enterprise customers. type enterpriseUserQuietHoursScheduleStore struct { defaultSchedule string - windowDuration time.Duration } var _ agpl.UserQuietHoursScheduleStore = &enterpriseUserQuietHoursScheduleStore{} -func NewEnterpriseUserQuietHoursScheduleStore(defaultSchedule string, windowDuration time.Duration) (agpl.UserQuietHoursScheduleStore, error) { +func NewEnterpriseUserQuietHoursScheduleStore(defaultSchedule string) (agpl.UserQuietHoursScheduleStore, error) { if defaultSchedule == "" { return nil, xerrors.Errorf("default schedule must be set") } - if windowDuration < 1*time.Hour { - return nil, xerrors.Errorf("window duration must be greater than 1 hour") - } s := &enterpriseUserQuietHoursScheduleStore{ defaultSchedule: defaultSchedule, - windowDuration: windowDuration, } _, err := s.parseSchedule(defaultSchedule) @@ -66,7 +60,6 @@ func (s *enterpriseUserQuietHoursScheduleStore) parseSchedule(rawSchedule string return agpl.UserQuietHoursScheduleOptions{ Schedule: sched, UserSet: userSet, - Duration: s.windowDuration, }, nil } diff --git a/enterprise/coderd/users.go b/enterprise/coderd/users.go index 20627b64f70d7..3f1e61fddb10d 100644 --- a/enterprise/coderd/users.go +++ b/enterprise/coderd/users.go @@ -70,7 +70,6 @@ func (api *API) userQuietHoursSchedule(rw http.ResponseWriter, r *http.Request) UserSet: opts.UserSet, Time: opts.Schedule.Time(), Timezone: opts.Schedule.Location().String(), - Duration: opts.Duration, Next: opts.Schedule.Next(time.Now().In(opts.Schedule.Location())), }) } @@ -117,7 +116,6 @@ func (api *API) putUserQuietHoursSchedule(rw http.ResponseWriter, r *http.Reques UserSet: opts.UserSet, Time: opts.Schedule.Time(), Timezone: opts.Schedule.Location().String(), - Duration: opts.Duration, Next: opts.Schedule.Next(time.Now().In(opts.Schedule.Location())), }) } diff --git a/enterprise/coderd/users_test.go b/enterprise/coderd/users_test.go index 5882ae557c4d0..b908dfd9600bf 100644 --- a/enterprise/coderd/users_test.go +++ b/enterprise/coderd/users_test.go @@ -35,7 +35,6 @@ func TestUserQuietHours(t *testing.T) { dv := coderdtest.DeploymentValues(t) dv.UserQuietHoursSchedule.DefaultSchedule.Set(defaultQuietHoursSchedule) - dv.UserQuietHoursSchedule.WindowDuration.Set("8h") // default is 4h dv.Experiments.Set(string(codersdk.ExperimentTemplateRestartRequirement)) client, user := coderdenttest.New(t, &coderdenttest.Options{ @@ -58,7 +57,6 @@ func TestUserQuietHours(t *testing.T) { require.False(t, sched1.UserSet) require.Equal(t, defaultScheduleParsed.Time(), sched1.Time) require.Equal(t, defaultScheduleParsed.Location().String(), sched1.Timezone) - require.Equal(t, dv.UserQuietHoursSchedule.WindowDuration.Value(), sched1.Duration) require.WithinDuration(t, defaultScheduleParsed.Next(time.Now()), sched1.Next, 15*time.Second) // Set their quiet hours. @@ -82,7 +80,6 @@ func TestUserQuietHours(t *testing.T) { require.True(t, sched2.UserSet) require.Equal(t, customScheduleParsed.Time(), sched2.Time) require.Equal(t, customScheduleParsed.Location().String(), sched2.Timezone) - require.Equal(t, dv.UserQuietHoursSchedule.WindowDuration.Value(), sched2.Duration) require.WithinDuration(t, customScheduleParsed.Next(time.Now()), sched2.Next, 15*time.Second) // Get quiet hours for a user that has them set. @@ -92,7 +89,6 @@ func TestUserQuietHours(t *testing.T) { require.True(t, sched3.UserSet) require.Equal(t, customScheduleParsed.Time(), sched3.Time) require.Equal(t, customScheduleParsed.Location().String(), sched3.Timezone) - require.Equal(t, dv.UserQuietHoursSchedule.WindowDuration.Value(), sched3.Duration) require.WithinDuration(t, customScheduleParsed.Next(time.Now()), sched3.Next, 15*time.Second) // Try setting a garbage schedule. diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 999894bf75555..9ac631119a025 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1123,7 +1123,6 @@ export interface UserLoginType { // From codersdk/deployment.go export interface UserQuietHoursScheduleConfig { readonly default_schedule: string - readonly window_duration: number } // From codersdk/users.go @@ -1132,8 +1131,6 @@ export interface UserQuietHoursScheduleResponse { readonly user_set: boolean readonly time: string readonly timezone: string - // This is likely an enum in an external package ("time.Duration") - readonly duration: number readonly next: string } From 85142a68d3210aee62df058e58511d3754d70ec3 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Thu, 20 Jul 2023 13:22:22 +0000 Subject: [PATCH 25/25] fixup! Merge branch 'main' into dean/user-maintenance-window --- .../migrations/000139_template_restart_requirement.down.sql | 5 ++++- coderd/database/modelqueries.go | 5 +++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/coderd/database/migrations/000139_template_restart_requirement.down.sql b/coderd/database/migrations/000139_template_restart_requirement.down.sql index 6e13eab568d57..f882ada1fd1c1 100644 --- a/coderd/database/migrations/000139_template_restart_requirement.down.sql +++ b/coderd/database/migrations/000139_template_restart_requirement.down.sql @@ -1,5 +1,9 @@ BEGIN; +-- Delete the new version of the template_with_users view to remove the column +-- dependency. +DROP VIEW template_with_users; + ALTER TABLE templates DROP COLUMN restart_requirement_days_of_week, DROP COLUMN restart_requirement_weeks; @@ -7,7 +11,6 @@ ALTER TABLE templates ALTER TABLE users DROP COLUMN quiet_hours_schedule; -- Restore the old version of the template_with_users view. -DROP VIEW template_with_users; CREATE VIEW template_with_users AS diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 790ce292721f5..0682b930baaf0 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -83,6 +83,10 @@ func (q *sqlQuerier) GetAuthorizedTemplates(ctx context.Context, arg GetTemplate &i.FailureTTL, &i.InactivityTTL, &i.LockedTTL, + &i.RestartRequirementDaysOfWeek, + &i.RestartRequirementWeeks, + &i.CreatedByAvatarURL, + &i.CreatedByUsername, ); err != nil { return nil, err } @@ -302,6 +306,7 @@ func (q *sqlQuerier) GetAuthorizedUsers(ctx context.Context, arg GetUsersParams, &i.AvatarURL, &i.Deleted, &i.LastSeenAt, + &i.QuietHoursSchedule, &i.Count, ); err != nil { return nil, err