Thanks to visit codestin.com
Credit goes to github.com

Skip to content

feat: update workspace deadline when workspace ttl updated #2165

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 20 commits into from
Jun 9, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions cli/ttl.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
package cli

import (
"errors"
"fmt"
"time"

"github.com/spf13/cobra"
"golang.org/x/xerrors"

"github.com/coder/coder/cli/cliui"
"github.com/coder/coder/codersdk"
)

Expand Down Expand Up @@ -89,6 +91,30 @@ func ttlset() *cobra.Command {
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "warning: ttl rounded down to %s\n", truncated)
}

if changed, newDeadline := changedNewDeadline(workspace, truncated); changed {
// For the purposes of the user, "less than a minute" is essentially the same as "immediately".
timeRemaining := time.Until(newDeadline).Truncate(time.Minute)
humanRemaining := "in " + timeRemaining.String()
if timeRemaining <= 0 {
humanRemaining = "immediately"
}
_, err = cliui.Prompt(cmd, cliui.PromptOptions{
Text: fmt.Sprintf(
"Workspace %q will be stopped %s. Are you sure?",
workspace.Name,
humanRemaining,
),
Comment on lines +97 to +106
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: wonderful strings!

Default: "yes",
IsConfirm: true,
Comment on lines +101 to +108
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

review: we can skip the prompt if timeRemaining >= SOME_CUTOFF.

})
if err != nil {
if errors.Is(err, cliui.Canceled) {
return nil
}
return err
}
}

millis := truncated.Milliseconds()
if err = client.UpdateWorkspaceTTL(cmd.Context(), workspace.ID, codersdk.UpdateWorkspaceTTLRequest{
TTLMillis: &millis,
Expand Down Expand Up @@ -131,3 +157,18 @@ func ttlunset() *cobra.Command {
},
}
}

func changedNewDeadline(ws codersdk.Workspace, newTTL time.Duration) (changed bool, newDeadline time.Time) {
if ws.LatestBuild.Transition != codersdk.WorkspaceTransitionStart {
// not running
return false, newDeadline
}

if ws.LatestBuild.Job.CompletedAt == nil {
// still building
return false, newDeadline
}

newDeadline = ws.LatestBuild.Job.CompletedAt.Add(newTTL)
return true, newDeadline
}
77 changes: 53 additions & 24 deletions cli/ttl_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,19 @@ package cli_test
import (
"bytes"
"context"
"fmt"
"strings"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/coder/coder/cli/clitest"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/coderd/util/ptr"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/pty/ptytest"
)

func TestTTL(t *testing.T) {
Expand All @@ -22,33 +25,29 @@ func TestTTL(t *testing.T) {
t.Parallel()

var (
ctx = context.Background()
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
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)
template = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
ttl = 7*time.Hour + 30*time.Minute + 30*time.Second
workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) {
cwr.TTLMillis = ptr.Ref(ttl.Milliseconds())
})
cmdArgs = []string{"ttl", "show", workspace.Name}
ttl = 8*time.Hour + 30*time.Minute + 30*time.Second
stdoutBuf = &bytes.Buffer{}
)

err := client.UpdateWorkspaceTTL(ctx, workspace.ID, codersdk.UpdateWorkspaceTTLRequest{
TTLMillis: ptr.Ref(ttl.Milliseconds()),
})
require.NoError(t, err)

cmd, root := clitest.New(t, cmdArgs...)
clitest.SetupConfig(t, client, root)
cmd.SetOut(stdoutBuf)

err = cmd.Execute()
err := cmd.Execute()
require.NoError(t, err, "unexpected error")
require.Equal(t, ttl.Truncate(time.Minute).String(), strings.TrimSpace(stdoutBuf.String()))
})

t.Run("SetUnsetOK", func(t *testing.T) {
t.Run("UnsetOK", func(t *testing.T) {
t.Parallel()

var (
Expand All @@ -58,9 +57,11 @@ func TestTTL(t *testing.T) {
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)
ttl = 8*time.Hour + 30*time.Minute + 30*time.Second
cmdArgs = []string{"ttl", "set", workspace.Name, ttl.String()}
workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID, func(cwr *codersdk.CreateWorkspaceRequest) {
cwr.TTLMillis = ptr.Ref(ttl.Milliseconds())
})
cmdArgs = []string{"ttl", "unset", workspace.Name}
stdoutBuf = &bytes.Buffer{}
)

Expand All @@ -71,24 +72,52 @@ func TestTTL(t *testing.T) {
err := cmd.Execute()
require.NoError(t, err, "unexpected error")

// Ensure ttl updated
// Ensure ttl unset
updated, err := client.Workspace(ctx, workspace.ID)
require.NoError(t, err, "fetch updated workspace")
require.Equal(t, ttl.Truncate(time.Minute), time.Duration(*updated.TTLMillis)*time.Millisecond)
require.Contains(t, stdoutBuf.String(), "warning: ttl rounded down")
require.Nil(t, updated.TTLMillis, "expected ttl to not be set")
})

// unset schedule
cmd, root = clitest.New(t, "ttl", "unset", workspace.Name)
clitest.SetupConfig(t, client, root)
cmd.SetOut(stdoutBuf)
t.Run("SetOK", func(t *testing.T) {
t.Parallel()

err = cmd.Execute()
require.NoError(t, err, "unexpected error")
var (
ctx = context.Background()
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
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)
ttl = 8*time.Hour + 30*time.Minute + 30*time.Second
workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID, func(cwr *codersdk.CreateWorkspaceRequest) {
cwr.TTLMillis = ptr.Ref(ttl.Milliseconds())
})
_ = coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
cmdArgs = []string{"ttl", "set", workspace.Name, ttl.String()}
done = make(chan struct{})
)

cmd, root := clitest.New(t, cmdArgs...)
clitest.SetupConfig(t, client, root)
pty := ptytest.New(t)
cmd.SetIn(pty.Input())
cmd.SetOut(pty.Output())

go func() {
defer close(done)
err := cmd.Execute()
assert.NoError(t, err, "unexpected error")
}()

pty.ExpectMatch(fmt.Sprintf("warning: ttl rounded down to %s", ttl.Truncate(time.Minute)))
pty.ExpectMatch(fmt.Sprintf("Workspace %q will be stopped in 8h29m0s. Are you sure?", workspace.Name))
pty.WriteLine("yes")
// Ensure ttl updated
updated, err = client.Workspace(ctx, workspace.ID)
updated, err := client.Workspace(ctx, workspace.ID)
require.NoError(t, err, "fetch updated workspace")
require.Nil(t, updated.TTLMillis, "expected ttl to not be set")
require.Equal(t, ttl.Truncate(time.Minute), time.Duration(*updated.TTLMillis)*time.Millisecond)

<-done
})

t.Run("ZeroInvalid", func(t *testing.T) {
Expand Down
33 changes: 28 additions & 5 deletions coderd/autobuild/executor/lifecycle_executor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
close(tickCh)
}()

// Then: the workspace should still stop - sorry!
// 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 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) {
Expand Down
48 changes: 44 additions & 4 deletions coderd/workspaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: What happens if a user starts a workspace with one TTL, but tries to update the TTL before the build is complete? As a user, I'd expect the new TTL to be in-effect once the build has completed or receive an error that TTL cannot be updated (yet).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See

workspaceDeadline = now.Add(time.Duration(workspace.Ttl.Int64))
-- we fetch the workspace TTL in a transaction and update the deadline upon finishing the provisioner job.

  • If the user updates the TTL first, then the updated TTL will be used by provisionerd.
  • If provisionerd updates the TTL first, then the deadline should be updated.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ooh yeah that makes sense, thanks for clarifying!

}

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) {
Expand Down
Loading