Skip to content

Commit 8f0a5a8

Browse files
authored
feat: add API/SDK support for autostop extension (#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

Lines changed: 17 additions & 8 deletions
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

Lines changed: 88 additions & 8 deletions
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

Lines changed: 1 addition & 0 deletions
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

Lines changed: 2 additions & 0 deletions
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

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ALTER TABLE ONLY workspace_builds DROP COLUMN deadline;
Lines changed: 1 addition & 0 deletions
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

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy