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).
This commit is contained in:
Cian Johnston
2022-05-26 18:08:11 +01:00
committed by GitHub
parent c04d045279
commit 8f0a5a81f1
18 changed files with 306 additions and 34 deletions
@@ -50,6 +50,18 @@ func (e *Executor) Run() {
func (e *Executor) runOnce(t time.Time) error {
currentTick := t.Truncate(time.Minute)
return e.db.InTx(func(db database.Store) error {
// TTL is set at the workspace level, and deadline at the workspace build level.
// When a workspace build is created, its deadline initially starts at zero.
// When provisionerd successfully completes a provision job, the deadline is
// set to now + TTL if the associated workspace has a TTL set. This deadline
// is what we compare against when performing autostop operations, rounded down
// to the minute.
//
// NOTE: Currently, 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. So, autostop will still happen at the
// original TTL value from when the workspace build was created.
// Whether this is expected behavior from a user's perspective is not yet known.
eligibleWorkspaces, err := db.GetWorkspacesAutostart(e.ctx)
if err != nil {
return xerrors.Errorf("get eligible workspaces for autostart or autostop: %w", err)
@@ -88,18 +100,15 @@ func (e *Executor) runOnce(t time.Time) error {
switch priorHistory.Transition {
case database.WorkspaceTransitionStart:
validTransition = database.WorkspaceTransitionStop
if !ws.Ttl.Valid || ws.Ttl.Int64 == 0 {
e.log.Debug(e.ctx, "invalid or zero ws ttl, skipping",
if priorHistory.Deadline.IsZero() {
e.log.Debug(e.ctx, "latest workspace build has zero deadline, skipping",
slog.F("workspace_id", ws.ID),
slog.F("ttl", time.Duration(ws.Ttl.Int64)),
slog.F("workspace_build_id", priorHistory.ID),
)
continue
}
ttl := time.Duration(ws.Ttl.Int64)
// Measure TTL from the time the workspace finished building.
// Truncate to nearest minute for consistency with autostart
// behavior, and add one minute for padding.
nextTransition = priorHistory.UpdatedAt.Truncate(time.Minute).Add(ttl + time.Minute)
// Truncate to nearest minute for consistency with autostart behavior
nextTransition = priorHistory.Deadline.Truncate(time.Minute)
case database.WorkspaceTransitionStop:
validTransition = database.WorkspaceTransitionStart
sched, err := schedule.Weekly(ws.AutostartSchedule.String)
@@ -190,14 +190,14 @@ func TestExecutorAutostopOK(t *testing.T) {
})
// Given: we have a user with a workspace
workspace = mustProvisionWorkspace(t, client)
ttl = *workspace.TTL
)
// Given: workspace is running
require.Equal(t, codersdk.WorkspaceTransitionStart, workspace.LatestBuild.Transition)
require.NotZero(t, workspace.LatestBuild.Deadline)
// When: the autobuild executor ticks *after* the TTL:
// When: the autobuild executor ticks *after* the deadline:
go func() {
tickCh <- time.Now().UTC().Add(ttl + time.Minute)
tickCh <- workspace.LatestBuild.Deadline.Add(time.Minute)
close(tickCh)
}()
@@ -209,6 +209,55 @@ func TestExecutorAutostopOK(t *testing.T) {
require.Equal(t, codersdk.WorkspaceTransitionStop, ws.LatestBuild.Transition, "expected workspace not to be running")
}
func TestExecutorAutostopExtend(t *testing.T) {
t.Parallel()
var (
ctx = context.Background()
tickCh = make(chan time.Time)
client = coderdtest.New(t, &coderdtest.Options{
AutobuildTicker: tickCh,
IncludeProvisionerD: true,
})
// Given: we have a user with a workspace
workspace = mustProvisionWorkspace(t, client)
originalDeadline = workspace.LatestBuild.Deadline
)
// Given: workspace is running
require.Equal(t, codersdk.WorkspaceTransitionStart, workspace.LatestBuild.Transition)
require.NotZero(t, originalDeadline)
// Given: we extend the workspace deadline
err := client.PutExtendWorkspace(ctx, workspace.ID, codersdk.PutExtendWorkspaceRequest{
Deadline: originalDeadline.Add(30 * time.Minute),
})
require.NoError(t, err, "extend workspace deadline")
// When: the autobuild executor ticks *after* the original deadline:
go func() {
tickCh <- originalDeadline.Add(time.Minute)
}()
// Then: nothing should happen
<-time.After(5 * time.Second)
ws := mustWorkspace(t, client, workspace.ID)
require.Equal(t, workspace.LatestBuild.ID, ws.LatestBuild.ID, "expected no further workspace builds to occur")
require.Equal(t, codersdk.WorkspaceTransitionStart, ws.LatestBuild.Transition, "expected workspace to be running")
// When: the autobuild executor ticks after the *new* deadline:
go func() {
tickCh <- ws.LatestBuild.Deadline.Add(time.Minute)
close(tickCh)
}()
// Then: the workspace should be stopped
<-time.After(5 * time.Second)
ws = mustWorkspace(t, client, workspace.ID)
require.NotEqual(t, workspace.LatestBuild.ID, ws.LatestBuild.ID, "expected a workspace build to occur")
require.Equal(t, codersdk.ProvisionerJobSucceeded, ws.LatestBuild.Job.Status, "expected provisioner job to have succeeded")
require.Equal(t, codersdk.WorkspaceTransitionStop, ws.LatestBuild.Transition, "expected workspace not to be running")
}
func TestExecutorAutostopAlreadyStopped(t *testing.T) {
t.Parallel()
@@ -222,7 +271,6 @@ func TestExecutorAutostopAlreadyStopped(t *testing.T) {
workspace = mustProvisionWorkspace(t, client, func(cwr *codersdk.CreateWorkspaceRequest) {
cwr.AutostartSchedule = nil
})
ttl = *workspace.TTL
)
// Given: workspace is stopped
@@ -230,7 +278,7 @@ func TestExecutorAutostopAlreadyStopped(t *testing.T) {
// When: the autobuild executor ticks past the TTL
go func() {
tickCh <- time.Now().UTC().Add(ttl + time.Minute)
tickCh <- workspace.LatestBuild.Deadline.Add(time.Minute)
close(tickCh)
}()
@@ -264,7 +312,7 @@ func TestExecutorAutostopNotEnabled(t *testing.T) {
// When: the autobuild executor ticks past the TTL
go func() {
tickCh <- time.Now().UTC().Add(time.Minute)
tickCh <- workspace.LatestBuild.Deadline.Add(time.Minute)
close(tickCh)
}()
@@ -352,7 +400,7 @@ func TestExecutorWorkspaceAutostartTooEarly(t *testing.T) {
require.Equal(t, codersdk.WorkspaceTransitionStart, ws.LatestBuild.Transition, "expected workspace to be running")
}
func TestExecutorWorkspaceTTLTooEarly(t *testing.T) {
func TestExecutorWorkspaceAutostopBeforeDeadline(t *testing.T) {
t.Parallel()
var (
@@ -367,7 +415,7 @@ func TestExecutorWorkspaceTTLTooEarly(t *testing.T) {
// When: the autobuild executor ticks before the TTL
go func() {
tickCh <- time.Now().UTC()
tickCh <- workspace.LatestBuild.Deadline.Add(-1 * time.Minute)
close(tickCh)
}()
@@ -378,6 +426,38 @@ func TestExecutorWorkspaceTTLTooEarly(t *testing.T) {
require.Equal(t, codersdk.WorkspaceTransitionStart, ws.LatestBuild.Transition, "expected workspace to be running")
}
func TestExecutorWorkspaceAutostopNoWaitChangedMyMind(t *testing.T) {
t.Parallel()
var (
ctx = context.Background()
tickCh = make(chan time.Time)
client = coderdtest.New(t, &coderdtest.Options{
AutobuildTicker: tickCh,
IncludeProvisionerD: true,
})
// Given: we have a user with a workspace
workspace = mustProvisionWorkspace(t, client)
)
// Given: the user changes their mind and decides their workspace should not auto-stop
err := client.UpdateWorkspaceTTL(ctx, workspace.ID, codersdk.UpdateWorkspaceTTLRequest{TTL: nil})
require.NoError(t, err)
// When: the autobuild executor ticks after the deadline
go func() {
tickCh <- workspace.LatestBuild.Deadline.Add(time.Minute)
close(tickCh)
}()
// Then: the workspace should still stop - sorry!
<-time.After(5 * time.Second)
ws := mustWorkspace(t, client, workspace.ID)
require.NotEqual(t, workspace.LatestBuild.ID, ws.LatestBuild.ID, "expected a workspace build to occur")
require.Equal(t, codersdk.ProvisionerJobSucceeded, ws.LatestBuild.Job.Status, "expected provisioner job to have succeeded")
require.Equal(t, codersdk.WorkspaceTransitionStop, ws.LatestBuild.Transition, "expected workspace not to be running")
}
func TestExecutorAutostartMultipleOK(t *testing.T) {
if os.Getenv("DB") == "" {
t.Skip(`This test only really works when using a "real" database, similar to a HA setup`)