From a7e3bb04841226dee4e6ec32c9d7293b126204d3 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Fri, 31 Mar 2023 17:48:35 +0000 Subject: [PATCH 1/9] feat: allow disabling autostart and custom autostop for template --- cli/server.go | 9 +- coderd/activitybump_test.go | 33 +--- coderd/apidoc/docs.go | 6 + coderd/apidoc/swagger.json | 6 + .../autobuild/executor/lifecycle_executor.go | 68 ++++--- .../executor/lifecycle_executor_test.go | 53 +++++- coderd/coderd.go | 15 +- coderd/coderdtest/coderdtest.go | 10 +- coderd/database/dbauthz/system.go | 4 + coderd/database/dbfake/databasefake.go | 27 +++ coderd/database/dump.sql | 8 +- ..._template_disable_user_scheduling.down.sql | 3 + ...11_template_disable_user_scheduling.up.sql | 9 + coderd/database/models.go | 4 + coderd/database/querier.go | 1 + coderd/database/queries.sql.go | 125 +++++++++++-- coderd/database/queries/templates.sql | 6 +- coderd/database/queries/workspaces.sql | 39 ++++ .../provisionerdserver/provisionerdserver.go | 8 +- .../provisionerdserver_test.go | 173 +++++++++--------- coderd/schedule/mock.go | 32 ++++ coderd/schedule/template.go | 16 +- coderd/templates.go | 29 ++- coderd/templates_test.go | 8 +- coderd/workspaces.go | 20 ++ coderd/workspaces_test.go | 94 ++++++++++ codersdk/templates.go | 4 + docs/admin/audit-logs.md | 22 +-- docs/api/schemas.md | 4 + docs/api/templates.md | 12 ++ enterprise/audit/table.go | 2 + enterprise/coderd/provisionerdaemons.go | 20 +- site/src/api/typesGenerated.ts | 4 + 33 files changed, 660 insertions(+), 214 deletions(-) create mode 100644 coderd/database/migrations/000111_template_disable_user_scheduling.down.sql create mode 100644 coderd/database/migrations/000111_template_disable_user_scheduling.up.sql create mode 100644 coderd/schedule/mock.go diff --git a/cli/server.go b/cli/server.go index b6fa7c31b647c..047f3fd05062f 100644 --- a/cli/server.go +++ b/cli/server.go @@ -30,6 +30,7 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "time" "github.com/coreos/go-oidc/v3/oidc" @@ -61,7 +62,6 @@ import ( "github.com/coder/coder/cli/cliui" "github.com/coder/coder/cli/config" "github.com/coder/coder/coderd" - "github.com/coder/coder/coderd/autobuild/executor" "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/database/dbfake" "github.com/coder/coder/coderd/database/dbpurge" @@ -72,6 +72,7 @@ import ( "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" "github.com/coder/coder/coderd/prometheusmetrics" + "github.com/coder/coder/coderd/schedule" "github.com/coder/coder/coderd/telemetry" "github.com/coder/coder/coderd/tracing" "github.com/coder/coder/coderd/updatecheck" @@ -632,6 +633,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. LoginRateLimit: loginRateLimit, FilesRateLimit: filesRateLimit, HTTPClient: httpClient, + TemplateScheduleStore: &atomic.Pointer[schedule.TemplateScheduleStore]{}, SSHConfig: codersdk.SSHConfigResponse{ HostnamePrefix: cfg.SSHConfig.DeploymentName.String(), SSHConfigOptions: configSSHOptions, @@ -1017,11 +1019,6 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. return xerrors.Errorf("notify systemd: %w", err) } - autobuildPoller := time.NewTicker(cfg.AutobuildPollInterval.Value()) - defer autobuildPoller.Stop() - autobuildExecutor := executor.New(ctx, options.Database, logger, autobuildPoller.C) - autobuildExecutor.Run() - // Currently there is no way to ask the server to shut // itself down, so any exit signal will result in a non-zero // exit of the server. diff --git a/coderd/activitybump_test.go b/coderd/activitybump_test.go index 1548eddbb057c..a3f70834c9999 100644 --- a/coderd/activitybump_test.go +++ b/coderd/activitybump_test.go @@ -16,29 +16,6 @@ import ( "github.com/coder/coder/testutil" ) -type mockTemplateScheduleStore struct { - getFn func(ctx context.Context, db database.Store, templateID uuid.UUID) (schedule.TemplateScheduleOptions, error) - setFn func(ctx context.Context, db database.Store, template database.Template, options schedule.TemplateScheduleOptions) (database.Template, error) -} - -var _ schedule.TemplateScheduleStore = mockTemplateScheduleStore{} - -func (m mockTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.Context, db database.Store, templateID uuid.UUID) (schedule.TemplateScheduleOptions, error) { - if m.getFn != nil { - return m.getFn(ctx, db, templateID) - } - - return schedule.NewAGPLTemplateScheduleStore().GetTemplateScheduleOptions(ctx, db, templateID) -} - -func (m mockTemplateScheduleStore) SetTemplateScheduleOptions(ctx context.Context, db database.Store, template database.Template, options schedule.TemplateScheduleOptions) (database.Template, error) { - if m.setFn != nil { - return m.setFn(ctx, db, template, options) - } - - return schedule.NewAGPLTemplateScheduleStore().SetTemplateScheduleOptions(ctx, db, template, options) -} - func TestWorkspaceActivityBump(t *testing.T) { t.Parallel() @@ -57,12 +34,12 @@ func TestWorkspaceActivityBump(t *testing.T) { // Agent stats trigger the activity bump, so we want to report // very frequently in tests. AgentStatsRefreshInterval: time.Millisecond * 100, - TemplateScheduleStore: mockTemplateScheduleStore{ - getFn: func(ctx context.Context, db database.Store, templateID uuid.UUID) (schedule.TemplateScheduleOptions, error) { + TemplateScheduleStore: schedule.MockTemplateScheduleStore{ + GetFn: func(ctx context.Context, db database.Store, templateID uuid.UUID) (schedule.TemplateScheduleOptions, error) { return schedule.TemplateScheduleOptions{ - UserSchedulingEnabled: true, - DefaultTTL: ttl, - MaxTTL: maxTTL, + UserAutoStopEnabled: true, + DefaultTTL: ttl, + MaxTTL: maxTTL, }, nil }, }, diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 9561e0b511a6d..6e35657017b84 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -8062,6 +8062,12 @@ const docTemplate = `{ "type": "string", "format": "uuid" }, + "allow_user_auto_start": { + "type": "boolean" + }, + "allow_user_auto_stop": { + "type": "boolean" + }, "allow_user_cancel_workspace_jobs": { "type": "boolean" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index fd9c8a7e8a3ff..4d9b843d7d35f 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -7245,6 +7245,12 @@ "type": "string", "format": "uuid" }, + "allow_user_auto_start": { + "type": "boolean" + }, + "allow_user_auto_stop": { + "type": "boolean" + }, "allow_user_cancel_workspace_jobs": { "type": "boolean" }, diff --git a/coderd/autobuild/executor/lifecycle_executor.go b/coderd/autobuild/executor/lifecycle_executor.go index f6b4d0db12d87..a08b07df10df3 100644 --- a/coderd/autobuild/executor/lifecycle_executor.go +++ b/coderd/autobuild/executor/lifecycle_executor.go @@ -3,6 +3,7 @@ package executor import ( "context" "encoding/json" + "sync/atomic" "time" "github.com/google/uuid" @@ -18,11 +19,12 @@ import ( // Executor automatically starts or stops workspaces. type Executor struct { - ctx context.Context - db database.Store - log slog.Logger - tick <-chan time.Time - statsCh chan<- Stats + ctx context.Context + db database.Store + templateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore] + log slog.Logger + tick <-chan time.Time + statsCh chan<- Stats } // Stats contains information about one run of Executor. @@ -33,13 +35,14 @@ type Stats struct { } // New returns a new autobuild executor. -func New(ctx context.Context, db database.Store, log slog.Logger, tick <-chan time.Time) *Executor { +func New(ctx context.Context, db database.Store, tss *atomic.Pointer[schedule.TemplateScheduleStore], log slog.Logger, tick <-chan time.Time) *Executor { le := &Executor{ //nolint:gocritic // Autostart has a limited set of permissions. - ctx: dbauthz.AsAutostart(ctx), - db: db, - tick: tick, - log: log, + ctx: dbauthz.AsAutostart(ctx), + db: db, + templateScheduleStore: tss, + tick: tick, + log: log, } return le } @@ -102,21 +105,11 @@ func (e *Executor) runOnce(t time.Time) Stats { // NOTE: If a workspace build is created with a given TTL and then the user either // changes or unsets the TTL, the deadline for the workspace build will not // have changed. This behavior is as expected per #2229. - workspaceRows, err := e.db.GetWorkspaces(e.ctx, database.GetWorkspacesParams{ - Deleted: false, - }) + workspaces, err := e.db.GetWorkspacesEligibleForAutoStartStop(e.ctx, t) if err != nil { e.log.Error(e.ctx, "get workspaces for autostart or autostop", slog.Error(err)) return stats } - workspaces := database.ConvertWorkspaceRows(workspaceRows) - - var eligibleWorkspaceIDs []uuid.UUID - for _, ws := range workspaces { - if isEligibleForAutoStartStop(ws) { - eligibleWorkspaceIDs = append(eligibleWorkspaceIDs, ws.ID) - } - } // We only use errgroup here for convenience of API, not for early // cancellation. This means we only return nil errors in th eg.Go. @@ -124,8 +117,8 @@ func (e *Executor) runOnce(t time.Time) Stats { // Limit the concurrency to avoid overloading the database. eg.SetLimit(10) - for _, wsID := range eligibleWorkspaceIDs { - wsID := wsID + for _, ws := range workspaces { + wsID := ws.ID log := e.log.With(slog.F("workspace_id", wsID)) eg.Go(func() error { @@ -137,9 +130,6 @@ func (e *Executor) runOnce(t time.Time) Stats { log.Error(e.ctx, "get workspace autostart failed", slog.Error(err)) return nil } - if !isEligibleForAutoStartStop(ws) { - return nil - } // Determine the workspace state based on its latest build. priorHistory, err := db.GetLatestWorkspaceBuildByWorkspaceID(e.ctx, ws.ID) @@ -148,6 +138,16 @@ func (e *Executor) runOnce(t time.Time) Stats { return nil } + templateSchedule, err := (*(e.templateScheduleStore.Load())).GetTemplateScheduleOptions(e.ctx, db, ws.TemplateID) + if err != nil { + log.Warn(e.ctx, "get template schedule options", slog.Error(err)) + return nil + } + + if !isEligibleForAutoStartStop(ws, priorHistory, templateSchedule) { + return nil + } + priorJob, err := db.GetProvisionerJobByID(e.ctx, priorHistory.JobID) if err != nil { log.Warn(e.ctx, "get last provisioner job for workspace %q: %w", slog.Error(err)) @@ -198,8 +198,20 @@ func (e *Executor) runOnce(t time.Time) Stats { return stats } -func isEligibleForAutoStartStop(ws database.Workspace) bool { - return !ws.Deleted && (ws.AutostartSchedule.String != "" || ws.Ttl.Int64 > 0) +func isEligibleForAutoStartStop(ws database.Workspace, priorHistory database.WorkspaceBuild, templateSchedule schedule.TemplateScheduleOptions) bool { + if ws.Deleted { + return false + } + if templateSchedule.UserAutoStartEnabled && ws.AutostartSchedule.Valid && ws.AutostartSchedule.String != "" { + return true + } + // Don't check the template schedule to see whether it allows autostop, this + // is done during the build when determining the deadline. + if priorHistory.Transition == database.WorkspaceTransitionStart && !priorHistory.Deadline.IsZero() { + return true + } + + return false } func getNextTransition( diff --git a/coderd/autobuild/executor/lifecycle_executor_test.go b/coderd/autobuild/executor/lifecycle_executor_test.go index 2548e317b69ff..b2fcf0c569431 100644 --- a/coderd/autobuild/executor/lifecycle_executor_test.go +++ b/coderd/autobuild/executor/lifecycle_executor_test.go @@ -6,9 +6,10 @@ import ( "testing" "time" - "go.uber.org/goleak" - "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/goleak" "github.com/coder/coder/coderd/autobuild/executor" "github.com/coder/coder/coderd/coderdtest" @@ -18,9 +19,6 @@ import ( "github.com/coder/coder/codersdk" "github.com/coder/coder/provisioner/echo" "github.com/coder/coder/provisionersdk/proto" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func TestExecutorAutostartOK(t *testing.T) { @@ -605,6 +603,51 @@ func TestExecutorAutostartWithParameters(t *testing.T) { mustWorkspaceParameters(t, client, workspace.LatestBuild.ID) } +func TestExecutorAutostartTemplateDisabled(t *testing.T) { + t.Parallel() + + var ( + sched = mustSchedule(t, "CRON_TZ=UTC 0 * * * *") + tickCh = make(chan time.Time) + statsCh = make(chan executor.Stats) + + client = coderdtest.New(t, &coderdtest.Options{ + AutobuildTicker: tickCh, + IncludeProvisionerDaemon: true, + AutobuildStats: statsCh, + TemplateScheduleStore: schedule.MockTemplateScheduleStore{ + GetFn: func(_ context.Context, _ database.Store, _ uuid.UUID) (schedule.TemplateScheduleOptions, error) { + return schedule.TemplateScheduleOptions{ + UserAutoStartEnabled: false, + UserAutoStopEnabled: true, + DefaultTTL: 0, + MaxTTL: 0, + }, nil + }, + }, + }) + // futureTime = time.Now().Add(time.Hour) + // futureTimeCron = fmt.Sprintf("%d %d * * *", futureTime.Minute(), futureTime.Hour()) + // Given: we have a user with a workspace configured to autostart some time in the future + workspace = mustProvisionWorkspace(t, client, func(cwr *codersdk.CreateWorkspaceRequest) { + cwr.AutostartSchedule = ptr.Ref(sched.String()) + }) + ) + // Given: workspace is stopped + workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + + // When: the autobuild executor ticks before the next scheduled time + go func() { + tickCh <- sched.Next(workspace.LatestBuild.CreatedAt).Add(time.Minute) + close(tickCh) + }() + + // Then: nothing should happen + stats := <-statsCh + assert.NoError(t, stats.Error) + assert.Len(t, stats.Transitions, 0) +} + func mustProvisionWorkspace(t *testing.T, client *codersdk.Client, mut ...func(*codersdk.CreateWorkspaceRequest)) codersdk.Workspace { t.Helper() user := coderdtest.CreateFirstUser(t, client) diff --git a/coderd/coderd.go b/coderd/coderd.go index 48e86caa6e118..c3bd7e3588a2f 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -120,7 +120,7 @@ type Options struct { DERPMap *tailcfg.DERPMap SwaggerEndpoint bool SetUserGroups func(ctx context.Context, tx database.Store, userID uuid.UUID, groupNames []string) error - TemplateScheduleStore schedule.TemplateScheduleStore + TemplateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore] // AppSigningKey denotes the symmetric key to use for signing app tickets. // The key must be 64 bytes long. AppSigningKey []byte @@ -231,7 +231,11 @@ func New(options *Options) *API { } } if options.TemplateScheduleStore == nil { - options.TemplateScheduleStore = schedule.NewAGPLTemplateScheduleStore() + options.TemplateScheduleStore = &atomic.Pointer[schedule.TemplateScheduleStore]{} + } + if options.TemplateScheduleStore.Load() == nil { + v := schedule.NewAGPLTemplateScheduleStore() + options.TemplateScheduleStore.Store(&v) } if len(options.AppSigningKey) != 64 { panic("coderd: AppSigningKey must be 64 bytes long") @@ -292,7 +296,7 @@ func New(options *Options) *API { ), metricsCache: metricsCache, Auditor: atomic.Pointer[audit.Auditor]{}, - TemplateScheduleStore: atomic.Pointer[schedule.TemplateScheduleStore]{}, + TemplateScheduleStore: options.TemplateScheduleStore, Experiments: experiments, } if options.UpdateCheckOptions != nil { @@ -309,7 +313,6 @@ func New(options *Options) *API { } api.Auditor.Store(&options.Auditor) - api.TemplateScheduleStore.Store(&options.TemplateScheduleStore) api.workspaceAgentCache = wsconncache.New(api.dialWorkspaceAgentTailnet, 0) api.TailnetCoordinator.Store(&options.TailnetCoordinator) @@ -745,7 +748,7 @@ type API struct { WorkspaceClientCoordinateOverride atomic.Pointer[func(rw http.ResponseWriter) bool] TailnetCoordinator atomic.Pointer[tailnet.Coordinator] QuotaCommitter atomic.Pointer[proto.QuotaCommitter] - TemplateScheduleStore atomic.Pointer[schedule.TemplateScheduleStore] + TemplateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore] HTTPAuth *HTTPAuthorizer @@ -855,7 +858,7 @@ func (api *API) CreateInMemoryProvisionerDaemon(ctx context.Context, debounce ti Tags: tags, QuotaCommitter: &api.QuotaCommitter, Auditor: &api.Auditor, - TemplateScheduleStore: &api.TemplateScheduleStore, + TemplateScheduleStore: api.TemplateScheduleStore, AcquireJobDebounce: debounce, Logger: api.Logger.Named(fmt.Sprintf("provisionerd-%s", daemon.Name)), }) diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index a6e8cf4a2f85f..d2f56b6f696e7 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -26,6 +26,7 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "testing" "time" @@ -209,10 +210,17 @@ func NewOptions(t *testing.T, options *Options) (func(http.Handler), context.Can options.FilesRateLimit = -1 } + var templateScheduleStore atomic.Pointer[schedule.TemplateScheduleStore] + if options.TemplateScheduleStore == nil { + options.TemplateScheduleStore = schedule.NewAGPLTemplateScheduleStore() + } + templateScheduleStore.Store(&options.TemplateScheduleStore) + ctx, cancelFunc := context.WithCancel(context.Background()) lifecycleExecutor := executor.New( ctx, options.Database, + &templateScheduleStore, slogtest.Make(t, nil).Named("autobuild.executor").Leveled(slog.LevelDebug), options.AutobuildTicker, ).WithStatsChannel(options.AutobuildStats) @@ -306,7 +314,7 @@ func NewOptions(t *testing.T, options *Options) (func(http.Handler), context.Can FilesRateLimit: options.FilesRateLimit, Authorizer: options.Authorizer, Telemetry: telemetry.NewNoop(), - TemplateScheduleStore: options.TemplateScheduleStore, + TemplateScheduleStore: &templateScheduleStore, TLSCertificates: options.TLSCertificates, TrialGenerator: options.TrialGenerator, DERPMap: &tailcfg.DERPMap{ diff --git a/coderd/database/dbauthz/system.go b/coderd/database/dbauthz/system.go index b4758afd0b2b6..248159ff5c53b 100644 --- a/coderd/database/dbauthz/system.go +++ b/coderd/database/dbauthz/system.go @@ -306,6 +306,10 @@ func (q *querier) GetDeploymentWorkspaceStats(ctx context.Context) (database.Get return q.db.GetDeploymentWorkspaceStats(ctx) } +func (q *querier) GetWorkspacesEligibleForAutoStartStop(ctx context.Context, now time.Time) ([]database.Workspace, error) { + return q.db.GetWorkspacesEligibleForAutoStartStop(ctx, now) +} + func (q *querier) GetParameterSchemasCreatedAfter(ctx context.Context, createdAt time.Time) ([]database.ParameterSchema, error) { if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceSystem); err != nil { return nil, err diff --git a/coderd/database/dbfake/databasefake.go b/coderd/database/dbfake/databasefake.go index 351707748b3c9..9c720b8eb3c3a 100644 --- a/coderd/database/dbfake/databasefake.go +++ b/coderd/database/dbfake/databasefake.go @@ -1901,6 +1901,8 @@ func (q *fakeQuerier) UpdateTemplateScheduleByID(_ context.Context, arg database if tpl.ID != arg.ID { continue } + tpl.AllowUserAutoStart = arg.AllowUserAutoStart + tpl.AllowUserAutoStop = arg.AllowUserAutoStop tpl.UpdatedAt = database.Now() tpl.DefaultTTL = arg.DefaultTTL tpl.MaxTTL = arg.MaxTTL @@ -3921,6 +3923,31 @@ func (q *fakeQuerier) GetWorkspaceAgentStats(_ context.Context, createdAfter tim return stats, nil } +func (q *fakeQuerier) GetWorkspacesEligibleForAutoStartStop(ctx context.Context, now time.Time) ([]database.Workspace, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + workspaces := []database.Workspace{} + for _, workspace := range q.workspaces { + build, err := q.getLatestWorkspaceBuildByWorkspaceIDNoLock(ctx, workspace.ID) + if err != nil { + return nil, err + } + + if build.Transition == database.WorkspaceTransitionStart && !build.Deadline.IsZero() && build.Deadline.Before(now) { + workspaces = append(workspaces, workspace) + continue + } + + if build.Transition == database.WorkspaceTransitionStop && workspace.AutostartSchedule.Valid { + workspaces = append(workspaces, workspace) + continue + } + } + + return workspaces, nil +} + func (q *fakeQuerier) UpdateWorkspaceTTLToBeWithinTemplateMax(_ context.Context, arg database.UpdateWorkspaceTTLToBeWithinTemplateMaxParams) error { if err := validateDatabaseType(arg); err != nil { return err diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 2f99cf2a073e3..97c23d1cf8569 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -442,7 +442,9 @@ 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 + max_ttl bigint DEFAULT '0'::bigint NOT NULL, + allow_user_auto_start boolean DEFAULT true NOT NULL, + allow_user_auto_stop boolean DEFAULT true NOT NULL ); COMMENT ON COLUMN templates.default_ttl IS 'The default duration for auto-stop for workspaces created from this template.'; @@ -451,6 +453,10 @@ COMMENT ON COLUMN templates.display_name IS 'Display name is a custom, human-fri COMMENT ON COLUMN templates.allow_user_cancel_workspace_jobs IS 'Allow users to cancel in-progress workspace jobs.'; +COMMENT ON COLUMN templates.allow_user_auto_start IS 'Allow users to specify an auto-start schedule for workspaces (enterprise).'; + +COMMENT ON COLUMN templates.allow_user_auto_stop IS 'Allow users to specify custom auto-stop values for workspaces (enterprise).'; + CREATE TABLE user_links ( user_id uuid NOT NULL, login_type login_type NOT NULL, diff --git a/coderd/database/migrations/000111_template_disable_user_scheduling.down.sql b/coderd/database/migrations/000111_template_disable_user_scheduling.down.sql new file mode 100644 index 0000000000000..61ee33a171fdd --- /dev/null +++ b/coderd/database/migrations/000111_template_disable_user_scheduling.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE "templates" + DROP COLUMN "allow_user_auto_start", + DROP COLUMN "allow_user_auto_stop"; diff --git a/coderd/database/migrations/000111_template_disable_user_scheduling.up.sql b/coderd/database/migrations/000111_template_disable_user_scheduling.up.sql new file mode 100644 index 0000000000000..fb15f6ea34257 --- /dev/null +++ b/coderd/database/migrations/000111_template_disable_user_scheduling.up.sql @@ -0,0 +1,9 @@ +ALTER TABLE "templates" + ADD COLUMN "allow_user_auto_start" boolean DEFAULT true NOT NULL, + ADD COLUMN "allow_user_auto_stop" boolean DEFAULT true NOT NULL; + +COMMENT ON COLUMN "templates"."allow_user_auto_start" + IS 'Allow users to specify an auto-start schedule for workspaces (enterprise).'; + +COMMENT ON COLUMN "templates"."allow_user_auto_stop" + IS 'Allow users to specify custom auto-stop values for workspaces (enterprise).'; diff --git a/coderd/database/models.go b/coderd/database/models.go index 82ade2f95c96e..a1bfae3a624a9 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1426,6 +1426,10 @@ type Template struct { // 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"` + // Allow users to specify an auto-start schedule for workspaces (enterprise). + AllowUserAutoStart bool `db:"allow_user_auto_start" json:"allow_user_auto_start"` + // Allow users to specify custom auto-stop values for workspaces (enterprise). + AllowUserAutoStop bool `db:"allow_user_auto_stop" json:"allow_user_auto_stop"` } type TemplateVersion struct { diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 4ef49a931fbf4..05e708968a345 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -152,6 +152,7 @@ type sqlcQuerier interface { GetWorkspaceResourcesByJobIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceResource, error) GetWorkspaceResourcesCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceResource, error) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) ([]GetWorkspacesRow, error) + GetWorkspacesEligibleForAutoStartStop(ctx context.Context, now time.Time) ([]Workspace, error) InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) (APIKey, error) // We use the organization_id as the id // for simplicity since all users is diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 669c5b7a474d1..c100e11851a3b 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -3195,7 +3195,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 + 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_auto_start, allow_user_auto_stop FROM templates WHERE @@ -3225,13 +3225,15 @@ func (q *sqlQuerier) GetTemplateByID(ctx context.Context, id uuid.UUID) (Templat &i.DisplayName, &i.AllowUserCancelWorkspaceJobs, &i.MaxTTL, + &i.AllowUserAutoStart, + &i.AllowUserAutoStop, ) 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 + 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_auto_start, allow_user_auto_stop FROM templates WHERE @@ -3269,12 +3271,14 @@ func (q *sqlQuerier) GetTemplateByOrganizationAndName(ctx context.Context, arg G &i.DisplayName, &i.AllowUserCancelWorkspaceJobs, &i.MaxTTL, + &i.AllowUserAutoStart, + &i.AllowUserAutoStop, ) 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 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_auto_start, allow_user_auto_stop FROM templates ORDER BY (name, id) ASC ` @@ -3305,6 +3309,8 @@ func (q *sqlQuerier) GetTemplates(ctx context.Context) ([]Template, error) { &i.DisplayName, &i.AllowUserCancelWorkspaceJobs, &i.MaxTTL, + &i.AllowUserAutoStart, + &i.AllowUserAutoStop, ); err != nil { return nil, err } @@ -3321,7 +3327,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 + 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_auto_start, allow_user_auto_stop FROM templates WHERE @@ -3389,6 +3395,8 @@ func (q *sqlQuerier) GetTemplatesWithFilter(ctx context.Context, arg GetTemplate &i.DisplayName, &i.AllowUserCancelWorkspaceJobs, &i.MaxTTL, + &i.AllowUserAutoStart, + &i.AllowUserAutoStop, ); err != nil { return nil, err } @@ -3422,7 +3430,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 + ($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_auto_start, allow_user_auto_stop ` type InsertTemplateParams struct { @@ -3478,6 +3486,8 @@ func (q *sqlQuerier) InsertTemplate(ctx context.Context, arg InsertTemplateParam &i.DisplayName, &i.AllowUserCancelWorkspaceJobs, &i.MaxTTL, + &i.AllowUserAutoStart, + &i.AllowUserAutoStop, ) return i, err } @@ -3491,7 +3501,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 + 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_auto_start, allow_user_auto_stop ` type UpdateTemplateACLByIDParams struct { @@ -3521,6 +3531,8 @@ func (q *sqlQuerier) UpdateTemplateACLByID(ctx context.Context, arg UpdateTempla &i.DisplayName, &i.AllowUserCancelWorkspaceJobs, &i.MaxTTL, + &i.AllowUserAutoStart, + &i.AllowUserAutoStop, ) return i, err } @@ -3580,7 +3592,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 + 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_auto_start, allow_user_auto_stop ` type UpdateTemplateMetaByIDParams struct { @@ -3622,6 +3634,8 @@ func (q *sqlQuerier) UpdateTemplateMetaByID(ctx context.Context, arg UpdateTempl &i.DisplayName, &i.AllowUserCancelWorkspaceJobs, &i.MaxTTL, + &i.AllowUserAutoStart, + &i.AllowUserAutoStop, ) return i, err } @@ -3631,25 +3645,31 @@ UPDATE templates SET updated_at = $2, - default_ttl = $3, - max_ttl = $4 + allow_user_auto_start = $3, + allow_user_auto_stop = $4, + default_ttl = $5, + max_ttl = $6 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 + 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_auto_start, allow_user_auto_stop ` type UpdateTemplateScheduleByIDParams struct { - ID uuid.UUID `db:"id" json:"id"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` - DefaultTTL int64 `db:"default_ttl" json:"default_ttl"` - MaxTTL int64 `db:"max_ttl" json:"max_ttl"` + ID uuid.UUID `db:"id" json:"id"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + AllowUserAutoStart bool `db:"allow_user_auto_start" json:"allow_user_auto_start"` + AllowUserAutoStop bool `db:"allow_user_auto_stop" json:"allow_user_auto_stop"` + DefaultTTL int64 `db:"default_ttl" json:"default_ttl"` + MaxTTL int64 `db:"max_ttl" json:"max_ttl"` } func (q *sqlQuerier) UpdateTemplateScheduleByID(ctx context.Context, arg UpdateTemplateScheduleByIDParams) (Template, error) { row := q.db.QueryRowContext(ctx, updateTemplateScheduleByID, arg.ID, arg.UpdatedAt, + arg.AllowUserAutoStart, + arg.AllowUserAutoStop, arg.DefaultTTL, arg.MaxTTL, ) @@ -3672,6 +3692,8 @@ func (q *sqlQuerier) UpdateTemplateScheduleByID(ctx context.Context, arg UpdateT &i.DisplayName, &i.AllowUserCancelWorkspaceJobs, &i.MaxTTL, + &i.AllowUserAutoStart, + &i.AllowUserAutoStop, ) return i, err } @@ -7792,6 +7814,81 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) return items, nil } +const getWorkspacesEligibleForAutoStartStop = `-- name: GetWorkspacesEligibleForAutoStartStop :many +SELECT + workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at +FROM + workspaces +LEFT JOIN + workspace_builds ON workspace_builds.workspace_id = workspaces.id +WHERE + workspace_builds.build_number = ( + SELECT + MAX(build_number) + FROM + workspace_builds + WHERE + workspace_builds.workspace_id = workspaces.id + ) AND + + ( + -- If the workspace build was a start transition, the workspace is + -- potentially eligible for autostop if it's past the deadline. The + -- deadline is computed at build time upon success and is bumped based + -- on activity (up the max deadline if set). We don't need to check + -- license here since that's done when the values are written to the build. + ( + workspace_builds.transition = 'start'::workspace_transition AND + workspace_builds.deadline IS NOT NULL AND + workspace_builds.deadline < $1 :: timestamptz + ) OR + + -- If the workspace build was a stop transition, the workspace is + -- potentially eligible for autostart if it has a schedule set. The + -- caller must check if the template allows autostart in a license-aware + -- fashion as we cannot check it here. + ( + workspace_builds.transition = 'stop'::workspace_transition AND + workspaces.autostart_schedule IS NOT NULL + ) + ) +` + +func (q *sqlQuerier) GetWorkspacesEligibleForAutoStartStop(ctx context.Context, now time.Time) ([]Workspace, error) { + rows, err := q.db.QueryContext(ctx, getWorkspacesEligibleForAutoStartStop, now) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Workspace + for rows.Next() { + var i Workspace + if err := rows.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.OwnerID, + &i.OrganizationID, + &i.TemplateID, + &i.Deleted, + &i.Name, + &i.AutostartSchedule, + &i.Ttl, + &i.LastUsedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const insertWorkspace = `-- name: InsertWorkspace :one INSERT INTO workspaces ( diff --git a/coderd/database/queries/templates.sql b/coderd/database/queries/templates.sql index 03b9c9bccc954..7a112dce63657 100644 --- a/coderd/database/queries/templates.sql +++ b/coderd/database/queries/templates.sql @@ -115,8 +115,10 @@ UPDATE templates SET updated_at = $2, - default_ttl = $3, - max_ttl = $4 + allow_user_auto_start = $3, + allow_user_auto_stop = $4, + default_ttl = $5, + max_ttl = $6 WHERE id = $1 RETURNING diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index 1694e5203b3c8..4f5cd4db24692 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -392,3 +392,42 @@ SELECT failed_workspaces.count AS failed_workspaces, stopped_workspaces.count AS stopped_workspaces FROM pending_workspaces, building_workspaces, running_workspaces, failed_workspaces, stopped_workspaces; + +-- name: GetWorkspacesEligibleForAutoStartStop :many +SELECT + workspaces.* +FROM + workspaces +LEFT JOIN + workspace_builds ON workspace_builds.workspace_id = workspaces.id +WHERE + workspace_builds.build_number = ( + SELECT + MAX(build_number) + FROM + workspace_builds + WHERE + workspace_builds.workspace_id = workspaces.id + ) AND + + ( + -- If the workspace build was a start transition, the workspace is + -- potentially eligible for autostop if it's past the deadline. The + -- deadline is computed at build time upon success and is bumped based + -- on activity (up the max deadline if set). We don't need to check + -- license here since that's done when the values are written to the build. + ( + workspace_builds.transition = 'start'::workspace_transition AND + workspace_builds.deadline IS NOT NULL AND + workspace_builds.deadline < @now :: timestamptz + ) OR + + -- If the workspace build was a stop transition, the workspace is + -- potentially eligible for autostart if it has a schedule set. The + -- caller must check if the template allows autostart in a license-aware + -- fashion as we cannot check it here. + ( + workspace_builds.transition = 'stop'::workspace_transition AND + workspaces.autostart_schedule IS NOT NULL + ) + ); diff --git a/coderd/provisionerdserver/provisionerdserver.go b/coderd/provisionerdserver/provisionerdserver.go index 5f989f784d8e4..a23b0acbca14f 100644 --- a/coderd/provisionerdserver/provisionerdserver.go +++ b/coderd/provisionerdserver/provisionerdserver.go @@ -985,9 +985,13 @@ func (server *Server) CompleteJob(ctx context.Context, completed *proto.Complete if err != nil { return xerrors.Errorf("get template schedule options: %w", err) } - if !templateSchedule.UserSchedulingEnabled { - // The user is not permitted to set their own TTL. + 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.MaxTTL > 0 { maxDeadline = now.Add(templateSchedule.MaxTTL) diff --git a/coderd/provisionerdserver/provisionerdserver_test.go b/coderd/provisionerdserver/provisionerdserver_test.go index a3209bcf07908..e54f8e51be287 100644 --- a/coderd/provisionerdserver/provisionerdserver_test.go +++ b/coderd/provisionerdserver/provisionerdserver_test.go @@ -828,11 +828,12 @@ func TestCompleteJob(t *testing.T) { t.Parallel() cases := []struct { - name string - templateDefaultTTL time.Duration - templateMaxTTL time.Duration - workspaceTTL time.Duration - transition database.WorkspaceTransition + 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. @@ -840,76 +841,94 @@ func TestCompleteJob(t *testing.T) { expectedMaxTTL time.Duration }{ { - name: "OK", - templateDefaultTTL: 0, - templateMaxTTL: 0, - workspaceTTL: 0, - transition: database.WorkspaceTransitionStart, - expectedTTL: 0, - expectedMaxTTL: 0, + name: "OK", + templateAllowAutoStop: true, + templateDefaultTTL: 0, + templateMaxTTL: 0, + workspaceTTL: 0, + transition: database.WorkspaceTransitionStart, + expectedTTL: 0, + expectedMaxTTL: 0, }, { - name: "Delete", - templateDefaultTTL: 0, - templateMaxTTL: 0, - workspaceTTL: 0, - transition: database.WorkspaceTransitionDelete, - expectedTTL: 0, - expectedMaxTTL: 0, + name: "Delete", + templateAllowAutoStop: true, + templateDefaultTTL: 0, + templateMaxTTL: 0, + workspaceTTL: 0, + transition: database.WorkspaceTransitionDelete, + expectedTTL: 0, + expectedMaxTTL: 0, }, { - name: "WorkspaceTTL", - templateDefaultTTL: 0, - templateMaxTTL: 0, - workspaceTTL: time.Hour, - transition: database.WorkspaceTransitionStart, - expectedTTL: time.Hour, - expectedMaxTTL: 0, + name: "WorkspaceTTL", + templateAllowAutoStop: true, + templateDefaultTTL: 0, + templateMaxTTL: 0, + workspaceTTL: time.Hour, + transition: database.WorkspaceTransitionStart, + expectedTTL: time.Hour, + expectedMaxTTL: 0, }, { - name: "TemplateDefaultTTLIgnored", - templateDefaultTTL: time.Hour, - templateMaxTTL: 0, - workspaceTTL: 0, - transition: database.WorkspaceTransitionStart, - expectedTTL: 0, - expectedMaxTTL: 0, + name: "TemplateDefaultTTLIgnored", + templateAllowAutoStop: true, + templateDefaultTTL: time.Hour, + templateMaxTTL: 0, + workspaceTTL: 0, + transition: database.WorkspaceTransitionStart, + expectedTTL: 0, + expectedMaxTTL: 0, }, { - name: "WorkspaceTTLOverridesTemplateDefaultTTL", - templateDefaultTTL: 2 * time.Hour, - templateMaxTTL: 0, - workspaceTTL: time.Hour, - transition: database.WorkspaceTransitionStart, - expectedTTL: time.Hour, - 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", - templateDefaultTTL: 0, - templateMaxTTL: time.Hour, - workspaceTTL: 0, - transition: database.WorkspaceTransitionStart, - expectedTTL: time.Hour, - expectedMaxTTL: time.Hour, + name: "TemplateMaxTTL", + templateAllowAutoStop: true, + templateDefaultTTL: 0, + templateMaxTTL: time.Hour, + workspaceTTL: 0, + transition: database.WorkspaceTransitionStart, + expectedTTL: time.Hour, + expectedMaxTTL: time.Hour, }, { - name: "TemplateMaxTTLOverridesWorkspaceTTL", - templateDefaultTTL: 0, - templateMaxTTL: 2 * time.Hour, - workspaceTTL: 3 * time.Hour, - transition: database.WorkspaceTransitionStart, - expectedTTL: 2 * time.Hour, - expectedMaxTTL: 2 * 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", - templateDefaultTTL: 3 * time.Hour, - templateMaxTTL: 2 * time.Hour, - workspaceTTL: 0, - 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, }, } @@ -921,12 +940,13 @@ func TestCompleteJob(t *testing.T) { srv := setup(t, false) - var store schedule.TemplateScheduleStore = mockTemplateScheduleStore{ + var store schedule.TemplateScheduleStore = schedule.MockTemplateScheduleStore{ GetFn: func(_ context.Context, _ database.Store, _ uuid.UUID) (schedule.TemplateScheduleOptions, error) { return schedule.TemplateScheduleOptions{ - UserSchedulingEnabled: true, - DefaultTTL: c.templateDefaultTTL, - MaxTTL: c.templateMaxTTL, + UserAutoStartEnabled: false, + UserAutoStopEnabled: c.templateAllowAutoStop, + DefaultTTL: c.templateDefaultTTL, + MaxTTL: c.templateMaxTTL, }, nil }, } @@ -938,10 +958,11 @@ func TestCompleteJob(t *testing.T) { Provisioner: database.ProvisionerTypeEcho, }) template, err := srv.Database.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{ - ID: template.ID, - UpdatedAt: database.Now(), - DefaultTTL: int64(c.templateDefaultTTL), - MaxTTL: int64(c.templateMaxTTL), + 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}) @@ -1190,17 +1211,3 @@ func must[T any](value T, err error) T { } return value } - -type mockTemplateScheduleStore struct { - GetFn func(ctx context.Context, db database.Store, id uuid.UUID) (schedule.TemplateScheduleOptions, error) -} - -var _ schedule.TemplateScheduleStore = mockTemplateScheduleStore{} - -func (mockTemplateScheduleStore) SetTemplateScheduleOptions(ctx context.Context, db database.Store, template database.Template, opts schedule.TemplateScheduleOptions) (database.Template, error) { - return schedule.NewAGPLTemplateScheduleStore().SetTemplateScheduleOptions(ctx, db, template, opts) -} - -func (m mockTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.Context, db database.Store, id uuid.UUID) (schedule.TemplateScheduleOptions, error) { - return m.GetFn(ctx, db, id) -} diff --git a/coderd/schedule/mock.go b/coderd/schedule/mock.go new file mode 100644 index 0000000000000..5c3c1e77ed803 --- /dev/null +++ b/coderd/schedule/mock.go @@ -0,0 +1,32 @@ +package schedule + +import ( + "context" + + "github.com/google/uuid" + + "github.com/coder/coder/coderd/database" +) + +type MockTemplateScheduleStore struct { + GetFn func(ctx context.Context, db database.Store, templateID uuid.UUID) (TemplateScheduleOptions, error) + SetFn func(ctx context.Context, db database.Store, template database.Template, options TemplateScheduleOptions) (database.Template, error) +} + +var _ TemplateScheduleStore = MockTemplateScheduleStore{} + +func (m MockTemplateScheduleStore) GetTemplateScheduleOptions(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) +} + +func (m MockTemplateScheduleStore) SetTemplateScheduleOptions(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) +} diff --git a/coderd/schedule/template.go b/coderd/schedule/template.go index 0d328837f3cf8..4578fba1824d8 100644 --- a/coderd/schedule/template.go +++ b/coderd/schedule/template.go @@ -10,8 +10,9 @@ import ( ) type TemplateScheduleOptions struct { - UserSchedulingEnabled bool `json:"user_scheduling_enabled"` - DefaultTTL time.Duration `json:"default_ttl"` + UserAutoStartEnabled bool `json:"user_auto_start_enabled"` + UserAutoStopEnabled bool `json:"user_auto_stop_enabled"` + DefaultTTL time.Duration `json:"default_ttl"` // If MaxTTL is set, the workspace must be stopped before this time or it // will be stopped automatically. // @@ -41,8 +42,11 @@ func (*agplTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.Context } return TemplateScheduleOptions{ - UserSchedulingEnabled: true, - DefaultTTL: time.Duration(tpl.DefaultTTL), + // Disregard the values in the database, since user scheduling is an + // enterprise feature. + UserAutoStartEnabled: true, + UserAutoStopEnabled: true, + DefaultTTL: time.Duration(tpl.DefaultTTL), // Disregard the value in the database, since MaxTTL is an enterprise // feature. MaxTTL: 0, @@ -56,6 +60,8 @@ func (*agplTemplateScheduleStore) SetTemplateScheduleOptions(ctx context.Context 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). - MaxTTL: tpl.MaxTTL, + AllowUserAutoStart: tpl.AllowUserAutoStart, + AllowUserAutoStop: tpl.AllowUserAutoStop, + MaxTTL: tpl.MaxTTL, }) } diff --git a/coderd/templates.go b/coderd/templates.go index ac28814eda9dc..b551dc6ee713c 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -259,9 +259,10 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque } dbTemplate, err = (*api.TemplateScheduleStore.Load()).SetTemplateScheduleOptions(ctx, tx, dbTemplate, schedule.TemplateScheduleOptions{ - UserSchedulingEnabled: true, - DefaultTTL: defaultTTL, - MaxTTL: maxTTL, + UserAutoStartEnabled: true, + UserAutoStopEnabled: true, + DefaultTTL: defaultTTL, + MaxTTL: maxTTL, }) if err != nil { return xerrors.Errorf("set template schedule options: %s", err) @@ -478,6 +479,8 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { req.Description == template.Description && req.DisplayName == template.DisplayName && req.Icon == template.Icon && + req.AllowUserAutoStart == template.AllowUserAutoStart && + req.AllowUserAutoStop == template.AllowUserAutoStop && req.AllowUserCancelWorkspaceJobs == template.AllowUserCancelWorkspaceJobs && req.DefaultTTLMillis == time.Duration(template.DefaultTTL).Milliseconds() && req.MaxTTLMillis == time.Duration(template.MaxTTL).Milliseconds() { @@ -491,7 +494,6 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { displayName := req.DisplayName desc := req.Description icon := req.Icon - allowUserCancelWorkspaceJobs := req.AllowUserCancelWorkspaceJobs if name == "" { name = template.Name @@ -508,7 +510,7 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { DisplayName: displayName, Description: desc, Icon: icon, - AllowUserCancelWorkspaceJobs: allowUserCancelWorkspaceJobs, + AllowUserCancelWorkspaceJobs: req.AllowUserCancelWorkspaceJobs, }) if err != nil { return xerrors.Errorf("update template metadata: %w", err) @@ -516,11 +518,18 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { defaultTTL := time.Duration(req.DefaultTTLMillis) * time.Millisecond maxTTL := time.Duration(req.MaxTTLMillis) * time.Millisecond - if defaultTTL != time.Duration(template.DefaultTTL) || maxTTL != time.Duration(template.MaxTTL) { + if defaultTTL != time.Duration(template.DefaultTTL) || + maxTTL != time.Duration(template.MaxTTL) || + req.AllowUserAutoStart != template.AllowUserAutoStart || + req.AllowUserAutoStop != template.AllowUserAutoStop { updated, err = (*api.TemplateScheduleStore.Load()).SetTemplateScheduleOptions(ctx, tx, updated, schedule.TemplateScheduleOptions{ - UserSchedulingEnabled: true, - DefaultTTL: defaultTTL, - MaxTTL: maxTTL, + // Some of these values are enterprise-only, but the + // TemplateScheduleStore will handle avoiding setting them if + // unlicensed. + UserAutoStartEnabled: req.AllowUserAutoStart, + UserAutoStopEnabled: req.AllowUserAutoStop, + DefaultTTL: defaultTTL, + MaxTTL: maxTTL, }) if err != nil { return xerrors.Errorf("set template schedule options: %w", err) @@ -661,6 +670,8 @@ func (api *API) convertTemplate( MaxTTLMillis: time.Duration(template.MaxTTL).Milliseconds(), CreatedByID: template.CreatedBy, CreatedByName: createdByName, + AllowUserAutoStart: template.AllowUserAutoStart, + AllowUserAutoStop: template.AllowUserAutoStop, AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, } } diff --git a/coderd/templates_test.go b/coderd/templates_test.go index 82e3d005a6a02..ebe651afb4172 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -157,8 +157,8 @@ func TestPostTemplateByOrganization(t *testing.T) { var setCalled int64 client := coderdtest.New(t, &coderdtest.Options{ - TemplateScheduleStore: mockTemplateScheduleStore{ - setFn: func(ctx context.Context, db database.Store, template database.Template, options schedule.TemplateScheduleOptions) (database.Template, error) { + 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) @@ -448,8 +448,8 @@ func TestPatchTemplateMeta(t *testing.T) { var setCalled int64 client := coderdtest.New(t, &coderdtest.Options{ - TemplateScheduleStore: mockTemplateScheduleStore{ - setFn: func(ctx context.Context, db database.Store, template database.Template, options schedule.TemplateScheduleOptions) (database.Template, error) { + 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) } diff --git a/coderd/workspaces.go b/coderd/workspaces.go index f84d7d6d9d345..f82bc5df93c33 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -735,6 +735,23 @@ func (api *API) putWorkspaceAutostart(rw http.ResponseWriter, r *http.Request) { return } + // Check if the template allows users to configure autostart. + templateSchedule, err := (*api.TemplateScheduleStore.Load()).GetTemplateScheduleOptions(ctx, api.Database, workspace.TemplateID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error getting template schedule options.", + Detail: err.Error(), + }) + return + } + if !templateSchedule.UserAutoStartEnabled { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Autostart is not allowed for workspaces using this template.", + Validations: []codersdk.ValidationError{{Field: "schedule", Detail: "Autostart is not allowed for workspaces using this template."}}, + }) + return + } + err = api.Database.UpdateWorkspaceAutostart(ctx, database.UpdateWorkspaceAutostartParams{ ID: workspace.ID, AutostartSchedule: dbSched, @@ -790,6 +807,9 @@ func (api *API) putWorkspaceTTL(rw http.ResponseWriter, r *http.Request) { if err != nil { return xerrors.Errorf("get template schedule: %w", err) } + if !templateSchedule.UserAutoStopEnabled { + return codersdk.ValidationError{Field: "ttl_ms", Detail: "Custom autostop TTL is not allowed for workspaces using this template."} + } // don't override 0 ttl with template default here because it indicates // disabled auto-stop diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 5c2dc9f138763..8caa94320ee78 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -1271,7 +1271,54 @@ func TestWorkspaceUpdateAutostart(t *testing.T) { }) } + t.Run("CustomAutoStartDisabledByTemplate", func(t *testing.T) { + t.Parallel() + var ( + tss = schedule.MockTemplateScheduleStore{ + GetFn: func(_ context.Context, _ database.Store, _ uuid.UUID) (schedule.TemplateScheduleOptions, error) { + return schedule.TemplateScheduleOptions{ + UserAutoStartEnabled: false, + UserAutoStopEnabled: false, + DefaultTTL: 0, + MaxTTL: 0, + }, nil + }, + SetFn: func(_ context.Context, _ database.Store, tpl database.Template, _ schedule.TemplateScheduleOptions) (database.Template, error) { + return tpl, nil + }, + } + + client = coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + TemplateScheduleStore: tss, + }) + user = coderdtest.CreateFirstUser(t, client) + version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID, func(cwr *codersdk.CreateWorkspaceRequest) { + cwr.AutostartSchedule = nil + cwr.TTLMillis = nil + }) + ) + + // await job to ensure audit logs for workspace_build start are created + _ = coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + + // ensure test invariant: new workspaces have no autostart schedule. + require.Empty(t, workspace.AutostartSchedule, "expected newly-minted workspace to have no autostart schedule") + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + err := client.UpdateWorkspaceAutostart(ctx, workspace.ID, codersdk.UpdateWorkspaceAutostartRequest{ + Schedule: ptr.Ref("CRON_TZ=Europe/Dublin 30 9 * * 1-5"), + }) + require.ErrorContains(t, err, "Autostart is not allowed for workspaces using this template") + }) + t.Run("NotFound", func(t *testing.T) { + t.Parallel() var ( client = coderdtest.New(t, nil) _ = coderdtest.CreateFirstUser(t, client) @@ -1391,7 +1438,54 @@ func TestWorkspaceUpdateTTL(t *testing.T) { }) } + t.Run("CustomAutoStopDisabledByTemplate", func(t *testing.T) { + t.Parallel() + var ( + tss = schedule.MockTemplateScheduleStore{ + GetFn: func(_ context.Context, _ database.Store, _ uuid.UUID) (schedule.TemplateScheduleOptions, error) { + return schedule.TemplateScheduleOptions{ + UserAutoStartEnabled: false, + UserAutoStopEnabled: false, + DefaultTTL: 0, + MaxTTL: 0, + }, nil + }, + SetFn: func(_ context.Context, _ database.Store, tpl database.Template, _ schedule.TemplateScheduleOptions) (database.Template, error) { + return tpl, nil + }, + } + + client = coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + TemplateScheduleStore: tss, + }) + user = coderdtest.CreateFirstUser(t, client) + version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID, func(cwr *codersdk.CreateWorkspaceRequest) { + cwr.AutostartSchedule = nil + cwr.TTLMillis = nil + }) + ) + + // await job to ensure audit logs for workspace_build start are created + _ = coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + + // ensure test invariant: new workspaces have no autostart schedule. + require.Empty(t, workspace.AutostartSchedule, "expected newly-minted workspace to have no autostart schedule") + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + err := client.UpdateWorkspaceTTL(ctx, workspace.ID, codersdk.UpdateWorkspaceTTLRequest{ + TTLMillis: ptr.Ref(time.Hour.Milliseconds()), + }) + require.ErrorContains(t, err, "Custom autostop TTL is not allowed for workspaces using this template") + }) + t.Run("NotFound", func(t *testing.T) { + t.Parallel() var ( client = coderdtest.New(t, nil) _ = coderdtest.CreateFirstUser(t, client) diff --git a/codersdk/templates.go b/codersdk/templates.go index f9f46e542c03a..0a62e439ed17e 100644 --- a/codersdk/templates.go +++ b/codersdk/templates.go @@ -34,6 +34,8 @@ type Template struct { CreatedByID uuid.UUID `json:"created_by_id" format:"uuid"` CreatedByName string `json:"created_by_name"` + AllowUserAutoStart bool `json:"allow_user_auto_start"` + AllowUserAutoStop bool `json:"allow_user_auto_stop"` AllowUserCancelWorkspaceJobs bool `json:"allow_user_cancel_workspace_jobs"` } @@ -87,6 +89,8 @@ type UpdateTemplateMeta struct { // template scheduling feature. If you attempt to set this value while // unlicensed, it will be ignored. MaxTTLMillis int64 `json:"max_ttl_ms,omitempty"` + AllowUserAutoStart bool `json:"allow_user_auto_start,omitempty"` + AllowUserAutoStop bool `json:"allow_user_auto_stop,omitempty"` AllowUserCancelWorkspaceJobs bool `json:"allow_user_cancel_workspace_jobs,omitempty"` } diff --git a/docs/admin/audit-logs.md b/docs/admin/audit-logs.md index 601146009efb0..a53dd50c91f1f 100644 --- a/docs/admin/audit-logs.md +++ b/docs/admin/audit-logs.md @@ -9,17 +9,17 @@ We track the following resources: -| Resource | | -| ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| APIKey
login, logout, 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_cancel_workspace_jobstrue
created_atfalse
created_bytrue
default_ttltrue
deletedfalse
descriptiontrue
display_nametrue
group_acltrue
icontrue
idtrue
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
| -| 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
| +| Resource | | +| ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| APIKey
login, logout, 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_auto_starttrue
allow_user_auto_stoptrue
allow_user_cancel_workspace_jobstrue
created_atfalse
created_bytrue
default_ttltrue
deletedfalse
descriptiontrue
display_nametrue
group_acltrue
icontrue
idtrue
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
| +| 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
| diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 2f76aef88bbb9..34f4d633df60d 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -3588,6 +3588,8 @@ Parameter represents a set value for the scope. { "active_user_count": 0, "active_version_id": "eae64611-bd53-4a80-bb77-df1e432c0fbc", + "allow_user_auto_start": true, + "allow_user_auto_stop": true, "allow_user_cancel_workspace_jobs": true, "build_time_stats": { "property1": { @@ -3621,6 +3623,8 @@ Parameter represents a set value for the scope. | ---------------------------------- | ------------------------------------------------------------------ | -------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | | `active_user_count` | integer | false | | Active user count is set to -1 when loading. | | `active_version_id` | string | false | | | +| `allow_user_auto_start` | boolean | false | | | +| `allow_user_auto_stop` | boolean | false | | | | `allow_user_cancel_workspace_jobs` | boolean | false | | | | `build_time_stats` | [codersdk.TemplateBuildTimeStats](#codersdktemplatebuildtimestats) | false | | | | `created_at` | string | false | | | diff --git a/docs/api/templates.md b/docs/api/templates.md index b083d7b35c81c..5c4478101772e 100644 --- a/docs/api/templates.md +++ b/docs/api/templates.md @@ -99,6 +99,8 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/templat { "active_user_count": 0, "active_version_id": "eae64611-bd53-4a80-bb77-df1e432c0fbc", + "allow_user_auto_start": true, + "allow_user_auto_stop": true, "allow_user_cancel_workspace_jobs": true, "build_time_stats": { "property1": { @@ -142,6 +144,8 @@ Status Code **200** | `[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_auto_start` | boolean | false | | | +| `» allow_user_auto_stop` | 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 | | | @@ -222,6 +226,8 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/templa { "active_user_count": 0, "active_version_id": "eae64611-bd53-4a80-bb77-df1e432c0fbc", + "allow_user_auto_start": true, + "allow_user_auto_stop": true, "allow_user_cancel_workspace_jobs": true, "build_time_stats": { "property1": { @@ -345,6 +351,8 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/templat { "active_user_count": 0, "active_version_id": "eae64611-bd53-4a80-bb77-df1e432c0fbc", + "allow_user_auto_start": true, + "allow_user_auto_stop": true, "allow_user_cancel_workspace_jobs": true, "build_time_stats": { "property1": { @@ -670,6 +678,8 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template} \ { "active_user_count": 0, "active_version_id": "eae64611-bd53-4a80-bb77-df1e432c0fbc", + "allow_user_auto_start": true, + "allow_user_auto_stop": true, "allow_user_cancel_workspace_jobs": true, "build_time_stats": { "property1": { @@ -776,6 +786,8 @@ curl -X PATCH http://coder-server:8080/api/v2/templates/{template} \ { "active_user_count": 0, "active_version_id": "eae64611-bd53-4a80-bb77-df1e432c0fbc", + "allow_user_auto_start": true, + "allow_user_auto_stop": true, "allow_user_cancel_workspace_jobs": true, "build_time_stats": { "property1": { diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index d020324748482..f075e77b7d081 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -71,6 +71,8 @@ var auditableResourcesTypes = map[any]map[string]Action{ "created_by": ActionTrack, "group_acl": ActionTrack, "user_acl": ActionTrack, + "allow_user_auto_start": ActionTrack, + "allow_user_auto_stop": ActionTrack, "allow_user_cancel_workspace_jobs": ActionTrack, "max_ttl": ActionTrack, }, diff --git a/enterprise/coderd/provisionerdaemons.go b/enterprise/coderd/provisionerdaemons.go index 7c241507bc09a..8fc9ba9893042 100644 --- a/enterprise/coderd/provisionerdaemons.go +++ b/enterprise/coderd/provisionerdaemons.go @@ -227,7 +227,7 @@ func (api *API) provisionerDaemonServe(rw http.ResponseWriter, r *http.Request) Provisioners: daemon.Provisioners, Telemetry: api.Telemetry, Auditor: &api.AGPL.Auditor, - TemplateScheduleStore: &api.AGPL.TemplateScheduleStore, + TemplateScheduleStore: api.AGPL.TemplateScheduleStore, Logger: api.Logger.Named(fmt.Sprintf("provisionerd-%s", daemon.Name)), Tags: rawTags, }) @@ -318,19 +318,21 @@ func (*enterpriseTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.C } return schedule.TemplateScheduleOptions{ - // TODO: make configurable at template level - UserSchedulingEnabled: true, - DefaultTTL: time.Duration(tpl.DefaultTTL), - MaxTTL: time.Duration(tpl.MaxTTL), + UserAutoStartEnabled: tpl.AllowUserAutoStart, + UserAutoStopEnabled: tpl.AllowUserAutoStop, + DefaultTTL: time.Duration(tpl.DefaultTTL), + MaxTTL: time.Duration(tpl.MaxTTL), }, nil } func (*enterpriseTemplateScheduleStore) SetTemplateScheduleOptions(ctx context.Context, db database.Store, tpl database.Template, opts schedule.TemplateScheduleOptions) (database.Template, error) { template, err := db.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{ - ID: tpl.ID, - UpdatedAt: database.Now(), - DefaultTTL: int64(opts.DefaultTTL), - MaxTTL: int64(opts.MaxTTL), + ID: tpl.ID, + UpdatedAt: database.Now(), + AllowUserAutoStart: opts.UserAutoStartEnabled, + AllowUserAutoStop: opts.UserAutoStopEnabled, + DefaultTTL: int64(opts.DefaultTTL), + MaxTTL: int64(opts.MaxTTL), }) 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 f1b40dddc60cd..14e9115733431 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -785,6 +785,8 @@ export interface Template { readonly max_ttl_ms: number readonly created_by_id: string readonly created_by_name: string + readonly allow_user_auto_start: boolean + readonly allow_user_auto_stop: boolean readonly allow_user_cancel_workspace_jobs: boolean } @@ -951,6 +953,8 @@ export interface UpdateTemplateMeta { readonly icon?: string readonly default_ttl_ms?: number readonly max_ttl_ms?: number + readonly allow_user_auto_start?: boolean + readonly allow_user_auto_stop?: boolean readonly allow_user_cancel_workspace_jobs?: boolean } From b319b686d60e53a0ce20515c555220371768d842 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Mon, 3 Apr 2023 00:55:30 +0000 Subject: [PATCH 2/9] feat: allow disabling user scheduling on template create --- cli/templateedit.go | 22 +- cli/templateedit_test.go | 233 ++++++++++++++++++ .../coder_templates_edit_--help.golden | 8 + coderd/apidoc/docs.go | 8 + coderd/apidoc/swagger.json | 8 + coderd/database/dbfake/databasefake.go | 2 + coderd/schedule/template.go | 5 + coderd/templates.go | 16 +- coderd/templates_test.go | 161 +++++++++++- codersdk/organizations.go | 11 + docs/api/schemas.md | 26 +- docs/api/templates.md | 2 + docs/cli/templates_edit.md | 18 ++ enterprise/coderd/provisionerdaemons.go | 8 + site/src/api/typesGenerated.ts | 2 + 15 files changed, 509 insertions(+), 21 deletions(-) diff --git a/cli/templateedit.go b/cli/templateedit.go index e0aa6bf694fd3..a240640021f99 100644 --- a/cli/templateedit.go +++ b/cli/templateedit.go @@ -21,6 +21,8 @@ func (r *RootCmd) templateEdit() *clibase.Cmd { defaultTTL time.Duration maxTTL time.Duration allowUserCancelWorkspaceJobs bool + allowUserAutoStart bool + allowUserAutoStop bool ) client := new(codersdk.Client) @@ -32,17 +34,17 @@ func (r *RootCmd) templateEdit() *clibase.Cmd { ), Short: "Edit the metadata of a template by name.", Handler: func(inv *clibase.Invocation) error { - if maxTTL != 0 { + if maxTTL != 0 || !allowUserAutoStart || !allowUserAutoStop { entitlements, err := client.Entitlements(inv.Context()) var sdkErr *codersdk.Error if xerrors.As(err, &sdkErr) && sdkErr.StatusCode() == http.StatusNotFound { - return xerrors.Errorf("your deployment appears to be an AGPL deployment, so you cannot set --max-ttl") + return xerrors.Errorf("your deployment appears to be an AGPL deployment, so you cannot set --max-ttl, --allow-user-autostart=false or --allow-user-autostop=false") } else if err != nil { return xerrors.Errorf("get entitlements: %w", err) } if !entitlements.Features[codersdk.FeatureAdvancedTemplateScheduling].Enabled { - return xerrors.Errorf("your license is not entitled to use advanced template scheduling, so you cannot set --max-ttl") + return xerrors.Errorf("your license is not entitled to use advanced template scheduling, so you cannot set --max-ttl, --allow-user-autostart=false or --allow-user-autostop=false") } } @@ -64,6 +66,8 @@ func (r *RootCmd) templateEdit() *clibase.Cmd { DefaultTTLMillis: defaultTTL.Milliseconds(), MaxTTLMillis: maxTTL.Milliseconds(), AllowUserCancelWorkspaceJobs: allowUserCancelWorkspaceJobs, + AllowUserAutoStart: allowUserAutoStart, + AllowUserAutoStop: allowUserAutoStop, } _, err = client.UpdateTemplateMeta(inv.Context(), template.ID, req) @@ -112,6 +116,18 @@ func (r *RootCmd) templateEdit() *clibase.Cmd { Default: "true", Value: clibase.BoolOf(&allowUserCancelWorkspaceJobs), }, + { + Flag: "allow-user-autostart", + Description: "Allow users to configure autostart for workspaces on this template. This can only be disabled in enterprise.", + Default: "true", + Value: clibase.BoolOf(&allowUserAutoStart), + }, + { + Flag: "allow-user-autostop", + Description: "Allow users to customize the autostop TTL for workspaces on this template. This can only be disabled in enterprise.", + Default: "true", + Value: clibase.BoolOf(&allowUserAutoStop), + }, cliui.SkipPromptOption(), } diff --git a/cli/templateedit_test.go b/cli/templateedit_test.go index 1a97401c958e4..6bbd36fb8546a 100644 --- a/cli/templateedit_test.go +++ b/cli/templateedit_test.go @@ -428,6 +428,147 @@ func TestTemplateEdit(t *testing.T) { 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) { + 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 with --allow-user-autostart. + cmdArgs := []string{ + "templates", + "edit", + template.Name, + "--allow-user-autostart=false", + } + 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") + + // Test the cli command with --allow-user-autostop. + cmdArgs = []string{ + "templates", + "edit", + template.Name, + "--allow-user-autostop=false", + } + 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) + assert.Equal(t, template.AllowUserAutoStart, updated.AllowUserAutoStart) + assert.Equal(t, template.AllowUserAutoStop, updated.AllowUserAutoStop) + }) + + 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) + + // 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 with --allow-user-autostart. + cmdArgs := []string{ + "templates", + "edit", + template.Name, + "--allow-user-autostart=false", + } + 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") + + // Test the cli command with --allow-user-autostop. + cmdArgs = []string{ + "templates", + "edit", + template.Name, + "--allow-user-autostop=false", + } + 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) @@ -437,6 +578,98 @@ func TestTemplateEdit(t *testing.T) { 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.AllowUserAutoStart, updated.AllowUserAutoStart) + assert.Equal(t, template.AllowUserAutoStop, updated.AllowUserAutoStop) + }) + 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) + + // 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.False(t, req.AllowUserAutoStart) + assert.False(t, req.AllowUserAutoStop) + + 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, + "--allow-user-autostart=false", + "--allow-user-autostop=false", + } + 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) + assert.Equal(t, template.AllowUserAutoStart, updated.AllowUserAutoStart) + assert.Equal(t, template.AllowUserAutoStop, updated.AllowUserAutoStop) }) }) } diff --git a/cli/testdata/coder_templates_edit_--help.golden b/cli/testdata/coder_templates_edit_--help.golden index 0dc5d8d6a7d69..271f0d9b9ed56 100644 --- a/cli/testdata/coder_templates_edit_--help.golden +++ b/cli/testdata/coder_templates_edit_--help.golden @@ -3,6 +3,14 @@ Usage: coder templates edit [flags]