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

Skip to content

Commit ff542af

Browse files
authored
feat: allow bumping workspace deadline (#1828)
* Adds a `bump` command to extend workspace build deadline * Reduces WARN-level logging spam from autobuild executor * Modifies `cli/ssh` notifications to read from workspace build deadline and to notify relative time instead (sidestepping the problem of figuring out a user's timezone across multiple OSes) * Shows workspace extension time in `coder list` output e.g. ``` WORKSPACE TEMPLATE STATUS LAST BUILT OUTDATED AUTOSTART TTL developer/test1 docker Running 4m false 0 9 * * MON-FRI 15m (+5m) ```
1 parent bde3779 commit ff542af

File tree

8 files changed

+383
-38
lines changed

8 files changed

+383
-38
lines changed

cli/bump.go

+93
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package cli
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
"time"
7+
8+
"github.com/spf13/cobra"
9+
"golang.org/x/xerrors"
10+
11+
"github.com/coder/coder/codersdk"
12+
)
13+
14+
const (
15+
bumpDescriptionLong = `To extend the autostop deadline for a workspace.
16+
If no unit is specified in the duration, we assume minutes.`
17+
defaultBumpDuration = 90 * time.Minute
18+
)
19+
20+
func bump() *cobra.Command {
21+
bumpCmd := &cobra.Command{
22+
Args: cobra.RangeArgs(1, 2),
23+
Annotations: workspaceCommand,
24+
Use: "bump <workspace-name> [duration]",
25+
Short: "Extend the autostop deadline for a workspace.",
26+
Long: bumpDescriptionLong,
27+
Example: "coder bump my-workspace 90m",
28+
RunE: func(cmd *cobra.Command, args []string) error {
29+
bumpDuration := defaultBumpDuration
30+
if len(args) > 1 {
31+
d, err := tryParseDuration(args[1])
32+
if err != nil {
33+
return err
34+
}
35+
bumpDuration = d
36+
}
37+
38+
if bumpDuration < time.Minute {
39+
return xerrors.New("minimum bump duration is 1 minute")
40+
}
41+
42+
client, err := createClient(cmd)
43+
if err != nil {
44+
return xerrors.Errorf("create client: %w", err)
45+
}
46+
organization, err := currentOrganization(cmd, client)
47+
if err != nil {
48+
return xerrors.Errorf("get current org: %w", err)
49+
}
50+
51+
workspace, err := client.WorkspaceByOwnerAndName(cmd.Context(), organization.ID, codersdk.Me, args[0])
52+
if err != nil {
53+
return xerrors.Errorf("get workspace: %w", err)
54+
}
55+
56+
if workspace.LatestBuild.Deadline.IsZero() {
57+
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "no deadline set\n")
58+
return nil
59+
}
60+
61+
newDeadline := workspace.LatestBuild.Deadline.Add(bumpDuration)
62+
if err := client.PutExtendWorkspace(cmd.Context(), workspace.ID, codersdk.PutExtendWorkspaceRequest{
63+
Deadline: newDeadline,
64+
}); err != nil {
65+
return err
66+
}
67+
68+
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Workspace %q will now stop at %s\n", workspace.Name, newDeadline.Format(time.RFC3339))
69+
70+
return nil
71+
},
72+
}
73+
74+
return bumpCmd
75+
}
76+
77+
func tryParseDuration(raw string) (time.Duration, error) {
78+
// If the user input a raw number, assume minutes
79+
if isDigit(raw) {
80+
raw = raw + "m"
81+
}
82+
d, err := time.ParseDuration(raw)
83+
if err != nil {
84+
return 0, err
85+
}
86+
return d, nil
87+
}
88+
89+
func isDigit(s string) bool {
90+
return strings.IndexFunc(s, func(c rune) bool {
91+
return c < '0' || c > '9'
92+
}) == -1
93+
}

cli/bump_test.go

