8000 refactor: workspace autostop_schedule -> ttl by johnstcn · Pull Request #1578 · coder/coder · GitHub
[go: up one dir, main page]

Skip to content

refactor: workspace autostop_schedule -> ttl #1578

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 15 commits into from
May 19, 2022
Merged
Prev Previous commit
Next Next commit
fix: truncate ttl to time.Minute
  • Loading branch information
johnstcn committed May 19, 2022
commit 3ea3e903f0a2ce087311821048bdfbefd88c1d77
2 changes: 1 addition & 1 deletion agent/usershell/usershell_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@ package usershell
import "os"

// Get returns the $SHELL environment variable.
func Get(username string) (string, error) {
func Get(_ string) (string, error) {
return os.Getenv("SHELL"), nil
}
4 changes: 2 additions & 2 deletions cli/ssh.go
Original file line number Diff line number Diff line change
Expand Up @@ -269,11 +269,11 @@ func notifyCondition(ctx context.Context, client *codersdk.Client, workspaceID u
return time.Time{}, nil
}

if ws.TTL == nil {
if ws.TTL == nil || *ws.TTL == 0 {
return time.Time{}, nil
}
Comment on lines +272 to 274
Copy link
Member

Choose a reason for hiding this comment

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

Instead of using a pointer why not just have 0 mean disabled?

Copy link
Member Author

Choose a reason for hiding this comment

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

I considered that initially, but I think using the zero value could lead to bugs and/or unintended behaviour. Grey also preferred the semantics of nil | time.Duration from a FE consumption perspective.

Copy link
Member Author

Choose a reason for hiding this comment

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

You do have a point, though -- 0 is already an invalid TTL.

Copy link
Contributor

Choose a reason for hiding this comment

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

Comment: I think the FE or other clients having to know 0 means "manual" is unfortunate, but I'm willing to accept it if it's cleaner for our backend/APIs.

As stated earlier, from v2 forwards I believe it's best that we look at the Coder system as a frontend for a backend, not the other way around.

Copy link
Member

Choose a reason for hiding this comment

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

Should we then consider that in the API response? We could return something like {"ttl": 0, "ttl_mode": "manual"}?

Copy link
Contributor

Choose a reason for hiding this comment

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

It's certainly golang convention to make use of "zero-values" in this way, but I don't think we should extend that to the HTTP API, CLI, or frontend, which should behave in ways that are independent of golang's idioms.

Copy link
Contributor

Choose a reason for hiding this comment

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

@spikecurtis are you referring to @deansheather 's comment here: #1578 (comment)

why not just have 0 mean disabled?

Or the fact that we're using a pointer such that the value can be nil or a number.

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah, I feel like among go practitioners, 0 means disabled would be preferred to making it a pointer and nil means disabled.


deadline = now.Add(*ws.TTL)
deadline = ws.LatestBuild.UpdatedAt.Add(*ws.TTL)
callback = func() {
ttl := deadline.Sub(now)
var title, body string
Expand Down
17 changes: 15 additions & 2 deletions cli/ttl.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@ import (
"time"

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

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

const ttlDescriptionLong = `To have your workspace stop automatically after a configurable interval has passed.`
const ttlDescriptionLong = `To have your workspace stop automatically after a configurable interval has passed.
Minimum TTL is 1 minute.
`

func ttl() *cobra.Command {
ttlCmd := &cobra.Command{
Expand Down Expand Up @@ -83,8 +86,18 @@ func ttlEnable() *cobra.Command {
return err
}

truncated := ttl.Truncate(time.Minute)

if truncated == 0 {
return xerrors.Errorf("ttl must be at least 1m")
}

if truncated != ttl {
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "warning: ttl rounded down to %s", truncated)
}

err = client.UpdateWorkspaceTTL(cmd.Context(), workspace.ID, codersdk.UpdateWorkspaceTTLRequest{
TTL: &ttl,
TTL: &truncated,
})
if err != nil {
return err
Expand Down
54 changes: 50 additions & 4 deletions cli/ttl_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ func TestTTL(t *testing.T) {
project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID)
cmdArgs = []string{"ttl", "show", workspace.Name}
ttl = 8 * time.Hour
ttl = 8*time.Hour + 30*time.Minute + 30*time.Second
stdoutBuf = &bytes.Buffer{}
)

Expand All @@ -45,7 +45,7 @@ func TestTTL(t *testing.T) {

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

t.Run("EnableDisableOK", func(t *testing.T) {
Expand All @@ -60,7 +60,7 @@ func TestTTL(t *testing.T) {
_ = 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
ttl = 8*time.Hour + 30*time.Minute + 30*time.Second
cmdArgs = []string{"ttl", "enable", workspace.Name, ttl.String()}
stdoutBuf = &bytes.Buffer{}
)
Expand All @@ -75,7 +75,8 @@ func TestTTL(t *testing.T) {
// Ensure autostop schedule updated
updated, err := client.Workspace(ctx, workspace.ID)
require.NoError(t, err, "fetch updated workspace")
require.Equal(t, *updated.TTL, ttl)
require.Equal(t, ttl.Truncate(time.Minute), *updated.TTL)
require.Contains(t, stdoutBuf.String(), "warning: ttl rounded down")

// Disable schedule
cmd, root = clitest.New(t, "ttl", "disable", workspace.Name)
Expand All @@ -91,6 +92,51 @@ func TestTTL(t *testing.T) {
require.Nil(t, updated.TTL, "expected ttl to not be set")
})

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

var (
ctx = context.Background()
client = coderdtest.New(t, nil)
_ = coderdtest.NewProvisionerDaemon(t, client)
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)
ttl = 8*time.Hour + 30*time.Minute + 30*time.Second
cmdArgs = []string{"ttl", "enable", workspace.Name, ttl.String()}
stdoutBuf = &bytes.Buffer{}
)

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

err := cmd.Execute()
require.NoError(t, err, "unexpected error")

// Ensure ttl updated
updated, err := client.Workspace(ctx, workspace.ID)
require.NoError(t, err, "fetch updated workspace")
require.Equal(t, ttl.Truncate(time.Minute), *updated.TTL)
require.Contains(t, stdoutBuf.String(), "warning: ttl rounded down")

// A TTL of zero is not considered valid.
stdoutBuf.Reset()
cmd, root = clitest.New(t, "ttl", "enable", workspace.Name, "0s")
clitest.SetupConfig(t, client, root)
cmd.SetOut(stdoutBuf)

err = cmd.Execute()
require.EqualError(t, err, "ttl must be at least 1m", "unexpected error")

// Ensure ttl remains as before
updated, err = client.Workspace(ctx, workspace.ID)
require.NoError(t, err, "fetch updated workspace")
require.Equal(t, ttl.Truncate(time.Minute), *updated.TTL)
})

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

Expand Down
13 changes: 7 additions & 6 deletions coderd/autobuild/executor/lifecycle_executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ func (e *Executor) runOnce(t time.Time) error {
}

var validTransition database.WorkspaceTransition
var nextTransitionAt time.Time
var nextTransition time.Time
switch priorHistory.Transition {
case database.WorkspaceTransitionStart:
validTransition = database.WorkspaceTransitionStop
Expand All @@ -97,8 +97,9 @@ func (e *Executor) runOnce(t time.Time) error {
}
ttl := time.Duration(ws.Ttl.Int64)
// Measure TTL from the time the workspace finished building.
// This can be finer granularity than 1 minute.
nextTransitionAt = priorHistory.UpdatedAt.Add(ttl)
// 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)
case database.WorkspaceTransitionStop:
validTransition = database.WorkspaceTransitionStart
sched, err := schedule.Weekly(ws.AutostartSchedule.String)
Expand All @@ -111,7 +112,7 @@ func (e *Executor) runOnce(t time.Time) error {
}
// Round down to the nearest minute, as this is the finest granularity cron supports.
// Truncate is probably not necessary here, but doing it anyway to be sure.
nextTransitionAt = sched.Next(priorHistory.CreatedAt).Truncate(time.Minute)
nextTransition = sched.Next(priorHistory.CreatedAt).Truncate(time.Minute)
default:
e.log.Debug(e.ctx, "last transition not valid for autostart or autostop",
slog.F("workspace_id", ws.ID),
Expand All @@ -120,10 +121,10 @@ func (e *Executor) runOnce(t time.Time) error {
continue
}

if currentTick.Before(nextTransitionAt) {
if currentTick.Before(nextTransition) {
e.log.Debug(e.ctx, "skipping workspace: too early",
slog.F("workspace_id", ws.ID),
slog.F("next_transition_at", nextTransitionAt),
slog.F("next_transition_at", nextTransition),
slog.F("transition", validTransition),
slog.F("current_tick", currentTick),
)
Expand Down
41 changes: 38 additions & 3 deletions coderd/autobuild/executor/lifecycle_executor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -208,13 +208,13 @@ func TestExecutorAutostopOK(t *testing.T) {
TTL: &ttl,
}))

// When: the autobuild executor ticks
// When: the autobuild executor ticks *after* the TTL:
go func() {
tickCh <- time.Now().UTC().Add(ttl + time.Minute)
close(tickCh)
}()

// Then: the workspace should be started
// 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")
Expand Down Expand Up @@ -333,7 +333,7 @@ func TestExecutorWorkspaceDeleted(t *testing.T) {
require.Equal(t, codersdk.WorkspaceTransitionDelete, ws.LatestBuild.Transition, "expected workspace to be deleted")
}

func TestExecutorWorkspaceTooEarly(t *testing.T) {
func TestExecutorWorkspaceAutostartTooEarly(t *testing.T) {
t.Parallel()

var (
Expand Down Expand Up @@ -372,6 +372,41 @@ func TestExecutorWorkspaceTooEarly(t *testing.T) {
require.Equal(t, codersdk.WorkspaceTransitionStart, ws.LatestBuild.Transition, "expected workspace to be running")
}

func TestExecutorWorkspaceTTLTooEarly(t *testing.T) {
t.Parallel()

var (
ctx = context.Background()
tickCh = make(chan time.Time)
client = coderdtest.New(t, &coderdtest.Options{
AutobuildTicker: tickCh,
})
// Given: we have a user with a workspace
workspace = mustProvisionWorkspace(t, client)
ttl = time.Hour
)

// Given: the workspace initially has TTL unset
require.Nil(t, workspace.TTL)

// When: we set the TTL to some time in the distant future
require.NoError(t, client.UpdateWorkspaceTTL(ctx, workspace.ID, codersdk.UpdateWorkspaceTTLRequest{
TTL: &ttl,
}))

// When: the autobuild executor ticks
go func() {
tickCh <- time.Now().UTC()
close(tickCh)
}()

// 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, database.WorkspaceTransitionStart, ws.LatestBuild.Transition, "expected workspace 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`)
Expand Down
5 changes: 3 additions & 2 deletions coderd/workspaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -560,8 +560,9 @@ func (api *api) putWorkspaceTTL(rw http.ResponseWriter, r *http.Request) {
}

var dbTTL sql.NullInt64
if req.TTL != nil {
dbTTL.Int64 = int64(*req.TTL)
if req.TTL != nil && *req.TTL > 0 {
truncated := req.TTL.Truncate(time.Minute)
dbTTL.Int64 = int64(truncated)
dbTTL.Valid = true
}

Expand Down
0