mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: allow disabling autostart and custom autostop for template (#6933)
API only, frontend in upcoming PR.
This commit is contained in:
@@ -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
|
||||
},
|
||||
},
|
||||
|
||||
Generated
+15
@@ -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"
|
||||
},
|
||||
|
||||
Generated
+15
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
@@ -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)),
|
||||
})
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+8
-2
@@ -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).';
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user