feat: allow disabling autostart and custom autostop for template (#6933)

API only, frontend in upcoming PR.
This commit is contained in:
Dean Sheather
2023-04-04 12:48:35 +00:00
committed by GitHub
parent 083fc89f93
commit e33941b7c2
65 changed files with 1433 additions and 486 deletions
+5 -28
View File
@@ -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
},
},
+15
View File
@@ -6401,6 +6401,14 @@ const docTemplate = `{
"template_version_id"
],
"properties": {
"allow_user_autostart": {
"description": "AllowUserAutostart allows users to set a schedule for autostarting their\nworkspace. By default this is true. This can only be disabled when using\nan enterprise license.",
"type": "boolean"
},
"allow_user_autostop": {
"description": "AllowUserAutostop allows users to set a custom workspace TTL to use in\nplace of the template's DefaultTTL field. By default this is true. If\nfalse, the DefaultTTL will always be used. This can only be disabled when\nusing an enterprise license.",
"type": "boolean"
},
"allow_user_cancel_workspace_jobs": {
"description": "Allow users to cancel in-progress workspace jobs.\n*bool as the default value is \"true\".",
"type": "boolean"
@@ -8188,6 +8196,13 @@ const docTemplate = `{
"type": "string",
"format": "uuid"
},
"allow_user_autostart": {
"description": "AllowUserAutostart and AllowUserAutostop are enterprise-only. Their\nvalues are only used if your license is entitled to use the advanced\ntemplate scheduling feature.",
"type": "boolean"
},
"allow_user_autostop": {
"type": "boolean"
},
"allow_user_cancel_workspace_jobs": {
"type": "boolean"
},
+15
View File
@@ -5703,6 +5703,14 @@
"type": "object",
"required": ["name", "template_version_id"],
"properties": {
"allow_user_autostart": {
"description": "AllowUserAutostart allows users to set a schedule for autostarting their\nworkspace. By default this is true. This can only be disabled when using\nan enterprise license.",
"type": "boolean"
},
"allow_user_autostop": {
"description": "AllowUserAutostop allows users to set a custom workspace TTL to use in\nplace of the template's DefaultTTL field. By default this is true. If\nfalse, the DefaultTTL will always be used. This can only be disabled when\nusing an enterprise license.",
"type": "boolean"
},
"allow_user_cancel_workspace_jobs": {
"description": "Allow users to cancel in-progress workspace jobs.\n*bool as the default value is \"true\".",
"type": "boolean"
@@ -7361,6 +7369,13 @@
"type": "string",
"format": "uuid"
},
"allow_user_autostart": {
"description": "AllowUserAutostart and AllowUserAutostop are enterprise-only. Their\nvalues are only used if your license is entitled to use the advanced\ntemplate scheduling feature.",
"type": "boolean"
},
"allow_user_autostop": {
"type": "boolean"
},
"allow_user_cancel_workspace_jobs": {
"type": "boolean"
},
+40 -28
View File
@@ -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(
@@ -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) {
@@ -445,7 +443,7 @@ func TestExecutorWorkspaceAutostopNoWaitChangedMyMind(t *testing.T) {
workspace = mustProvisionWorkspace(t, client)
)
// Given: the user changes their mind and decides their workspace should not auto-stop
// Given: the user changes their mind and decides their workspace should not autostop
err := client.UpdateWorkspaceTTL(ctx, workspace.ID, codersdk.UpdateWorkspaceTTLRequest{TTLMillis: nil})
require.NoError(t, err)
@@ -471,7 +469,7 @@ func TestExecutorWorkspaceAutostopNoWaitChangedMyMind(t *testing.T) {
// Start the workspace again
workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStop, database.WorkspaceTransitionStart)
// Given: the user changes their mind again and wants to enable auto-stop
// Given: the user changes their mind again and wants to enable autostop
newTTL := 8 * time.Hour
err = client.UpdateWorkspaceTTL(ctx, workspace.ID, codersdk.UpdateWorkspaceTTLRequest{TTLMillis: ptr.Ref(newTTL.Milliseconds())})
require.NoError(t, err)
@@ -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)
+9 -6
View File
@@ -122,7 +122,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 temporary app
// tokens. The key must be 64 bytes long.
AppSigningKey []byte
@@ -235,7 +235,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")
@@ -309,7 +313,7 @@ func New(options *Options) *API {
),
metricsCache: metricsCache,
Auditor: atomic.Pointer[audit.Auditor]{},
TemplateScheduleStore: atomic.Pointer[schedule.TemplateScheduleStore]{},
TemplateScheduleStore: options.TemplateScheduleStore,
Experiments: experiments,
healthCheckGroup: &singleflight.Group[string, *healthcheck.Report]{},
}
@@ -327,7 +331,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)
@@ -770,7 +773,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
@@ -882,7 +885,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)),
})
+9 -1
View File
@@ -26,6 +26,7 @@ import (
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
@@ -214,10 +215,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)
@@ -311,7 +319,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{
+4
View File
@@ -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
+29
View File
@@ -1902,6 +1902,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
@@ -2903,6 +2905,8 @@ func (q *fakeQuerier) InsertTemplate(_ context.Context, arg database.InsertTempl
DisplayName: arg.DisplayName,
Icon: arg.Icon,
AllowUserCancelWorkspaceJobs: arg.AllowUserCancelWorkspaceJobs,
AllowUserAutostart: true,
AllowUserAutostop: true,
}
q.templates = append(q.templates, template)
return template.DeepCopy(), nil
@@ -3977,6 +3981,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
+8 -2
View File
@@ -445,15 +445,21 @@ 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_autostart boolean DEFAULT true NOT NULL,
allow_user_autostop boolean DEFAULT true NOT NULL
);
COMMENT ON COLUMN templates.default_ttl IS 'The default duration for auto-stop for workspaces created from this template.';
COMMENT ON COLUMN templates.default_ttl IS 'The default duration for autostop for workspaces created from this template.';
COMMENT ON COLUMN templates.display_name IS 'Display name is a custom, human-friendly template name that user can set.';
COMMENT ON COLUMN templates.allow_user_cancel_workspace_jobs IS 'Allow users to cancel in-progress workspace jobs.';
COMMENT ON COLUMN templates.allow_user_autostart IS 'Allow users to specify an autostart schedule for workspaces (enterprise).';
COMMENT ON COLUMN templates.allow_user_autostop IS 'Allow users to specify custom autostop values for workspaces (enterprise).';
CREATE TABLE user_links (
user_id uuid NOT NULL,
login_type login_type NOT NULL,
@@ -3,4 +3,4 @@ ALTER TABLE "templates" DROP COLUMN "min_autostart_interval";
-- rename "max_ttl" to "default_ttl" on "templates" table
ALTER TABLE "templates" RENAME COLUMN "max_ttl" TO "default_ttl";
COMMENT ON COLUMN templates.default_ttl IS 'The default duration for auto-stop for workspaces created from this template.';
COMMENT ON COLUMN templates.default_ttl IS 'The default duration for autostop for workspaces created from this template.';
@@ -0,0 +1,3 @@
ALTER TABLE "templates"
DROP COLUMN "allow_user_autostart",
DROP COLUMN "allow_user_autostop";
@@ -0,0 +1,9 @@
ALTER TABLE "templates"
ADD COLUMN "allow_user_autostart" boolean DEFAULT true NOT NULL,
ADD COLUMN "allow_user_autostop" boolean DEFAULT true NOT NULL;
COMMENT ON COLUMN "templates"."allow_user_autostart"
IS 'Allow users to specify an autostart schedule for workspaces (enterprise).';
COMMENT ON COLUMN "templates"."allow_user_autostop"
IS 'Allow users to specify custom autostop values for workspaces (enterprise).';
+2
View File
@@ -78,6 +78,8 @@ func (q *sqlQuerier) GetAuthorizedTemplates(ctx context.Context, arg GetTemplate
&i.DisplayName,
&i.AllowUserCancelWorkspaceJobs,
&i.MaxTTL,
&i.AllowUserAutostart,
&i.AllowUserAutostop,
); err != nil {
return nil, err
}
+5 -1
View File
@@ -1415,7 +1415,7 @@ type Template struct {
Provisioner ProvisionerType `db:"provisioner" json:"provisioner"`
ActiveVersionID uuid.UUID `db:"active_version_id" json:"active_version_id"`
Description string `db:"description" json:"description"`
// The default duration for auto-stop for workspaces created from this template.
// The default duration for autostop for workspaces created from this template.
DefaultTTL int64 `db:"default_ttl" json:"default_ttl"`
CreatedBy uuid.UUID `db:"created_by" json:"created_by"`
Icon string `db:"icon" json:"icon"`
@@ -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 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).
AllowUserAutostop bool `db:"allow_user_autostop" json:"allow_user_autostop"`
}
type TemplateVersion struct {
+1
View File
@@ -153,6 +153,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
+111 -14
View File
@@ -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_autostart, allow_user_autostop
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_autostart, allow_user_autostop
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_autostart, allow_user_autostop 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_autostart, allow_user_autostop
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_autostart, allow_user_autostop
`
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_autostart, allow_user_autostop
`
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_autostart, allow_user_autostop
`
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_autostart = $3,
allow_user_autostop = $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_autostart, allow_user_autostop
`
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_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"`
}
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
}
@@ -7906,6 +7928,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 (
+4 -2
View File
@@ -115,8 +115,10 @@ UPDATE
templates
SET
updated_at = $2,
default_ttl = $3,
max_ttl = $4
allow_user_autostart = $3,
allow_user_autostop = $4,
default_ttl = $5,
max_ttl = $6
WHERE
id = $1
RETURNING
+39
View File
@@ -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
)
);
@@ -986,9 +986,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)
@@ -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)
}
+1 -1
View File
@@ -1,5 +1,5 @@
// package schedule provides utilities for managing template and workspace
// auto-start and auto-stop schedules. This includes utilities for parsing and
// autostart and autostop schedules. This includes utilities for parsing and
// deserializing cron-style expressions.
package schedule
+32
View File
@@ -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)
}
+16 -5
View File
@@ -10,8 +10,9 @@ import (
)
type TemplateScheduleOptions struct {
UserSchedulingEnabled bool `json:"user_scheduling_enabled"`
DefaultTTL time.Duration `json:"default_ttl"`
UserAutostartEnabled bool `json:"user_autostart_enabled"`
UserAutostopEnabled bool `json:"user_autostop_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,
@@ -50,12 +54,19 @@ func (*agplTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.Context
}
func (*agplTemplateScheduleStore) SetTemplateScheduleOptions(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
}
return db.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{
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).
MaxTTL: tpl.MaxTTL,
AllowUserAutostart: tpl.AllowUserAutostart,
AllowUserAutostop: tpl.AllowUserAutostop,
MaxTTL: tpl.MaxTTL,
})
}
+31 -10
View File
@@ -227,10 +227,20 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque
return
}
var allowUserCancelWorkspaceJobs bool
var (
allowUserCancelWorkspaceJobs bool
allowUserAutostart = true
allowUserAutostop = true
)
if createTemplate.AllowUserCancelWorkspaceJobs != nil {
allowUserCancelWorkspaceJobs = *createTemplate.AllowUserCancelWorkspaceJobs
}
if createTemplate.AllowUserAutostart != nil {
allowUserAutostart = *createTemplate.AllowUserAutostart
}
if createTemplate.AllowUserAutostop != nil {
allowUserAutostop = *createTemplate.AllowUserAutostop
}
var dbTemplate database.Template
var template codersdk.Template
@@ -259,9 +269,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: allowUserAutostart,
UserAutostopEnabled: allowUserAutostop,
DefaultTTL: defaultTTL,
MaxTTL: maxTTL,
})
if err != nil {
return xerrors.Errorf("set template schedule options: %s", err)
@@ -478,6 +489,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 +504,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 +520,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 +528,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 +680,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,
}
}
+161 -8
View File
@@ -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)
@@ -233,6 +233,67 @@ func TestPostTemplateByOrganization(t *testing.T) {
})
})
t.Run("AllowUserScheduling", func(t *testing.T) {
t.Parallel()
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.False(t, options.UserAutostartEnabled)
require.False(t, options.UserAutostopEnabled)
template.AllowUserAutostart = options.UserAutostartEnabled
template.AllowUserAutostop = options.UserAutostopEnabled
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,
AllowUserAutostart: ptr.Ref(false),
AllowUserAutostop: ptr.Ref(false),
})
require.NoError(t, err)
require.EqualValues(t, 1, atomic.LoadInt64(&setCalled))
require.False(t, got.AllowUserAutostart)
require.False(t, got.AllowUserAutostop)
})
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,
AllowUserAutostart: ptr.Ref(false),
AllowUserAutostop: ptr.Ref(false),
})
require.NoError(t, err)
// ignored and use AGPL defaults
require.True(t, got.AllowUserAutostart)
require.True(t, got.AllowUserAutostop)
})
})
t.Run("NoVersion", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
@@ -448,8 +509,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)
}
@@ -543,6 +604,96 @@ func TestPatchTemplateMeta(t *testing.T) {
})
})
t.Run("AllowUserScheduling", func(t *testing.T) {
t.Parallel()
t.Run("OK", func(t *testing.T) {
t.Parallel()
var (
setCalled int64
allowAutostart atomic.Bool
allowAutostop atomic.Bool
)
allowAutostart.Store(true)
allowAutostop.Store(true)
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.Equal(t, allowAutostart.Load(), options.UserAutostartEnabled)
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
},
},
})
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())
})
require.Equal(t, allowAutostart.Load(), template.AllowUserAutostart)
require.Equal(t, allowAutostop.Load(), template.AllowUserAutostop)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
allowAutostart.Store(false)
allowAutostop.Store(false)
got, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{
Name: template.Name,
DisplayName: template.DisplayName,
Description: template.Description,
Icon: template.Icon,
DefaultTTLMillis: template.DefaultTTLMillis,
MaxTTLMillis: template.MaxTTLMillis,
AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs,
AllowUserAutostart: allowAutostart.Load(),
AllowUserAutostop: allowAutostop.Load(),
})
require.NoError(t, err)
require.EqualValues(t, 2, atomic.LoadInt64(&setCalled))
require.Equal(t, allowAutostart.Load(), got.AllowUserAutostart)
require.Equal(t, allowAutostop.Load(), got.AllowUserAutostop)
})
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,
// Increase the default TTL to avoid error "not modified".
DefaultTTLMillis: template.DefaultTTLMillis + 1,
MaxTTLMillis: template.MaxTTLMillis,
AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs,
AllowUserAutostart: false,
AllowUserAutostop: false,
})
require.NoError(t, err)
require.True(t, got.AllowUserAutostart)
require.True(t, got.AllowUserAutostop)
})
})
t.Run("NotModified", func(t *testing.T) {
t.Parallel()
@@ -559,10 +710,12 @@ func TestPatchTemplateMeta(t *testing.T) {
defer cancel()
req := codersdk.UpdateTemplateMeta{
Name: template.Name,
Description: template.Description,
Icon: template.Icon,
DefaultTTLMillis: template.DefaultTTLMillis,
Name: template.Name,
Description: template.Description,
Icon: template.Icon,
DefaultTTLMillis: template.DefaultTTLMillis,
AllowUserAutostart: template.AllowUserAutostart,
AllowUserAutostop: template.AllowUserAutostop,
}
_, err := client.UpdateTemplateMeta(ctx, template.ID, req)
require.ErrorContains(t, err, "not modified")
+21 -1
View File
@@ -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,9 +807,12 @@ 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
// disabled autostop
var validityErr error
dbTTL, validityErr = validWorkspaceTTLMillis(req.TTLMillis, 0, templateSchedule.MaxTTL)
if validityErr != nil {
+94
View File
@@ -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)