Skip to content

Commit 8f0a5a8

Browse files
authored
feat: add API/SDK support for autostop extension (coder#1778)
* Adds deadline column to workspace_builds, associated DB/API plumbing * database: Upon inserting a row into workspace_builds, deadline will initially be zero. * autobuild: Executor now checks the Deadline field of the workspace_build for the purpose of autostop logic. * coderd: Adds a new route /api/v2/workspaces/:workspace/extend which allows updating the deadline of the currently active workspace build. The new deadline must be after the existing deadline, and not the zero time. * provisionerd: updates workspace_build.deadline upon successful workspace build completion (equal to now plus workspace TTL, if it exists).
1 parent c04d045 commit 8f0a5a8

18 files changed

+306
-34
lines changed

coderd/autobuild/executor/lifecycle_executor.go

+17-8
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,18 @@ func (e *Executor) Run() {
5050
func (e *Executor) runOnce(t time.Time) error {
5151
currentTick := t.Truncate(time.Minute)
5252
return e.db.InTx(func(db database.Store) error {
53+
// TTL is set at the workspace level, and deadline at the workspace build level.
54+
// When a workspace build is created, its deadline initially starts at zero.
55+
// When provisionerd successfully completes a provision job, the deadline is
56+
// set to now + TTL if the associated workspace has a TTL set. This deadline
57+
// is what we compare against when performing autostop operations, rounded down
58+
// to the minute.
59+
//
60+
// NOTE: Currently, if a workspace build is created with a given TTL and then
61+
// the user either changes or unsets the TTL, the deadline for the workspace
62+
// build will not have changed. So, autostop will still happen at the
63+
// original TTL value from when the workspace build was created.
64+
// Whether this is expected behavior from a user's perspective is not yet known.
5365
eligibleWorkspaces, err := db.GetWorkspacesAutostart(e.ctx)
5466
if err != nil {
5567
return xerrors.Errorf("get eligible workspaces for autostart or autostop: %w", err)
@@ -88,18 +100,15 @@ func (e *Executor) runOnce(t time.Time) error {
88100
switch priorHistory.Transition {
89101
case database.WorkspaceTransitionStart:
90102
validTransition = database.WorkspaceTransitionStop
91-
if !ws.Ttl.Valid || ws.Ttl.Int64 == 0 {
92-
e.log.Debug(e.ctx, "invalid or zero ws ttl, skipping",
103+
if priorHistory.Deadline.IsZero() {
104+
e.log.Debug(e.ctx, "latest workspace build has zero deadline, skipping",
93105
slog.F("workspace_id", ws.ID),
94-
slog.F("ttl", time.Duration(ws.Ttl.Int64)),
106+
slog.F("workspace_build_id", priorHistory.ID),
95107
)
96108
continue
97109
}
98-
ttl := time.Duration(ws.Ttl.Int64)
99-
// Measure TTL from the time the workspace finished building.
100-
// Truncate to nearest minute for consistency with autostart
101-
// behavior, and add one minute for padding.
102-
nextTransition = priorHistory.UpdatedAt.Truncate(time.Minute).Add(ttl + time.Minute)
110+
// Truncate to nearest minute for consistency with autostart behavior
111+
nextTransition = priorHistory.Deadline.Truncate(time.Minute)
103112
case database.WorkspaceTransitionStop:
104113
validTransition = database.WorkspaceTransitionStart
105114
sched, err := schedule.Weekly(ws.AutostartSchedule.String)

coderd/autobuild/executor/lifecycle_executor_test.go

+88-8
Original file line numberDiff line numberDiff line change
@@ -190,14 +190,14 @@ func TestExecutorAutostopOK(t *testing.T) {
190190
})
191191
// Given: we have a user with a workspace
192192
workspace = mustProvisionWorkspace(t, client)
193-
ttl = *workspace.TTL
194193
)
195194
// Given: workspace is running
196195
require.Equal(t, codersdk.WorkspaceTransitionStart, workspace.LatestBuild.Transition)
196+
require.NotZero(t, workspace.LatestBuild.Deadline)
197197

198-
// When: the autobuild executor ticks *after* the TTL:
198+
// When: the autobuild executor ticks *after* the deadline:
199199
go func() {
200-
tickCh <- time.Now().UTC().Add(ttl + time.Minute)
200+
tickCh <- workspace.LatestBuild.Deadline.Add(time.Minute)
201201
close(tickCh)
202202
}()
203203

@@ -209,6 +209,55 @@ func TestExecutorAutostopOK(t *testing.T) {
209209
require.Equal(t, codersdk.WorkspaceTransitionStop, ws.LatestBuild.Transition, "expected workspace not to be running")
210210
}
211211

212+
func TestExecutorAutostopExtend(t *testing.T) {
213+
t.Parallel()
214+
215+
var (
216+
ctx = context.Background()
217+
tickCh = make(chan time.Time)
218+
client = coderdtest.New(t, &coderdtest.Options{
219+
AutobuildTicker: tickCh,
220+
IncludeProvisionerD: true,
221+
})
222+
// Given: we have a user with a workspace
223+
workspace = mustProvisionWorkspace(t, client)
224+
originalDeadline = workspace.LatestBuild.Deadline
225+
)
226+
// Given: workspace is running
227+
require.Equal(t, codersdk.WorkspaceTransitionStart, workspace.LatestBuild.Transition)
228+
require.NotZero(t, originalDeadline)
229+
230+
// Given: we extend the workspace deadline
231+
err := client.PutExtendWorkspace(ctx, workspace.ID, codersdk.PutExtendWorkspaceRequest{
232+
Deadline: originalDeadline.Add(30 * time.Minute),
233+
})
234+
require.NoError(t, err, "extend workspace deadline")
235+
236+
// When: the autobuild executor ticks *after* the original deadline:
237+
go func() {
238+
tickCh <- originalDeadline.Add(time.Minute)
239+
}()
240+
241+
// Then: nothing should happen
242+
<-time.After(5 * time.Second)
243+
ws := mustWorkspace(t, client, workspace.ID)
244+
require.Equal(t, workspace.LatestBuild.ID, ws.LatestBuild.ID, "expected no further workspace builds to occur")
245+
require.Equal(t, codersdk.WorkspaceTransitionStart, ws.LatestBuild.Transition, "expected workspace to be running")
246+
247+
// When: the autobuild executor ticks after the *new* deadline:
248+
go func() {
249+
tickCh <- ws.LatestBuild.Deadline.Add(time.Minute)
250+
close(tickCh)
251+
}()
252+
253+
// Then: the workspace should be stopped
254+
<-time.After(5 * time.Second)
255+
ws = mustWorkspace(t, client, workspace.ID)
256+
require.NotEqual(t, workspace.LatestBuild.ID, ws.LatestBuild.ID, "expected a workspace build to occur")
257+
require.Equal(t, codersdk.ProvisionerJobSucceeded, ws.LatestBuild.Job.Status, "expected provisioner job to have succeeded")
258+
require.Equal(t, codersdk.WorkspaceTransitionStop, ws.LatestBuild.Transition, "expected workspace not to be running")
259+
}
260+
212261
func TestExecutorAutostopAlreadyStopped(t *testing.T) {
213262
t.Parallel()
214263

@@ -222,15 +271,14 @@ func TestExecutorAutostopAlreadyStopped(t *testing.T) {
222271
workspace = mustProvisionWorkspace(t, client, func(cwr *codersdk.CreateWorkspaceRequest) {
223272
cwr.AutostartSchedule = nil
224273
})
225-
ttl = *workspace.TTL
226274
)
227275

228276
// Given: workspace is stopped
229277
workspace = mustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop)
230278

231279
// When: the autobuild executor ticks past the TTL
232280
go func() {
233-
tickCh <- time.Now().UTC().Add(ttl + time.Minute)
281+
tickCh <- workspace.LatestBuild.Deadline.Add(time.Minute)
234282
close(tickCh)
235283
}()
236284

@@ -264,7 +312,7 @@ func TestExecutorAutostopNotEnabled(t *testing.T) {
264312

265313
// When: the autobuild executor ticks past the TTL
266314
go func() {
267-
tickCh <- time.Now().UTC().Add(time.Minute)
315+
tickCh <- workspace.LatestBuild.Deadline.Add(time.Minute)
268316
close(tickCh)
269317
}()
270318

@@ -352,7 +400,7 @@ func TestExecutorWorkspaceAutostartTooEarly(t *testing.T) {
352400
require.Equal(t, codersdk.WorkspaceTransitionStart, ws.LatestBuild.Transition, "expected workspace to be running")
353401
}
354402

355-
func TestExecutorWorkspaceTTLTooEarly(t *testing.T) {
403+
func TestExecutorWorkspaceAutostopBeforeDeadline(t *testing.T) {
356404
t.Parallel()
357405

358406
var (
@@ -367,7 +415,7 @@ func TestExecutorWorkspaceTTLTooEarly(t *testing.T) {
367415

368416
// When: the autobuild executor ticks before the TTL
369417
go func() {
370-
tickCh <- time.Now().UTC()
418+
tickCh <- workspace.LatestBuild.Deadline.Add(-1 * time.Minute)
371419
close(tickCh)
372420
}()
373421

@@ -378,6 +426,38 @@ func TestExecutorWorkspaceTTLTooEarly(t *testing.T) {
378426
require.Equal(t, codersdk.WorkspaceTransitionStart, ws.LatestBuild.Transition, "expected workspace to be running")
379427
}
380428

429+
func TestExecutorWorkspaceAutostopNoWaitChangedMyMind(t *testing.T) {
430+
t.Parallel()
431+
432+
var (
433+
ctx = context.Background()
434+
tickCh = make(chan time.Time)
435+
client = coderdtest.New(t, &coderdtest.Options{
436+
AutobuildTicker: tickCh,
437+
IncludeProvisionerD: true,
438+
})
439+
// Given: we have a user with a workspace
440+
workspace = mustProvisionWorkspace(t, client)
441+
)
442+
443+
// Given: the user changes their mind and decides their workspace should not auto-stop
444+
err := client.UpdateWorkspaceTTL(ctx, workspace.ID, codersdk.UpdateWorkspaceTTLRequest{TTL: nil})
445+
require.NoError(t, err)
446+
447+
// When: the autobuild executor ticks after the deadline
448+
go func() {
449+
tickCh <- workspace.LatestBuild.Deadline.Add(time.Minute)
450+
close(tickCh)
451+
}()
452+
453+
// Then: the workspace should still stop - sorry!
454+
<-time.After(5 * time.Second)
455+
ws := mustWorkspace(t, client, workspace.ID)
456+
require.NotEqual(t, workspace.LatestBuild.ID, ws.LatestBuild.ID, "expected a workspace build to occur")
457+
require.Equal(t, codersdk.ProvisionerJobSucceeded, ws.LatestBuild.Job.Status, "expected provisioner job to have succeeded")
458+
require.Equal(t, codersdk.WorkspaceTransitionStop, ws.LatestBuild.Transition, "expected workspace not to be running")
459+
}
460+
381461
func TestExecutorAutostartMultipleOK(t *testing.T) {
382462
if os.Getenv("DB") == "" {
383463
t.Skip(`This test only really works when using a "real" database, similar to a HA setup`)

coderd/coderd.go

+1
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,7 @@ func New(options *Options) *API {
315315
r.Put("/", api.putWorkspaceTTL)
316316
})
317317
r.Get("/watch", api.watchWorkspace)
318+
r.Put("/extend", api.putExtendWorkspace)
318319
})
319320
})
320321
r.Route("/workspacebuilds/{workspacebuild}", func(r chi.Router) {

coderd/database/databasefake/databasefake.go

+2
Original file line numberDiff line numberDiff line change
@@ -1485,6 +1485,7 @@ func (q *fakeQuerier) InsertWorkspaceBuild(_ context.Context, arg database.Inser
14851485
InitiatorID: arg.InitiatorID,
14861486
JobID: arg.JobID,
14871487
ProvisionerState: arg.ProvisionerState,
1488+
Deadline: arg.Deadline,
14881489
}
14891490
q.workspaceBuilds = append(q.workspaceBuilds, workspaceBuild)
14901491
return workspaceBuild, nil
@@ -1693,6 +1694,7 @@ func (q *fakeQuerier) UpdateWorkspaceBuildByID(_ context.Context, arg database.U
16931694
}
16941695
workspaceBuild.UpdatedAt = arg.UpdatedAt
16951696
workspaceBuild.ProvisionerState = arg.ProvisionerState
1697+
workspaceBuild.Deadline = arg.Deadline
16961698
q.workspaceBuilds[index] = workspaceBuild
16971699
return nil
16981700
}

coderd/database/dump.sql

+2-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ALTER TABLE ONLY workspace_builds DROP COLUMN deadline;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ALTER TABLE ONLY workspace_builds ADD COLUMN deadline TIMESTAMPTZ NOT NULL DEFAULT TIMESTAMPTZ '0001-01-01 00:00:00+00:00';

coderd/database/models.go

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)