+218
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
package cli_test
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"testing"
7+
"time"
8+
9+
"github.com/stretchr/testify/require"
10+
11+
"github.com/coder/coder/cli/clitest"
12+
"github.com/coder/coder/coderd/coderdtest"
13+
"github.com/coder/coder/codersdk"
14+
)
15+
16+
func TestBump(t *testing.T) {
17+
t.Parallel()
18+
19+
t.Run("BumpOKDefault", func(t *testing.T) {
20+
t.Parallel()
21+
22+
// Given: we have a workspace
23+
var (
24+
err error
25+
ctx = context.Background()
26+
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
27+
user = coderdtest.CreateFirstUser(t, client)
28+
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
29+
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
30+
project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
31+
workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID)
32+
cmdArgs = []string{"bump", workspace.Name}
33+
stdoutBuf = &bytes.Buffer{}
34+
)
35+
36+
// Given: we wait for the workspace to be built
37+
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
38+
workspace, err = client.Workspace(ctx, workspace.ID)
39+
require.NoError(t, err)
40+
expectedDeadline := workspace.LatestBuild.Deadline.Add(90 * time.Minute)
41+
42+
// Assert test invariant: workspace build has a deadline set equal to now plus ttl
43+
require.WithinDuration(t, workspace.LatestBuild.Deadline, time.Now().Add(*workspace.TTL), time.Minute)
44+
require.NoError(t, err)
45+
46+
cmd, root := clitest.New(t, cmdArgs...)
47+
clitest.SetupConfig(t, client, root)
48+
cmd.SetOut(stdoutBuf)
49+
50+
// When: we execute `coder bump <workspace>`
51+
err = cmd.ExecuteContext(ctx)
52+
require.NoError(t, err, "unexpected error")
53+
54+
// Then: the deadline of the latest build is updated
55+
updated, err := client.Workspace(ctx, workspace.ID)
56+
require.NoError(t, err)
57+
require.WithinDuration(t, expectedDeadline, updated.LatestBuild.Deadline, time.Minute)
58+
})
59+
60+
t.Run("BumpSpecificDuration", func(t *testing.T) {
61+
t.Parallel()
62+
63+
// Given: we have a workspace
64+
var (
65+
err error
66+
ctx = context.Background()
67+
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
68+
user = coderdtest.CreateFirstUser(t, client)
69+
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
70+
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
71+
project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
72+
workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID)
73+
cmdArgs = []string{"bump", workspace.Name, "30"}
74+
stdoutBuf = &bytes.Buffer{}
75+
)
76+
77+
// Given: we wait for the workspace to be built
78+
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
79+
workspace, err = client.Workspace(ctx, workspace.ID)
80+
require.NoError(t, err)
81+
expectedDeadline := workspace.LatestBuild.Deadline.Add(30 * time.Minute)
82+
83+
// Assert test invariant: workspace build has a deadline set equal to now plus ttl
84+
require.WithinDuration(t, workspace.LatestBuild.Deadline, time.Now().Add(*workspace.TTL), time.Minute)
85+
require.NoError(t, err)
86+
87+
cmd, root := clitest.New(t, cmdArgs...)
88+
clitest.SetupConfig(t, client, root)
89+
cmd.SetOut(stdoutBuf)
90+
91+
// When: we execute `coder bump workspace <number without units>`
92+
err = cmd.ExecuteContext(ctx)
93+
require.NoError(t, err)
94+
95+
// Then: the deadline of the latest build is updated assuming the units are minutes
96+
updated, err := client.Workspace(ctx, workspace.ID)
97+
require.NoError(t, err)
98+
require.WithinDuration(t, expectedDeadline, updated.LatestBuild.Deadline, time.Minute)
99+
})
100+
101+
t.Run("BumpInvalidDuration", func(t *testing.T) {
102+
t.Parallel()
103+
104+
// Given: we have a workspace
105+
var (
106+
err error
107+
ctx = context.Background()
108+
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
109+
user = coderdtest.CreateFirstUser(t, client)
110+
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
111+
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
112+
project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
113+
workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID)
114+
cmdArgs = []string{"bump", workspace.Name, "kwyjibo"}
115+
stdoutBuf = &bytes.Buffer{}
116+
)
117+
118+
// Given: we wait for the workspace to be built
119+
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
120+
workspace, err = client.Workspace(ctx, workspace.ID)
121+
require.NoError(t, err)
122+
123+
// Assert test invariant: workspace build has a deadline set equal to now plus ttl
124+
require.WithinDuration(t, workspace.LatestBuild.Deadline, time.Now().Add(*workspace.TTL), time.Minute)
125+
require.NoError(t, err)
126+
127+
cmd, root := clitest.New(t, cmdArgs...)
128+
clitest.SetupConfig(t, client, root)
129+
cmd.SetOut(stdoutBuf)
130+
131+
// When: we execute `coder bump workspace <not a number>`
132+
err = cmd.ExecuteContext(ctx)
133+
// Then: the command fails
134+
require.ErrorContains(t, err, "invalid duration")
135+
})
136+
137+
t.Run("BumpNoDeadline", func(t *testing.T) {
138+
t.Parallel()
139+
140+
// Given: we have a workspace with no deadline set
141+
var (
142+
err error
143+
ctx = context.Background()
144+
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
145+
user = coderdtest.CreateFirstUser(t, client)
146+
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
147+
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
148+
project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
149+
workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID, func(cwr *codersdk.CreateWorkspaceRequest) {
150+
cwr.TTL = nil
151+
})
152+
cmdArgs = []string{"bump", workspace.Name}
153+
stdoutBuf = &bytes.Buffer{}
154+
)
155+
156+
// Given: we wait for the workspace to build
157+
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
158+
workspace, err = client.Workspace(ctx, workspace.ID)
159+
require.NoError(t, err)
160+
161+
// Assert test invariant: workspace has no TTL set
162+
require.Zero(t, workspace.LatestBuild.Deadline)
163+
require.NoError(t, err)
164+
165+
cmd, root := clitest.New(t, cmdArgs...)
166+
clitest.SetupConfig(t, client, root)
167+
cmd.SetOut(stdoutBuf)
168+
169+
// When: we execute `coder bump workspace``
170+
err = cmd.ExecuteContext(ctx)
171+
require.NoError(t, err)
172+
173+
// Then: nothing happens and the deadline remains unset
174+
updated, err := client.Workspace(ctx, workspace.ID)
175+
require.NoError(t, err)
176+
require.Zero(t, updated.LatestBuild.Deadline)
177+
})
178+
179+
t.Run("BumpMinimumDuration", func(t *testing.T) {
180+
t.Parallel()
181+
182+
// Given: we have a workspace with no deadline set
183+
var (
184+
err error
185+
ctx = context.Background()
186+
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
187+
user = coderdtest.CreateFirstUser(t, client)
188+
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
189+
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
190+
project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
191+
workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID)
192+
cmdArgs = []string{"bump", workspace.Name, "59s"}
193+
stdoutBuf = &bytes.Buffer{}
194+
)
195+
196+
// Given: we wait for the workspace to build
197+
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
198+
workspace, err = client.Workspace(ctx, workspace.ID)
199+
require.NoError(t, err)
200+
201+
// Assert test invariant: workspace build has a deadline set equal to now plus ttl
202+
require.WithinDuration(t, workspace.LatestBuild.Deadline, time.Now().Add(*workspace.TTL), time.Minute)
203+
require.NoError(t, err)
204+
205+
cmd, root := clitest.New(t, cmdArgs...)
206+
clitest.SetupConfig(t, client, root)
207+
cmd.SetOut(stdoutBuf)
208+
209+
// When: we execute `coder bump workspace 59s`
210+
err = cmd.ExecuteContext(ctx)
211+
require.ErrorContains(t, err, "minimum bump duration is 1 minute")
212+
213+
// Then: an error is reported and the deadline remains as before
214+
updated, err := client.Workspace(ctx, workspace.ID)
215+
require.NoError(t, err)
216+
require.WithinDuration(t, workspace.LatestBuild.Deadline, updated.LatestBuild.Deadline, time.Minute)
217+
})
218+
}

0 commit comments

Comments
 (0)