feat: update workspace deadline when workspace ttl updated (#2165)

This commit adds the following changes to workspace scheduling behaviour:

* CLI: updating a workspace TTL updates the deadline of the workspace.
  * If the TTL is being un-set, the workspace deadline is set to zero.
  * If the TTL is being set, the workspace deadline is updated to be the last updated time of the workspace build plus the requested TTL. Additionally, the user is prompted to confirm interactively (can be bypassed with -y).
* UI: updating the workspace schedule behaves similarly to the CLI, showing a message to the user if the updated TTL/time to shutdown would effect changes to the lifetime of the running workspace.
This commit is contained in:
Cian Johnston
2022-06-09 22:10:24 +01:00
committed by GitHub
parent 411d7da661
commit 119db78bff
9 changed files with 360 additions and 56 deletions

View File

@ -440,18 +440,41 @@ func TestExecutorWorkspaceAutostopNoWaitChangedMyMind(t *testing.T) {
err := client.UpdateWorkspaceTTL(ctx, workspace.ID, codersdk.UpdateWorkspaceTTLRequest{TTLMillis: nil})
require.NoError(t, err)
// When: the autobuild executor ticks after the deadline
// Then: the deadline should be the zero value
updated := coderdtest.MustWorkspace(t, client, workspace.ID)
assert.Zero(t, updated.LatestBuild.Deadline)
// When: the autobuild executor ticks after the original deadline
go func() {
tickCh <- workspace.LatestBuild.Deadline.Add(time.Minute)
}()
// Then: the workspace should not stop
stats := <-statsCh
assert.NoError(t, stats.Error)
assert.Len(t, stats.Transitions, 0)
// Given: the user changes their mind again and wants to enable auto-stop
newTTL := 8 * time.Hour
expectedDeadline := workspace.LatestBuild.UpdatedAt.Add(newTTL)
err = client.UpdateWorkspaceTTL(ctx, workspace.ID, codersdk.UpdateWorkspaceTTLRequest{TTLMillis: ptr.Ref(newTTL.Milliseconds())})
require.NoError(t, err)
// Then: the deadline should be updated based on the TTL
updated = coderdtest.MustWorkspace(t, client, workspace.ID)
assert.WithinDuration(t, expectedDeadline, updated.LatestBuild.Deadline, time.Minute)
// When: the relentless onward march of time continues
go func() {
tickCh <- workspace.LatestBuild.Deadline.Add(newTTL + time.Minute)
close(tickCh)
}()
// Then: the workspace should still stop - sorry!
stats := <-statsCh
// Then: the workspace should stop
stats = <-statsCh
assert.NoError(t, stats.Error)
assert.Len(t, stats.Transitions, 1)
assert.Contains(t, stats.Transitions, workspace.ID)
assert.Equal(t, database.WorkspaceTransitionStop, stats.Transitions[workspace.ID])
assert.Equal(t, stats.Transitions[workspace.ID], database.WorkspaceTransitionStop)
}
func TestExecutorAutostartMultipleOK(t *testing.T) {

View File

@ -566,17 +566,57 @@ func (api *API) putWorkspaceTTL(rw http.ResponseWriter, r *http.Request) {
return
}
err = api.Database.UpdateWorkspaceTTL(r.Context(), database.UpdateWorkspaceTTLParams{
ID: workspace.ID,
Ttl: dbTTL,
err = api.Database.InTx(func(s database.Store) error {
if err := s.UpdateWorkspaceTTL(r.Context(), database.UpdateWorkspaceTTLParams{
ID: workspace.ID,
Ttl: dbTTL,
}); err != nil {
return xerrors.Errorf("update workspace TTL: %w", err)
}
// Also extend the workspace deadline if the workspace is running
latestBuild, err := s.GetLatestWorkspaceBuildByWorkspaceID(r.Context(), workspace.ID)
if err != nil {
return xerrors.Errorf("get latest workspace build: %w", err)
}
if latestBuild.Transition != database.WorkspaceTransitionStart {
return nil // nothing to do
}
if latestBuild.UpdatedAt.IsZero() {
// Build in progress; provisionerd should update with the new TTL.
return nil
}
var newDeadline time.Time
if dbTTL.Valid {
newDeadline = latestBuild.UpdatedAt.Add(time.Duration(dbTTL.Int64))
}
if err := s.UpdateWorkspaceBuildByID(
r.Context(),
database.UpdateWorkspaceBuildByIDParams{
ID: latestBuild.ID,
UpdatedAt: latestBuild.UpdatedAt,
ProvisionerState: latestBuild.ProvisionerState,
Deadline: newDeadline,
},
); err != nil {
return xerrors.Errorf("update workspace deadline: %w", err)
}
return nil
})
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: "Internal error updating workspace TTL.",
Message: "Error updating workspace time until shutdown!",
Detail: err.Error(),
})
return
}
httpapi.Write(rw, http.StatusOK, nil)
}
func (api *API) putExtendWorkspace(rw http.ResponseWriter, r *http.Request) {

View File

@ -550,19 +550,16 @@ func TestWorkspaceUpdateAutostart(t *testing.T) {
name: "invalid location",
schedule: ptr.Ref("CRON_TZ=Imaginary/Place 30 9 * * 1-5"),
expectedError: "parse schedule: provided bad location Imaginary/Place: unknown time zone Imaginary/Place",
// expectedError: "status code 500: Invalid autostart schedule\n\tError: parse schedule: provided bad location Imaginary/Place: unknown time zone Imaginary/Place",
},
{
name: "invalid schedule",
schedule: ptr.Ref("asdf asdf asdf "),
expectedError: `validate weekly schedule: expected schedule to consist of 5 fields with an optional CRON_TZ=<timezone> prefix`,
// expectedError: "status code 500: Invalid autostart schedule\n\tError: validate weekly schedule: expected schedule to consist of 5 fields with an optional CRON_TZ=<timezone> prefix",
},
{
name: "only 3 values",
schedule: ptr.Ref("CRON_TZ=Europe/Dublin 30 9 *"),
expectedError: `validate weekly schedule: expected schedule to consist of 5 fields with an optional CRON_TZ=<timezone> prefix`,
// expectedError: "status code 500: Invalid autostart schedule\n\tError: validate weekly schedule: expected schedule to consist of 5 fields with an optional CRON_TZ=<timezone> prefix",
},
}
@ -640,15 +637,23 @@ func TestWorkspaceUpdateTTL(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
ttlMillis *int64
expectedError string
modifyTemplate func(*codersdk.CreateTemplateRequest)
name string
ttlMillis *int64
expectedError string
expectedDeadline *time.Time
modifyTemplate func(*codersdk.CreateTemplateRequest)
}{
{
name: "disable ttl",
ttlMillis: nil,
expectedError: "",
name: "disable ttl",
ttlMillis: nil,
expectedError: "",
expectedDeadline: ptr.Ref(time.Time{}),
},
{
name: "update ttl",
ttlMillis: ptr.Ref(12 * time.Hour.Milliseconds()),
expectedError: "",
expectedDeadline: ptr.Ref(time.Now().Add(12*time.Hour + time.Minute)),
},
{
name: "below minimum ttl",
@ -656,14 +661,16 @@ func TestWorkspaceUpdateTTL(t *testing.T) {
expectedError: "ttl must be at least one minute",
},
{
name: "minimum ttl",
ttlMillis: ptr.Ref(time.Minute.Milliseconds()),
expectedError: "",
name: "minimum ttl",
ttlMillis: ptr.Ref(time.Minute.Milliseconds()),
expectedError: "",
expectedDeadline: ptr.Ref(time.Now().Add(2 * time.Minute)),
},
{
name: "maximum ttl",
ttlMillis: ptr.Ref((24 * 7 * time.Hour).Milliseconds()),
expectedError: "",
name: "maximum ttl",
ttlMillis: ptr.Ref((24 * 7 * time.Hour).Milliseconds()),
expectedError: "",
expectedDeadline: ptr.Ref(time.Now().Add(24*7*time.Hour + time.Minute)),
},
{
name: "above maximum ttl",
@ -698,6 +705,7 @@ func TestWorkspaceUpdateTTL(t *testing.T) {
cwr.AutostartSchedule = nil
cwr.TTLMillis = nil
})
_ = coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
)
err := client.UpdateWorkspaceTTL(ctx, workspace.ID, codersdk.UpdateWorkspaceTTLRequest{
@ -715,6 +723,9 @@ func TestWorkspaceUpdateTTL(t *testing.T) {
require.NoError(t, err, "fetch updated workspace")
require.Equal(t, testCase.ttlMillis, updated.TTLMillis, "expected autostop ttl to equal requested")
if testCase.expectedDeadline != nil {
require.WithinDuration(t, *testCase.expectedDeadline, updated.LatestBuild.Deadline, time.Minute, "expected autostop deadline to be equal expected")
}
})
}