Skip to content

Commit 259d517

Browse files
committed
Merge branch 'main' into statusbar/presleyp/1032
2 parents 72c856e + 50ad2f8 commit 259d517

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

51 files changed

+1403
-314
lines changed

.github/workflows/release.yaml

+1-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ jobs:
1313
DOCKER_CLI_EXPERIMENTAL: "enabled"
1414
steps:
1515
# Docker is not included on macos-latest
16-
- uses: docker-practice/actions-setup-docker@v1
16+
- uses: docker-practice/actions-setup-docker@v1.0.8
1717

1818
- uses: actions/checkout@v3
1919
with:

cli/autostart.go

+40-1
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,54 @@ func autostart() *cobra.Command {
2323
Short: "schedule a workspace to automatically start at a regular time",
2424
Long: autostartDescriptionLong,
2525
Example: "coder autostart enable my-workspace --minute 30 --hour 9 --days 1-5 --tz Europe/Dublin",
26-
Hidden: true,
2726
}
2827

28+
autostartCmd.AddCommand(autostartShow())
2929
autostartCmd.AddCommand(autostartEnable())
3030
autostartCmd.AddCommand(autostartDisable())
3131

3232
return autostartCmd
3333
}
3434

35+
func autostartShow() *cobra.Command {
36+
cmd := &cobra.Command{
37+
Use: "show <workspace_name>",
38+
Args: cobra.ExactArgs(1),
39+
RunE: func(cmd *cobra.Command, args []string) error {
40+
client, err := createClient(cmd)
41+
if err != nil {
42+
return err
43+
}
44+
organization, err := currentOrganization(cmd, client)
45+
if err != nil {
46+
return err
47+
}
48+
49+
workspace, err := client.WorkspaceByOwnerAndName(cmd.Context(), organization.ID, codersdk.Me, args[0])
50+
if err != nil {
51+
return err
52+
}
53+
54+
if workspace.AutostartSchedule == "" {
55+
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "not enabled\n")
56+
return nil
57+
}
58+
59+
validSchedule, err := schedule.Weekly(workspace.AutostartSchedule)
60+
if err != nil {
61+
// This should never happen.
62+
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "invalid autostart schedule %q for workspace %s: %s\n", workspace.AutostartSchedule, workspace.Name, err.Error())
63+
return nil
64+
}
65+
66+
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "schedule: %s\nnext: %s\n", workspace.AutostartSchedule, validSchedule.Next(time.Now()))
67+
68+
return nil
69+
},
70+
}
71+
return cmd
72+
}
73+
3574
func autostartEnable() *cobra.Command {
3675
// yes some of these are technically numbers but the cron library will do that work
3776
var autostartMinute string

cli/autostart_test.go

+32
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,43 @@ import (
1111

1212
"github.com/coder/coder/cli/clitest"
1313
"github.com/coder/coder/coderd/coderdtest"
14+
"github.com/coder/coder/codersdk"
1415
)
1516

1617
func TestAutostart(t *testing.T) {
1718
t.Parallel()
1819

20+
t.Run("ShowOK", func(t *testing.T) {
21+
t.Parallel()
22+
23+
var (
24+
ctx = context.Background()
25+
client = coderdtest.New(t, nil)
26+
_ = coderdtest.NewProvisionerDaemon(t, client)
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{"autostart", "show", workspace.Name}
33+
sched = "CRON_TZ=Europe/Dublin 30 17 * * 1-5"
34+
stdoutBuf = &bytes.Buffer{}
35+
)
36+
37+
err := client.UpdateWorkspaceAutostart(ctx, workspace.ID, codersdk.UpdateWorkspaceAutostartRequest{
38+
Schedule: sched,
39+
})
40+
require.NoError(t, err)
41+
42+
cmd, root := clitest.New(t, cmdArgs...)
43+
clitest.SetupConfig(t, client, root)
44+
cmd.SetOut(stdoutBuf)
45+
46+
err = cmd.Execute()
47+
require.NoError(t, err, "unexpected error")
48+
require.Contains(t, stdoutBuf.String(), "schedule: "+sched)
49+
})
50+
1951
t.Run("EnableDisableOK", func(t *testing.T) {
2052
t.Parallel()
2153

cli/autostop.go

+45-5
Original file line numberDiff line numberDiff line change
@@ -18,19 +18,59 @@ The default autostop schedule is at 18:00 in your local timezone (TZ env, UTC by
1818

1919
func autostop() *cobra.Command {
2020
autostopCmd := &cobra.Command{
21-
Use: "autostop enable <workspace>",
22-
Short: "schedule a workspace to automatically stop at a regular time",
23-
Long: autostopDescriptionLong,
24-
Example: "coder autostop enable my-workspace --minute 0 --hour 18 --days 1-5 -tz Europe/Dublin",
25-
Hidden: true,
21+
Annotations: workspaceCommand,
22+
Use: "autostop enable <workspace>",
23+
Short: "schedule a workspace to automatically stop at a regular time",
24+
Long: autostopDescriptionLong,
25+
Example: "coder autostop enable my-workspace --minute 0 --hour 18 --days 1-5 -tz Europe/Dublin",
2626
}
2727

28+
autostopCmd.AddCommand(autostopShow())
2829
autostopCmd.AddCommand(autostopEnable())
2930
autostopCmd.AddCommand(autostopDisable())
3031

3132
return autostopCmd
3233
}
3334

35+
func autostopShow() *cobra.Command {
36+
cmd := &cobra.Command{
37+
Use: "show <workspace_name>",
38+
Args: cobra.ExactArgs(1),
39+
RunE: func(cmd *cobra.Command, args []string) error {
40+
client, err := createClient(cmd)
41+
if err != nil {
42+
return err
43+
}
44+
organization, err := currentOrganization(cmd, client)
45+
if err != nil {
46+
return err
47+
}
48+
49+
workspace, err := client.WorkspaceByOwnerAndName(cmd.Context(), organization.ID, codersdk.Me, args[0])
50+
if err != nil {
51+
return err
52+
}
53+
54+
if workspace.AutostopSchedule == "" {
55+
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "not enabled\n")
56+
return nil
57+
}
58+
59+
validSchedule, err := schedule.Weekly(workspace.AutostopSchedule)
60+
if err != nil {
61+
// This should never happen.
62+
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "invalid autostop schedule %q for workspace %s: %s\n", workspace.AutostopSchedule, workspace.Name, err.Error())
63+
return nil
64+
}
65+
66+
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "schedule: %s\nnext: %s\n", workspace.AutostopSchedule, validSchedule.Next(time.Now()))
67+
68+
return nil
69+
},
70+
}
71+
return cmd
72+
}
73+
3474
func autostopEnable() *cobra.Command {
3575
// yes some of these are technically numbers but the cron library will do that work
3676
var autostopMinute string

cli/autostop_test.go

+32
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,43 @@ import (
1111

1212
"github.com/coder/coder/cli/clitest"
1313
"github.com/coder/coder/coderd/coderdtest"
14+
"github.com/coder/coder/codersdk"
1415
)
1516

1617
func TestAutostop(t *testing.T) {
1718
t.Parallel()
1819

20+
t.Run("ShowOK", func(t *testing.T) {
21+
t.Parallel()
22+
23+
var (
24+
ctx = context.Background()
25+
client = coderdtest.New(t, nil)
26+
_ = coderdtest.NewProvisionerDaemon(t, client)
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{"autostop", "show", workspace.Name}
33+
sched = "CRON_TZ=Europe/Dublin 30 17 * * 1-5"
34+
stdoutBuf = &bytes.Buffer{}
35+
)
36+
37+
err := client.UpdateWorkspaceAutostop(ctx, workspace.ID, codersdk.UpdateWorkspaceAutostopRequest{
38+
Schedule: sched,
39+
})
40+
require.NoError(t, err)
41+
42+
cmd, root := clitest.New(t, cmdArgs...)
43+
clitest.SetupConfig(t, client, root)
44+
cmd.SetOut(stdoutBuf)
45+
46+
err = cmd.Execute()
47+
require.NoError(t, err, "unexpected error")
48+
require.Contains(t, stdoutBuf.String(), "schedule: "+sched)
49+
})
50+
1951
t.Run("EnableDisableOK", func(t *testing.T) {
2052
t.Parallel()
2153

cli/list.go

+13-1
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ func list() *cobra.Command {
4949
}
5050

5151
tableWriter := cliui.Table()
52-
header := table.Row{"workspace", "template", "status", "last built", "outdated"}
52+
header := table.Row{"workspace", "template", "status", "last built", "outdated", "autostart", "autostop"}
5353
tableWriter.AppendHeader(header)
5454
tableWriter.SortBy([]table.SortBy{{
5555
Name: "workspace",
@@ -108,13 +108,25 @@ func list() *cobra.Command {
108108
durationDisplay = durationDisplay[:len(durationDisplay)-2]
109109
}
110110

111+
autostartDisplay := "not enabled"
112+
if workspace.AutostartSchedule != "" {
113+
autostartDisplay = workspace.AutostartSchedule
114+
}
115+
116+
autostopDisplay := "not enabled"
117+
if workspace.AutostopSchedule != "" {
118+
autostopDisplay = workspace.AutostopSchedule
119+
}
120+
111121
user := usersByID[workspace.OwnerID]
112122
tableWriter.AppendRow(table.Row{
113123
user.Username + "/" + workspace.Name,
114124
workspace.TemplateName,
115125
status,
116126
durationDisplay,
117127
workspace.Outdated,
128+
autostartDisplay,
129+
autostopDisplay,
118130
})
119131
}
120132
_, err = fmt.Fprintln(cmd.OutOrStdout(), tableWriter.Render())

cli/ssh.go

+67
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,15 @@ package cli
22

33
import (
44
"context"
5+
"fmt"
56
"io"
67
"os"
8+
"path/filepath"
79
"strings"
10+
"time"
811

12+
"github.com/gen2brain/beeep"
13+
"github.com/gofrs/flock"
914
"github.com/google/uuid"
1015
"github.com/mattn/go-isatty"
1116
"github.com/spf13/cobra"
@@ -15,10 +20,15 @@ import (
1520

1621
"github.com/coder/coder/cli/cliflag"
1722
"github.com/coder/coder/cli/cliui"
23+
"github.com/coder/coder/coderd/autobuild/notify"
24+
"github.com/coder/coder/coderd/autobuild/schedule"
1825
"github.com/coder/coder/coderd/database"
1926
"github.com/coder/coder/codersdk"
2027
)
2128

29+
var autostopPollInterval = 30 * time.Second
30+
var autostopNotifyCountdown = []time.Duration{5 * time.Minute}
31+
2232
func ssh() *cobra.Command {
2333
var (
2434
stdio bool
@@ -108,6 +118,9 @@ func ssh() *cobra.Command {
108118
}
109119
defer conn.Close()
110120

121+
stopPolling := tryPollWorkspaceAutostop(cmd.Context(), client, workspace)
122+
defer stopPolling()
123+
111124
if stdio {
112125
rawSSH, err := conn.SSH()
113126
if err != nil {
@@ -179,3 +192,57 @@ func ssh() *cobra.Command {
179192

180193
return cmd
181194
}
195+
196+
// Attempt to poll workspace autostop. We write a per-workspace lockfile to
197+
// avoid spamming the user with notifications in case of multiple instances
198+
// of the CLI running simultaneously.
199+
func tryPollWorkspaceAutostop(ctx context.Context, client *codersdk.Client, workspace codersdk.Workspace) (stop func()) {
200+
lock := flock.New(filepath.Join(os.TempDir(), "coder-autostop-notify-"+workspace.ID.String()))
201+
condition := notifyCondition(ctx, client, workspace.ID, lock)
202+
return notify.Notify(condition, autostopPollInterval, autostopNotifyCountdown...)
203+
}
204+
205+
// Notify the user if the workspace is due to shutdown.
206+
func notifyCondition(ctx context.Context, client *codersdk.Client, workspaceID uuid.UUID, lock *flock.Flock) notify.Condition {
207+
return func(now time.Time) (deadline time.Time, callback func()) {
208+
// Keep trying to regain the lock.
209+
locked, err := lock.TryLockContext(ctx, autostopPollInterval)
210+
if err != nil || !locked {
211+
return time.Time{}, nil
212+
}
213+
214+
ws, err := client.Workspace(ctx, workspaceID)
215+
if err != nil {
216+
return time.Time{}, nil
217+
}
218+
219+
if ws.AutostopSchedule == "" {
220+
return time.Time{}, nil
221+
}
222+
223+
sched, err := schedule.Weekly(ws.AutostopSchedule)
224+
if err != nil {
225+
return time.Time{}, nil
226+
}
227+
228+
deadline = sched.Next(now)
229+
callback = func() {
230+
ttl := deadline.Sub(now)
231+
var title, body string
232+
if ttl > time.Minute {
233+
title = fmt.Sprintf(`Workspace %s stopping in %.0f mins`, ws.Name, ttl.Minutes())
234+
body = fmt.Sprintf(
235+
`Your Coder workspace %s is scheduled to stop at %s.`,
236+
ws.Name,
237+
deadline.Format(time.Kitchen),
238+
)
239+
} else {
240+
title = fmt.Sprintf("Workspace %s stopping!", ws.Name)
241+
body = fmt.Sprintf("Your Coder workspace %s is stopping any time now!", ws.Name)
242+
}
243+
// notify user with a native system notification (best effort)
244+
_ = beeep.Notify(title, body, "")
245+
}
246+
return deadline.Truncate(time.Minute), callback
247+
}
248+
}

0 commit comments

Comments
 (0)