From dd3fa2ab2aa87bed2bddae8734ba646948b8d887 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 8 Nov 2023 17:08:06 +0000 Subject: [PATCH 1/7] feat(cli): allow showing schedules for multiple workspaces --- cli/constants.go | 6 - cli/list.go | 84 +-- cli/schedule.go | 130 ++-- cli/schedule_test.go | 626 +++++++++--------- cli/testdata/coder_list_--help.golden | 4 +- cli/testdata/coder_schedule_--help.golden | 2 +- .../coder_schedule_show_--help.golden | 20 +- cli/util.go | 11 + coderd/schedule/cron/cron.go | 15 +- docs/cli/list.md | 2 +- docs/cli/schedule.md | 2 +- docs/cli/schedule_show.md | 43 +- docs/manifest.json | 2 +- 13 files changed, 516 insertions(+), 431 deletions(-) delete mode 100644 cli/constants.go diff --git a/cli/constants.go b/cli/constants.go deleted file mode 100644 index 64d28c7d2a16c..0000000000000 --- a/cli/constants.go +++ /dev/null @@ -1,6 +0,0 @@ -package cli - -const ( - timeFormat = "3:04PM MST" - dateFormat = "Jan 2, 2006" -) diff --git a/cli/list.go b/cli/list.go index f7479e789978f..c42329e033f66 100644 --- a/cli/list.go +++ b/cli/list.go @@ -1,19 +1,17 @@ package cli import ( + "context" "fmt" "strconv" "time" - "github.com/google/uuid" - - "github.com/coder/pretty" + "golang.org/x/xerrors" "github.com/coder/coder/v2/cli/clibase" "github.com/coder/coder/v2/cli/cliui" - "github.com/coder/coder/v2/coderd/schedule/cron" - "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" + "github.com/coder/pretty" ) // workspaceListRow is the type provided to the OutputFormatter. This is a bit @@ -31,55 +29,42 @@ type workspaceListRow struct { LastBuilt string `json:"-" table:"last built"` Outdated bool `json:"-" table:"outdated"` StartsAt string `json:"-" table:"starts at"` + StartsNext string `json:"-" table:"starts next"` StopsAfter string `json:"-" table:"stops after"` + StopsNext string `json:"-" table:"stops next"` DailyCost string `json:"-" table:"daily cost"` } -func workspaceListRowFromWorkspace(now time.Time, usersByID map[uuid.UUID]codersdk.User, workspace codersdk.Workspace) workspaceListRow { +func workspaceListRowFromWorkspace(now time.Time, workspace codersdk.Workspace) workspaceListRow { status := codersdk.WorkspaceDisplayStatus(workspace.LatestBuild.Job.Status, workspace.LatestBuild.Transition) lastBuilt := now.UTC().Sub(workspace.LatestBuild.Job.CreatedAt).Truncate(time.Second) - autostartDisplay := "-" - if !ptr.NilOrEmpty(workspace.AutostartSchedule) { - if sched, err := cron.Weekly(*workspace.AutostartSchedule); err == nil { - autostartDisplay = fmt.Sprintf("%s %s (%s)", sched.Time(), sched.DaysOfWeek(), sched.Location()) - } - } - - autostopDisplay := "-" - if !ptr.NilOrZero(workspace.TTLMillis) { - dur := time.Duration(*workspace.TTLMillis) * time.Millisecond - autostopDisplay = durationDisplay(dur) - if !workspace.LatestBuild.Deadline.IsZero() && workspace.LatestBuild.Deadline.Time.After(now) && status == "Running" { - remaining := time.Until(workspace.LatestBuild.Deadline.Time) - autostopDisplay = fmt.Sprintf("%s (%s)", autostopDisplay, relative(remaining)) - } - } + schedRow := scheduleListRowFromWorkspace(now, workspace) healthy := "" if status == "Starting" || status == "Started" { healthy = strconv.FormatBool(workspace.Health.Healthy) } - user := usersByID[workspace.OwnerID] return workspaceListRow{ Workspace: workspace, - WorkspaceName: user.Username + "/" + workspace.Name, + WorkspaceName: workspace.OwnerName + "/" + workspace.Name, Template: workspace.TemplateName, Status: status, Healthy: healthy, LastBuilt: durationDisplay(lastBuilt), Outdated: workspace.Outdated, - StartsAt: autostartDisplay, - StopsAfter: autostopDisplay, + StartsAt: schedRow.StartsAt, + StartsNext: schedRow.StartsNext, + StopsAfter: schedRow.StopsAfter, + StopsNext: schedRow.StopsNext, DailyCost: strconv.Itoa(int(workspace.LatestBuild.DailyCost)), } } func (r *RootCmd) list() *clibase.Cmd { var ( - filter cliui.WorkspaceFilter - displayWorkspaces []workspaceListRow - formatter = cliui.NewOutputFormatter( + filter cliui.WorkspaceFilter + formatter = cliui.NewOutputFormatter( cliui.TableFormat( []workspaceListRow{}, []string{ @@ -107,11 +92,12 @@ func (r *RootCmd) list() *clibase.Cmd { r.InitClient(client), ), Handler: func(inv *clibase.Invocation) error { - res, err := client.Workspaces(inv.Context(), filter.Filter()) + res, err := queryConvertWorkspaces(inv.Context(), client, filter.Filter(), workspaceListRowFromWorkspace) if err != nil { return err } - if len(res.Workspaces) == 0 { + + if len(res) == 0 { pretty.Fprintf(inv.Stderr, cliui.DefaultStyles.Prompt, "No workspaces found! Create one:\n") _, _ = fmt.Fprintln(inv.Stderr) _, _ = fmt.Fprintln(inv.Stderr, " "+pretty.Sprint(cliui.DefaultStyles.Code, "coder create ")) @@ -119,23 +105,7 @@ func (r *RootCmd) list() *clibase.Cmd { return nil } - userRes, err := client.Users(inv.Context(), codersdk.UsersRequest{}) - if err != nil { - return err - } - - usersByID := map[uuid.UUID]codersdk.User{} - for _, user := range userRes.Users { - usersByID[user.ID] = user - } - - now := time.Now() - displayWorkspaces = make([]workspaceListRow, len(res.Workspaces)) - for i, workspace := range res.Workspaces { - displayWorkspaces[i] = workspaceListRowFromWorkspace(now, usersByID, workspace) - } - - out, err := formatter.Format(inv.Context(), displayWorkspaces) + out, err := formatter.Format(inv.Context(), res) if err != nil { return err } @@ -148,3 +118,21 @@ func (r *RootCmd) list() *clibase.Cmd { formatter.AttachOptions(&cmd.Options) return cmd } + +// queryConvertWorkspaces is a helper function for converting +// codersdk.Workspaces to a different type. +// It's used by the list command to convert workspaces to +// workspaceListRow, and by the schedule command to +// convert workspaces to scheduleListRow. +func queryConvertWorkspaces[T any](ctx context.Context, client *codersdk.Client, filter codersdk.WorkspaceFilter, convertF func(time.Time, codersdk.Workspace) T) ([]T, error) { + var empty []T + workspaces, err := client.Workspaces(ctx, filter) + if err != nil { + return empty, xerrors.Errorf("query workspaces: %w", err) + } + converted := make([]T, len(workspaces.Workspaces)) + for i, workspace := range workspaces.Workspaces { + converted[i] = convertF(time.Now(), workspace) + } + return converted, nil +} diff --git a/cli/schedule.go b/cli/schedule.go index 6b0f105875c80..d1269e84965ea 100644 --- a/cli/schedule.go +++ b/cli/schedule.go @@ -3,9 +3,9 @@ package cli import ( "fmt" "io" + "strings" "time" - "github.com/jedib0t/go-pretty/v6/table" "golang.org/x/xerrors" "github.com/coder/coder/v2/cli/clibase" @@ -17,7 +17,7 @@ import ( ) const ( - scheduleShowDescriptionLong = `Shows the following information for the given workspace: + scheduleShowDescriptionLong = `Shows the following information for the given workspace(s): * The automatic start schedule * The next scheduled start time * The duration after which it will stop @@ -72,25 +72,67 @@ func (r *RootCmd) schedules() *clibase.Cmd { return scheduleCmd } +// scheduleShow() is just a wrapper for list() with some different defaults. func (r *RootCmd) scheduleShow() *clibase.Cmd { + var ( + filter cliui.WorkspaceFilter + formatter = cliui.NewOutputFormatter( + cliui.TableFormat( + []scheduleListRow{}, + []string{ + "workspace", + "starts at", + "starts next", + "stops after", + "stops next", + }, + ), + cliui.JSONFormat(), + ) + ) client := new(codersdk.Client) showCmd := &clibase.Cmd{ - Use: "show ", - Short: "Show workspace schedule", + Use: "show [] | [--search ] [--all]", + Short: "Show workspace schedules", Long: scheduleShowDescriptionLong, Middleware: clibase.Chain( - clibase.RequireNArgs(1), + clibase.RequireRangeArgs(0, 1), r.InitClient(client), ), Handler: func(inv *clibase.Invocation) error { - workspace, err := namedWorkspace(inv.Context(), client, inv.Args[0]) + // To preserve existing behavior, if an argument is passed we will + // only show the schedule for that workspace. + // This will clobber the search query if one is passed. + f := filter.Filter() + if len(inv.Args) == 1 { + // If the argument contains a slash, we assume it's a full owner/name reference + if strings.Contains(inv.Args[0], "/") { + _, workspaceName, err := splitNamedWorkspace(inv.Args[0]) + if err != nil { + return err + } + f.FilterQuery = fmt.Sprintf("name:%s", workspaceName) + } else { + // Otherwise, we assume it's a workspace name owned by the current user + f.FilterQuery = fmt.Sprintf("owner:me name:%s", inv.Args[0]) + } + } + res, err := queryConvertWorkspaces(inv.Context(), client, f, scheduleListRowFromWorkspace) if err != nil { return err } - return displaySchedule(workspace, inv.Stdout) + out, err := formatter.Format(inv.Context(), res) + if err != nil { + return err + } + + _, err = fmt.Fprintln(inv.Stdout, out) + return err }, } + filter.AttachOptions(&showCmd.Options) + formatter.AttachOptions(&showCmd.Options) return showCmd } @@ -242,50 +284,52 @@ func (r *RootCmd) scheduleOverride() *clibase.Cmd { return overrideCmd } -func displaySchedule(workspace codersdk.Workspace, out io.Writer) error { - loc, err := tz.TimezoneIANA() +func displaySchedule(ws codersdk.Workspace, out io.Writer) error { + rows := []workspaceListRow{workspaceListRowFromWorkspace(time.Now(), ws)} + rendered, err := cliui.DisplayTable(rows, "workspace", []string{ + "workspace", "starts at", "starts next", "stops after", "stops next", + }) if err != nil { - loc = time.UTC // best effort + return err } + _, _ = fmt.Fprintln(out, rendered) + return nil +} - var ( - schedStart = "manual" - schedStop = "manual" - schedNextStart = "-" - schedNextStop = "-" - ) +// scheduleListRow is a row in the schedule list. +// this is required for proper JSON output. +type scheduleListRow struct { + WorkspaceName string `json:"workspace" table:"workspace,default_sort"` + StartsAt string `json:"starts_at" table:"starts at"` + StartsNext string `json:"starts_next" table:"starts next"` + StopsAfter string `json:"stops_after" table:"stops after"` + StopsNext string `json:"stops_next" table:"stops next"` +} + +func scheduleListRowFromWorkspace(now time.Time, workspace codersdk.Workspace) scheduleListRow { + autostartDisplay := "" + nextStartDisplay := "" if !ptr.NilOrEmpty(workspace.AutostartSchedule) { - sched, err := cron.Weekly(ptr.NilToEmpty(workspace.AutostartSchedule)) - if err != nil { - // This should never happen. - _, _ = fmt.Fprintf(out, "Invalid autostart schedule %q for workspace %s: %s\n", *workspace.AutostartSchedule, workspace.Name, err.Error()) - return nil + if sched, err := cron.Weekly(*workspace.AutostartSchedule); err == nil { + autostartDisplay = sched.Humanize() + nextStartDisplay = timeDisplay(sched.Next(now)) } - schedNext := sched.Next(time.Now()).In(sched.Location()) - schedStart = fmt.Sprintf("%s %s (%s)", sched.Time(), sched.DaysOfWeek(), sched.Location()) - schedNextStart = schedNext.Format(timeFormat + " on " + dateFormat) } + autostopDisplay := "" + nextStopDisplay := "" if !ptr.NilOrZero(workspace.TTLMillis) { - d := time.Duration(*workspace.TTLMillis) * time.Millisecond - schedStop = durationDisplay(d) + " after start" - } - - if !workspace.LatestBuild.Deadline.IsZero() { - if workspace.LatestBuild.Transition != "start" { - schedNextStop = "-" - } else { - schedNextStop = workspace.LatestBuild.Deadline.Time.In(loc).Format(timeFormat + " on " + dateFormat) - schedNextStop = fmt.Sprintf("%s (in %s)", schedNextStop, durationDisplay(time.Until(workspace.LatestBuild.Deadline.Time))) + dur := time.Duration(*workspace.TTLMillis) * time.Millisecond + autostopDisplay = durationDisplay(dur) + if !workspace.LatestBuild.Deadline.IsZero() && workspace.LatestBuild.Transition == codersdk.WorkspaceTransitionStart { + nextStopDisplay = timeDisplay(workspace.LatestBuild.Deadline.Time) } } - - tw := cliui.Table() - tw.AppendRow(table.Row{"Starts at", schedStart}) - tw.AppendRow(table.Row{"Starts next", schedNextStart}) - tw.AppendRow(table.Row{"Stops at", schedStop}) - tw.AppendRow(table.Row{"Stops next", schedNextStop}) - - _, _ = fmt.Fprintln(out, tw.Render()) - return nil + return scheduleListRow{ + WorkspaceName: workspace.OwnerName + "/" + workspace.Name, + StartsAt: autostartDisplay, + StartsNext: nextStartDisplay, + StopsAfter: autostopDisplay, + StopsNext: nextStopDisplay, + } } diff --git a/cli/schedule_test.go b/cli/schedule_test.go index dfb992976bc62..9a134335a17eb 100644 --- a/cli/schedule_test.go +++ b/cli/schedule_test.go @@ -3,8 +3,9 @@ package cli_test import ( "bytes" "context" - "fmt" - "strings" + "database/sql" + "encoding/json" + "sort" "testing" "time" @@ -14,372 +15,355 @@ import ( "github.com/coder/coder/v2/cli/clitest" "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" - "github.com/coder/coder/v2/coderd/util/ptr" + "github.com/coder/coder/v2/coderd/database/dbfake" + "github.com/coder/coder/v2/coderd/schedule/cron" "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/pty/ptytest" + "github.com/coder/coder/v2/testutil" ) +// setupTestSchedule creates 4 workspaces: +// 1. a-owner-ws1: owned by owner, has both autostart and autostop enabled. +// 2. b-owner-ws2: owned by owner, has only autostart enabled. +// 3. c-member-ws3: owned by member, has only autostop enabled. +// 4. d-member-ws4: owned by member, has neither autostart nor autostop enabled. +// It returns the owner and member clients, the database, and the workspaces. +// The workspaces are returned in the same order as they are created. +func setupTestSchedule(t *testing.T, sched *cron.Schedule) (ownerClient, memberClient *codersdk.Client, db database.Store, ws []codersdk.Workspace) { + ownerClient, db = coderdtest.NewWithDatabase(t, nil) + owner := coderdtest.CreateFirstUser(t, ownerClient) + memberClient, memberUser := coderdtest.CreateAnotherUserMutators(t, ownerClient, owner.OrganizationID, nil, func(r *codersdk.CreateUserRequest) { + r.Username = "testuser2" // ensure deterministic ordering + }) + _, _ = dbfake.WorkspaceWithAgent(t, db, database.Workspace{ + Name: "a-owner", + OwnerID: owner.UserID, + OrganizationID: owner.OrganizationID, + AutostartSchedule: sql.NullString{String: sched.String(), Valid: true}, + Ttl: sql.NullInt64{Int64: 8 * time.Hour.Nanoseconds(), Valid: true}, + }) + _, _ = dbfake.WorkspaceWithAgent(t, db, database.Workspace{ + Name: "b-owner", + OwnerID: owner.UserID, + OrganizationID: owner.OrganizationID, + AutostartSchedule: sql.NullString{String: sched.String(), Valid: true}, + }) + _, _ = dbfake.WorkspaceWithAgent(t, db, database.Workspace{ + Name: "c-member", + OwnerID: memberUser.ID, + OrganizationID: owner.OrganizationID, + Ttl: sql.NullInt64{Int64: 8 * time.Hour.Nanoseconds(), Valid: true}, + }) + _, _ = dbfake.WorkspaceWithAgent(t, db, database.Workspace{ + Name: "d-member", + OwnerID: memberUser.ID, + OrganizationID: owner.OrganizationID, + }) + + // Need this for LatestBuild.Deadline + resp, err := ownerClient.Workspaces(context.Background(), codersdk.WorkspaceFilter{}) + require.NoError(t, err) + require.Len(t, resp.Workspaces, 4) + // Ensure same order as in CLI output + ws = resp.Workspaces + sort.Slice(ws, func(i, j int) bool { + a := ws[i].OwnerName + "/" + ws[i].Name + b := ws[j].OwnerName + "/" + ws[j].Name + return a < b + }) + + return ownerClient, memberClient, db, ws +} + func TestScheduleShow(t *testing.T) { t.Parallel() - t.Run("Enabled", func(t *testing.T) { - t.Parallel() - var ( - tz = "Europe/Dublin" - sched = "30 7 * * 1-5" - schedCron = fmt.Sprintf("CRON_TZ=%s %s", tz, sched) - ttl = 8 * time.Hour - client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user = coderdtest.CreateFirstUser(t, client) - version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) - project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID, func(cwr *codersdk.CreateWorkspaceRequest) { - cwr.AutostartSchedule = ptr.Ref(schedCron) - cwr.TTLMillis = ptr.Ref(ttl.Milliseconds()) - }) - _ = coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) - cmdArgs = []string{"schedule", "show", workspace.Name} - stdoutBuf = &bytes.Buffer{} - ) + // Given + sched, err := cron.Weekly("CRON_TZ=Europe/Dublin 30 7 * * Mon-Fri") + require.NoError(t, err, "invalid schedule") + ownerClient, memberClient, _, ws := setupTestSchedule(t, sched) + now := time.Now() - inv, root := clitest.New(t, cmdArgs...) - clitest.SetupConfig(t, client, root) - inv.Stdout = stdoutBuf - - err := inv.Run() - require.NoError(t, err, "unexpected error") - lines := strings.Split(strings.TrimSpace(stdoutBuf.String()), "\n") - if assert.Len(t, lines, 4) { - assert.Contains(t, lines[0], "Starts at 7:30AM Mon-Fri (Europe/Dublin)") - assert.Contains(t, lines[1], "Starts next 7:30AM") - // it should have either IST or GMT - if !strings.Contains(lines[1], "IST") && !strings.Contains(lines[1], "GMT") { - t.Error("expected either IST or GMT") - } - assert.Contains(t, lines[2], "Stops at 8h after start") - assert.NotContains(t, lines[3], "Stops next -") - } + t.Run("OwnerNoArgs", func(t *testing.T) { + t.Parallel() + + // When: owner specifies no args + inv, root := clitest.New(t, "schedule", "show") + //nolint:gocritic // Testing that owner user sees all + clitest.SetupConfig(t, ownerClient, root) + pty := ptytest.New(t).Attach(inv) + require.NoError(t, inv.Run()) + + // Then: they should see their own workspaces. + // 1st workspace: a-owner-ws1 has both autostart and autostop enabled. + pty.ExpectMatch(ws[0].OwnerName + "/" + ws[0].Name) + pty.ExpectMatch(sched.Humanize()) + pty.ExpectMatch(sched.Next(now).Format(time.RFC3339)) + pty.ExpectMatch("8h") + pty.ExpectMatch(ws[0].LatestBuild.Deadline.Time.Format(time.RFC3339)) + // 2nd workspace: b-owner-ws2 has only autostart enabled. + pty.ExpectMatch(ws[1].OwnerName + "/" + ws[1].Name) + pty.ExpectMatch(sched.Humanize()) + pty.ExpectMatch(sched.Next(now).Format(time.RFC3339)) }) - t.Run("Manual", func(t *testing.T) { + t.Run("OwnerAll", func(t *testing.T) { t.Parallel() - var ( - client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user = coderdtest.CreateFirstUser(t, client) - version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) - project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID, func(cwr *codersdk.CreateWorkspaceRequest) { - cwr.AutostartSchedule = nil - cwr.TTLMillis = nil - }) - _ = coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) - cmdArgs = []string{"schedule", "show", workspace.Name} - stdoutBuf = &bytes.Buffer{} - ) - - inv, root := clitest.New(t, cmdArgs...) - clitest.SetupConfig(t, client, root) - inv.Stdout = stdoutBuf - - err := inv.Run() - require.NoError(t, err, "unexpected error") - lines := strings.Split(strings.TrimSpace(stdoutBuf.String()), "\n") - if assert.Len(t, lines, 4) { - assert.Contains(t, lines[0], "Starts at manual") - assert.Contains(t, lines[1], "Starts next -") - assert.Contains(t, lines[2], "Stops at manual") - assert.Contains(t, lines[3], "Stops next -") - } + // When: owner lists all workspaces + inv, root := clitest.New(t, "schedule", "show", "--all") + //nolint:gocritic // Testing that owner user sees all + clitest.SetupConfig(t, ownerClient, root) + pty := ptytest.New(t).Attach(inv) + require.NoError(t, inv.Run()) + + // Then: they should see all workspaces + // 1st workspace: a-owner-ws1 has both autostart and autostop enabled. + pty.ExpectMatch(ws[0].OwnerName + "/" + ws[0].Name) + pty.ExpectMatch(sched.Humanize()) + pty.ExpectMatch(sched.Next(now).Format(time.RFC3339)) + pty.ExpectMatch("8h") + pty.ExpectMatch(ws[0].LatestBuild.Deadline.Time.Format(time.RFC3339)) + // 2nd workspace: b-owner-ws2 has only autostart enabled. + pty.ExpectMatch(ws[1].OwnerName + "/" + ws[1].Name) + pty.ExpectMatch(sched.Humanize()) + pty.ExpectMatch(sched.Next(now).Format(time.RFC3339)) + // 3rd workspace: c-member-ws3 has only autostop enabled. + pty.ExpectMatch(ws[2].OwnerName + "/" + ws[2].Name) + pty.ExpectMatch("8h") + pty.ExpectMatch(ws[2].LatestBuild.Deadline.Time.Format(time.RFC3339)) + // 4th workspace: d-member-ws4 has neither autostart nor autostop enabled. + pty.ExpectMatch(ws[3].OwnerName + "/" + ws[3].Name) }) - t.Run("NotFound", func(t *testing.T) { + t.Run("OwnerSearchByName", func(t *testing.T) { t.Parallel() - var ( - client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user = coderdtest.CreateFirstUser(t, client) - version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) - ) + // When: owner specifies a search query + inv, root := clitest.New(t, "schedule", "show", "--search", "name:"+ws[1].Name) + //nolint:gocritic // Testing that owner user sees all + clitest.SetupConfig(t, ownerClient, root) + pty := ptytest.New(t).Attach(inv) + require.NoError(t, inv.Run()) + + // Then: they should see workspaces matching that query + // 2nd workspace: b-owner-ws2 has only autostart enabled. + pty.ExpectMatch(ws[1].OwnerName + "/" + ws[1].Name) + pty.ExpectMatch(sched.Humanize()) + pty.ExpectMatch(sched.Next(now).Format(time.RFC3339)) + }) - inv, root := clitest.New(t, "schedule", "show", "doesnotexist") - clitest.SetupConfig(t, client, root) + t.Run("OwnerOneArg", func(t *testing.T) { + t.Parallel() - err := inv.Run() - require.ErrorContains(t, err, "status code 404", "unexpected error") + // When: owner asks for a specific workspace by name + inv, root := clitest.New(t, "schedule", "show", ws[2].OwnerName+"/"+ws[2].Name) + //nolint:gocritic // Testing that owner user sees all + clitest.SetupConfig(t, ownerClient, root) + pty := ptytest.New(t).Attach(inv) + require.NoError(t, inv.Run()) + + // Then: they should see that workspace + // 3rd workspace: c-member-ws3 has only autostop enabled. + pty.ExpectMatch(ws[2].OwnerName + "/" + ws[2].Name) + pty.ExpectMatch("8h") + pty.ExpectMatch(ws[2].LatestBuild.Deadline.Time.Format(time.RFC3339)) }) -} -func TestScheduleStart(t *testing.T) { - t.Parallel() + t.Run("MemberNoArgs", func(t *testing.T) { + t.Parallel() - var ( - ctx = context.Background() - client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user = coderdtest.CreateFirstUser(t, client) - version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) - project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID, func(cwr *codersdk.CreateWorkspaceRequest) { - cwr.AutostartSchedule = nil - }) - _ = coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) - tz = "Europe/Dublin" - sched = "CRON_TZ=Europe/Dublin 30 9 * * Mon-Fri" - stdoutBuf = &bytes.Buffer{} - ) + // When: a member specifies no args + inv, root := clitest.New(t, "schedule", "show") + clitest.SetupConfig(t, memberClient, root) + pty := ptytest.New(t).Attach(inv) + require.NoError(t, inv.Run()) + + // Then: they should see their own workspaces + // 1st workspace: c-member-ws3 has only autostop enabled. + pty.ExpectMatch(ws[2].OwnerName + "/" + ws[2].Name) + pty.ExpectMatch("8h") + pty.ExpectMatch(ws[2].LatestBuild.Deadline.Time.Format(time.RFC3339)) + // 2nd workspace: d-member-ws4 has neither autostart nor autostop enabled. + pty.ExpectMatch(ws[3].OwnerName + "/" + ws[3].Name) + }) - // Set a well-specified autostart schedule - inv, root := clitest.New(t, "schedule", "start", workspace.Name, "9:30AM", "Mon-Fri", tz) - clitest.SetupConfig(t, client, root) - inv.Stdout = stdoutBuf - - err := inv.Run() - assert.NoError(t, err, "unexpected error") - lines := strings.Split(strings.TrimSpace(stdoutBuf.String()), "\n") - if assert.Len(t, lines, 4) { - assert.Contains(t, lines[0], "Starts at 9:30AM Mon-Fri (Europe/Dublin)") - assert.Contains(t, lines[1], "Starts next 9:30AM") - // it should have either IST or GMT - if !strings.Contains(lines[1], "IST") && !strings.Contains(lines[1], "GMT") { - t.Error("expected either IST or GMT") - } - } - - // Ensure autostart schedule updated - updated, err := client.Workspace(ctx, workspace.ID) - require.NoError(t, err, "fetch updated workspace") - require.Equal(t, sched, *updated.AutostartSchedule, "expected autostart schedule to be set") - - // Reset stdout - stdoutBuf = &bytes.Buffer{} - - // unset schedule - inv, root = clitest.New(t, "schedule", "start", workspace.Name, "manual") - clitest.SetupConfig(t, client, root) - inv.Stdout = stdoutBuf - - err = inv.Run() - assert.NoError(t, err, "unexpected error") - lines = strings.Split(strings.TrimSpace(stdoutBuf.String()), "\n") - if assert.Len(t, lines, 4) { - assert.Contains(t, lines[0], "Starts at manual") - assert.Contains(t, lines[1], "Starts next -") - } -} + t.Run("MemberAll", func(t *testing.T) { + t.Parallel() -func TestScheduleStop(t *testing.T) { - t.Parallel() + // When: a member lists all workspaces + inv, root := clitest.New(t, "schedule", "show", "--all") + clitest.SetupConfig(t, memberClient, root) + pty := ptytest.New(t).Attach(inv) + ctx := testutil.Context(t, testutil.WaitShort) + errC := make(chan error) + go func() { + errC <- inv.WithContext(ctx).Run() + }() + require.NoError(t, <-errC) + + // Then: they should only see their own + // 1st workspace: c-member-ws3 has only autostop enabled. + pty.ExpectMatch(ws[2].OwnerName + "/" + ws[2].Name) + pty.ExpectMatch("8h") + pty.ExpectMatch(ws[2].LatestBuild.Deadline.Time.Format(time.RFC3339)) + // 2nd workspace: d-member-ws4 has neither autostart nor autostop enabled. + pty.ExpectMatch(ws[3].OwnerName + "/" + ws[3].Name) + }) - var ( - client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user = coderdtest.CreateFirstUser(t, client) - version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) - project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - ttl = 8*time.Hour + 30*time.Minute - workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID) - _ = coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) - stdoutBuf = &bytes.Buffer{} - ) + t.Run("JSON", func(t *testing.T) { + t.Parallel() - // Set the workspace TTL - inv, root := clitest.New(t, "schedule", "stop", workspace.Name, ttl.String()) - clitest.SetupConfig(t, client, root) - inv.Stdout = stdoutBuf - - err := inv.Run() - assert.NoError(t, err, "unexpected error") - lines := strings.Split(strings.TrimSpace(stdoutBuf.String()), "\n") - if assert.Len(t, lines, 4) { - assert.Contains(t, lines[2], "Stops at 8h30m after start") - // Should not be manual - assert.NotContains(t, lines[3], "Stops next -") - } - - // Reset stdout - stdoutBuf = &bytes.Buffer{} - - // Unset the workspace TTL - inv, root = clitest.New(t, "schedule", "stop", workspace.Name, "manual") - clitest.SetupConfig(t, client, root) - inv.Stdout = stdoutBuf - - err = inv.Run() - assert.NoError(t, err, "unexpected error") - lines = strings.Split(strings.TrimSpace(stdoutBuf.String()), "\n") - if assert.Len(t, lines, 4) { - assert.Contains(t, lines[2], "Stops at manual") - // Deadline of a running workspace is not updated. - assert.NotContains(t, lines[3], "Stops next -") - } + // When: owner lists all workspaces in JSON format + inv, root := clitest.New(t, "schedule", "show", "--all", "--output", "json") + var buf bytes.Buffer + inv.Stdout = &buf + clitest.SetupConfig(t, ownerClient, root) + ctx := testutil.Context(t, testutil.WaitShort) + errC := make(chan error) + go func() { + errC <- inv.WithContext(ctx).Run() + }() + assert.NoError(t, <-errC) + + // Then: they should see all workspace schedules in JSON format + var parsed []map[string]string + require.NoError(t, json.Unmarshal(buf.Bytes(), &parsed)) + require.Len(t, parsed, 4) + // Ensure same order as in CLI output + sort.Slice(parsed, func(i, j int) bool { + a := parsed[i]["workspace"] + b := parsed[j]["workspace"] + return a < b + }) + // 1st workspace: a-owner-ws1 has both autostart and autostop enabled. + assert.Equal(t, ws[0].OwnerName+"/"+ws[0].Name, parsed[0]["workspace"]) + assert.Equal(t, sched.Humanize(), parsed[0]["starts_at"]) + assert.Equal(t, sched.Next(now).Format(time.RFC3339), parsed[0]["starts_next"]) + assert.Equal(t, "8h", parsed[0]["stops_after"]) + assert.Equal(t, ws[0].LatestBuild.Deadline.Time.Format(time.RFC3339), parsed[0]["stops_next"]) + // 2nd workspace: b-owner-ws2 has only autostart enabled. + assert.Equal(t, ws[1].OwnerName+"/"+ws[1].Name, parsed[1]["workspace"]) + assert.Equal(t, sched.Humanize(), parsed[1]["starts_at"]) + assert.Equal(t, sched.Next(now).Format(time.RFC3339), parsed[1]["starts_next"]) + assert.Empty(t, parsed[1]["stops_after"]) + assert.Empty(t, parsed[1]["stops_next"]) + // 3rd workspace: c-member-ws3 has only autostop enabled. + assert.Equal(t, ws[2].OwnerName+"/"+ws[2].Name, parsed[2]["workspace"]) + assert.Empty(t, parsed[2]["starts_at"]) + assert.Empty(t, parsed[2]["starts_next"]) + assert.Equal(t, "8h", parsed[2]["stops_after"]) + assert.Equal(t, ws[2].LatestBuild.Deadline.Time.Format(time.RFC3339), parsed[2]["stops_next"]) + // 4th workspace: d-member-ws4 has neither autostart nor autostop enabled. + assert.Equal(t, ws[3].OwnerName+"/"+ws[3].Name, parsed[3]["workspace"]) + assert.Empty(t, parsed[3]["starts_at"]) + assert.Empty(t, parsed[3]["starts_next"]) + assert.Empty(t, parsed[3]["stops_after"]) + }) } -func TestScheduleOverride(t *testing.T) { +func TestScheduleModify(t *testing.T) { t.Parallel() - t.Run("OK", func(t *testing.T) { + // Given + sched, err := cron.Weekly("CRON_TZ=Europe/Dublin 30 7 * * Mon-Fri") + require.NoError(t, err, "invalid schedule") + ownerClient, _, _, ws := setupTestSchedule(t, sched) + now := time.Now() + + t.Run("SetStart", func(t *testing.T) { t.Parallel() - // Given: we have a workspace - var ( - err error - ctx = context.Background() - client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user = coderdtest.CreateFirstUser(t, client) - version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) - project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID) - cmdArgs = []string{"schedule", "override-stop", workspace.Name, "10h"} - stdoutBuf = &bytes.Buffer{} + // When: we set the start schedule + inv, root := clitest.New(t, + "schedule", "start", ws[3].OwnerName+"/"+ws[3].Name, "7:30AM", "Mon-Fri", "Europe/Dublin", ) - - // Given: we wait for the workspace to be built - coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) - workspace, err = client.Workspace(ctx, workspace.ID) - require.NoError(t, err) - expectedDeadline := time.Now().Add(10 * time.Hour) - - // Assert test invariant: workspace build has a deadline set equal to now plus ttl - initDeadline := time.Now().Add(time.Duration(*workspace.TTLMillis) * time.Millisecond) - require.WithinDuration(t, initDeadline, workspace.LatestBuild.Deadline.Time, time.Minute) - - inv, root := clitest.New(t, cmdArgs...) - clitest.SetupConfig(t, client, root) - inv.Stdout = stdoutBuf - - // When: we execute `coder schedule override workspace ` - err = inv.WithContext(ctx).Run() - require.NoError(t, err) - - // Then: the deadline of the latest build is updated assuming the units are minutes - updated, err := client.Workspace(ctx, workspace.ID) - require.NoError(t, err) - require.WithinDuration(t, expectedDeadline, updated.LatestBuild.Deadline.Time, time.Minute) + //nolint:gocritic // this workspace is not owned by the same user + clitest.SetupConfig(t, ownerClient, root) + pty := ptytest.New(t).Attach(inv) + require.NoError(t, inv.Run()) + + // Then: the updated schedule should be shown + pty.ExpectMatch(ws[3].OwnerName + "/" + ws[3].Name) + pty.ExpectMatch(sched.Humanize()) + pty.ExpectMatch(sched.Next(now).Format(time.RFC3339)) }) - t.Run("InvalidDuration", func(t *testing.T) { + t.Run("SetStop", func(t *testing.T) { t.Parallel() - // Given: we have a workspace - var ( - err error - ctx = context.Background() - client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user = coderdtest.CreateFirstUser(t, client) - version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) - project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID) - cmdArgs = []string{"schedule", "override-stop", workspace.Name, "kwyjibo"} - stdoutBuf = &bytes.Buffer{} + // When: we set the stop schedule + inv, root := clitest.New(t, + "schedule", "stop", ws[2].OwnerName+"/"+ws[2].Name, "8h30m", ) + //nolint:gocritic // this workspace is not owned by the same user + clitest.SetupConfig(t, ownerClient, root) + pty := ptytest.New(t).Attach(inv) + require.NoError(t, inv.Run()) + + // Then: the updated schedule should be shown + pty.ExpectMatch(ws[2].OwnerName + "/" + ws[2].Name) + pty.ExpectMatch("8h30m") + pty.ExpectMatch(ws[2].LatestBuild.Deadline.Time.Format(time.RFC3339)) + }) - // Given: we wait for the workspace to be built - coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) - workspace, err = client.Workspace(ctx, workspace.ID) - require.NoError(t, err) - - // Assert test invariant: workspace build has a deadline set equal to now plus ttl - initDeadline := time.Now().Add(time.Duration(*workspace.TTLMillis) * time.Millisecond) - require.WithinDuration(t, initDeadline, workspace.LatestBuild.Deadline.Time, time.Minute) + t.Run("UnsetStart", func(t *testing.T) { + t.Parallel() - inv, root := clitest.New(t, cmdArgs...) - clitest.SetupConfig(t, client, root) - inv.Stdout = stdoutBuf + // When: we unset the start schedule + inv, root := clitest.New(t, + "schedule", "start", ws[1].OwnerName+"/"+ws[1].Name, "manual", + ) + //nolint:gocritic // this workspace is owned by owner + clitest.SetupConfig(t, ownerClient, root) + pty := ptytest.New(t).Attach(inv) + require.NoError(t, inv.Run()) - // When: we execute `coder bump workspace ` - err = inv.WithContext(ctx).Run() - // Then: the command fails - require.ErrorContains(t, err, "invalid duration") + // Then: the updated schedule should be shown + pty.ExpectMatch(ws[1].OwnerName + "/" + ws[1].Name) }) - t.Run("NoDeadline", func(t *testing.T) { + t.Run("UnsetStop", func(t *testing.T) { t.Parallel() - // Given: we have a workspace with no deadline set - var ( - err error - ctx = context.Background() - client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user = coderdtest.CreateFirstUser(t, client) - version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) - template = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) { - cwr.TTLMillis = nil - }) - cmdArgs = []string{"schedule", "override-stop", workspace.Name, "1h"} - stdoutBuf = &bytes.Buffer{} + // When: we unset the stop schedule + inv, root := clitest.New(t, + "schedule", "stop", ws[0].OwnerName+"/"+ws[0].Name, "manual", ) - require.Zero(t, template.DefaultTTLMillis) - require.Empty(t, template.AutostopRequirement.DaysOfWeek) - require.EqualValues(t, 1, template.AutostopRequirement.Weeks) - - // Unset the workspace TTL - err = client.UpdateWorkspaceTTL(ctx, workspace.ID, codersdk.UpdateWorkspaceTTLRequest{TTLMillis: nil}) - require.NoError(t, err) - workspace, err = client.Workspace(ctx, workspace.ID) - require.NoError(t, err) - require.Nil(t, workspace.TTLMillis) - - // Given: we wait for the workspace to build - coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) - workspace, err = client.Workspace(ctx, workspace.ID) - require.NoError(t, err) - - // NOTE(cian): need to stop and start the workspace as we do not update the deadline - // see: https://github.com/coder/coder/issues/2224 - coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) - coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStop, database.WorkspaceTransitionStart) - - // Assert test invariant: workspace has no TTL set - require.Zero(t, workspace.LatestBuild.Deadline) - require.NoError(t, err) - - inv, root := clitest.New(t, cmdArgs...) - clitest.SetupConfig(t, client, root) - inv.Stdout = stdoutBuf - - // When: we execute `coder bump workspace`` - err = inv.WithContext(ctx).Run() - require.Error(t, err) - - // Then: nothing happens and the deadline remains unset - updated, err := client.Workspace(ctx, workspace.ID) - require.NoError(t, err) - require.Zero(t, updated.LatestBuild.Deadline) + //nolint:gocritic // this workspace is owned by owner + clitest.SetupConfig(t, ownerClient, root) + pty := ptytest.New(t).Attach(inv) + require.NoError(t, inv.Run()) + + // Then: the updated schedule should be shown + pty.ExpectMatch(ws[0].OwnerName + "/" + ws[0].Name) }) } -//nolint:paralleltest // t.Setenv -func TestScheduleStartDefaults(t *testing.T) { - t.Setenv("TZ", "Pacific/Tongatapu") - var ( - client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user = coderdtest.CreateFirstUser(t, client) - version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) - project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID, func(cwr *codersdk.CreateWorkspaceRequest) { - cwr.AutostartSchedule = nil - }) - stdoutBuf = &bytes.Buffer{} +func TestScheduleOverride(t *testing.T) { + t.Parallel() + + // Given + sched, err := cron.Weekly("CRON_TZ=Europe/Dublin 30 7 * * Mon-Fri") + require.NoError(t, err, "invalid schedule") + ownerClient, _, _, ws := setupTestSchedule(t, sched) + now := time.Now() + // To avoid the likelihood of time-related flakes, only matching up to the hour. + expectedDeadline := time.Now().Add(10 * time.Hour).Format("2006-01-02T15:") + + // When: we override the stop schedule + inv, root := clitest.New(t, + "schedule", "override-stop", ws[0].OwnerName+"/"+ws[0].Name, "10h", ) - // Set an underspecified schedule - inv, root := clitest.New(t, "schedule", "start", workspace.Name, "9:30AM") - clitest.SetupConfig(t, client, root) - inv.Stdout = stdoutBuf - err := inv.Run() - require.NoError(t, err, "unexpected error") - lines := strings.Split(strings.TrimSpace(stdoutBuf.String()), "\n") - if assert.Len(t, lines, 4) { - assert.Contains(t, lines[0], "Starts at 9:30AM daily (Pacific/Tongatapu)") - assert.Contains(t, lines[1], "Starts next 9:30AM +13 on") - assert.Contains(t, lines[2], "Stops at 8h after start") - } + clitest.SetupConfig(t, ownerClient, root) + pty := ptytest.New(t).Attach(inv) + require.NoError(t, inv.Run()) + + // Then: the updated schedule should be shown + pty.ExpectMatch(ws[0].OwnerName + "/" + ws[0].Name) + pty.ExpectMatch(sched.Humanize()) + pty.ExpectMatch(sched.Next(now).Format(time.RFC3339)) + pty.ExpectMatch("8h") + pty.ExpectMatch(expectedDeadline) } diff --git a/cli/testdata/coder_list_--help.golden b/cli/testdata/coder_list_--help.golden index 15ff7a5878d51..a2610d8f8813b 100644 --- a/cli/testdata/coder_list_--help.golden +++ b/cli/testdata/coder_list_--help.golden @@ -13,8 +13,8 @@ OPTIONS: -c, --column string-array (default: workspace,template,status,healthy,last built,outdated,starts at,stops after) Columns to display in table output. Available columns: workspace, - template, status, healthy, last built, outdated, starts at, stops - after, daily cost. + template, status, healthy, last built, outdated, starts at, starts + next, stops after, stops next, daily cost. -o, --output string (default: table) Output format. Available formats: table, json. diff --git a/cli/testdata/coder_schedule_--help.golden b/cli/testdata/coder_schedule_--help.golden index 97bae2719a603..7c6e06a31b656 100644 --- a/cli/testdata/coder_schedule_--help.golden +++ b/cli/testdata/coder_schedule_--help.golden @@ -8,7 +8,7 @@ USAGE: SUBCOMMANDS: override-stop Override the stop time of a currently running workspace instance. - show Show workspace schedule + show Show workspace schedules start Edit workspace start schedule stop Edit workspace stop schedule diff --git a/cli/testdata/coder_schedule_show_--help.golden b/cli/testdata/coder_schedule_show_--help.golden index f9b5d47e6a381..61fd5cc4d91fb 100644 --- a/cli/testdata/coder_schedule_show_--help.golden +++ b/cli/testdata/coder_schedule_show_--help.golden @@ -1,15 +1,29 @@ coder v0.0.0-devel USAGE: - coder schedule show + coder schedule show [flags] [] | [--search ] [--all] - Show workspace schedule + Show workspace schedules - Shows the following information for the given workspace: + Shows the following information for the given workspace(s): * The automatic start schedule * The next scheduled start time * The duration after which it will stop * The next scheduled stop time +OPTIONS: + -a, --all bool + Specifies whether all workspaces will be listed or not. + + -c, --column string-array (default: workspace,starts at,starts next,stops after,stops next) + Columns to display in table output. Available columns: workspace, + starts at, starts next, stops after, stops next. + + -o, --output string (default: table) + Output format. Available formats: table, json. + + --search string (default: owner:me) + Search for a workspace with a query. + ——— Run `coder --help` for a list of global options. diff --git a/cli/util.go b/cli/util.go index 0b86c10a2cb0d..90b745e003b10 100644 --- a/cli/util.go +++ b/cli/util.go @@ -62,6 +62,17 @@ func durationDisplay(d time.Duration) string { return sign + durationDisplay } +// timeDisplay formats a time in the local timezone +// in RFC3339 format. +func timeDisplay(t time.Time) string { + localTz, err := tz.TimezoneIANA() + if err != nil { + localTz = time.UTC + } + + return t.In(localTz).Format(time.RFC3339) +} + // relative relativizes a duration with the prefix "ago" or "in" func relative(d time.Duration) string { if d > 0 { diff --git a/coderd/schedule/cron/cron.go b/coderd/schedule/cron/cron.go index 35102d66af34a..df5cb0ac03d90 100644 --- a/coderd/schedule/cron/cron.go +++ b/coderd/schedule/cron/cron.go @@ -115,7 +115,7 @@ type Schedule struct { cronStr string } -// String serializes the schedule to its original human-friendly format. +// String serializes the schedule to its original format. // The leading CRON_TZ is maintained. func (s Schedule) String() string { var sb strings.Builder @@ -126,6 +126,19 @@ func (s Schedule) String() string { return sb.String() } +// Humanize returns a slightly more human-friendly representation of the +// schedule. +func (s Schedule) Humanize() string { + var sb strings.Builder + _, _ = sb.WriteString(s.Time()) + _, _ = sb.WriteString(" ") + _, _ = sb.WriteString(s.DaysOfWeek()) + _, _ = sb.WriteString(" (") + _, _ = sb.WriteString(s.Location().String()) + _, _ = sb.WriteString(")") + return sb.String() +} + // Location returns the IANA location for the schedule. func (s Schedule) Location() *time.Location { return s.sched.Location diff --git a/docs/cli/list.md b/docs/cli/list.md index b840a32acb151..ef8ef2fcaad16 100644 --- a/docs/cli/list.md +++ b/docs/cli/list.md @@ -31,7 +31,7 @@ Specifies whether all workspaces will be listed or not. | Type | string-array | | Default | workspace,template,status,healthy,last built,outdated,starts at,stops after | -Columns to display in table output. Available columns: workspace, template, status, healthy, last built, outdated, starts at, stops after, daily cost. +Columns to display in table output. Available columns: workspace, template, status, healthy, last built, outdated, starts at, starts next, stops after, stops next, daily cost. ### -o, --output diff --git a/docs/cli/schedule.md b/docs/cli/schedule.md index 4e9891f123ac4..6fb5104e126f6 100644 --- a/docs/cli/schedule.md +++ b/docs/cli/schedule.md @@ -15,6 +15,6 @@ coder schedule { show | start | stop | override } | Name | Purpose | | --------------------------------------------------------- | ----------------------------------------------------------------- | | [override-stop](./schedule_override-stop.md) | Override the stop time of a currently running workspace instance. | -| [show](./schedule_show.md) | Show workspace schedule | +| [show](./schedule_show.md) | Show workspace schedules | | [start](./schedule_start.md) | Edit workspace start schedule | | [stop](./schedule_stop.md) | Edit workspace stop schedule | diff --git a/docs/cli/schedule_show.md b/docs/cli/schedule_show.md index 23bb92a356015..5035224c2be27 100644 --- a/docs/cli/schedule_show.md +++ b/docs/cli/schedule_show.md @@ -2,21 +2,58 @@ # schedule show -Show workspace schedule +Show workspace schedules ## Usage ```console -coder schedule show +coder schedule show [flags] [] | [--search ] [--all] ``` ## Description ```console -Shows the following information for the given workspace: +Shows the following information for the given workspace(s): * The automatic start schedule * The next scheduled start time * The duration after which it will stop * The next scheduled stop time ``` + +## Options + +### -a, --all + +| | | +| ---- | ----------------- | +| Type | bool | + +Specifies whether all workspaces will be listed or not. + +### -c, --column + +| | | +| ------- | ------------------------------------------------------------------- | +| Type | string-array | +| Default | workspace,starts at,starts next,stops after,stops next | + +Columns to display in table output. Available columns: workspace, starts at, starts next, stops after, stops next. + +### -o, --output + +| | | +| ------- | ------------------- | +| Type | string | +| Default | table | + +Output format. Available formats: table, json. + +### --search + +| | | +| ------- | --------------------- | +| Type | string | +| Default | owner:me | + +Search for a workspace with a query. diff --git a/docs/manifest.json b/docs/manifest.json index 661b2f6753838..82628cfc77c92 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -734,7 +734,7 @@ }, { "title": "schedule show", - "description": "Show workspace schedule", + "description": "Show workspace schedules", "path": "cli/schedule_show.md" }, { From 9a98fbf1711d8e27ff721c47121e3dfdf5277b4a Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 10 Nov 2023 12:34:32 +0100 Subject: [PATCH 2/7] ensure tz remains the same --- cli/schedule_test.go | 85 ++++++++++++++++++++------------------------ 1 file changed, 39 insertions(+), 46 deletions(-) diff --git a/cli/schedule_test.go b/cli/schedule_test.go index 9a134335a17eb..8b6f4849c2405 100644 --- a/cli/schedule_test.go +++ b/cli/schedule_test.go @@ -17,6 +17,7 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbfake" "github.com/coder/coder/v2/coderd/schedule/cron" + "github.com/coder/coder/v2/coderd/util/tz" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/pty/ptytest" "github.com/coder/coder/v2/testutil" @@ -30,6 +31,8 @@ import ( // It returns the owner and member clients, the database, and the workspaces. // The workspaces are returned in the same order as they are created. func setupTestSchedule(t *testing.T, sched *cron.Schedule) (ownerClient, memberClient *codersdk.Client, db database.Store, ws []codersdk.Workspace) { + t.Helper() + ownerClient, db = coderdtest.NewWithDatabase(t, nil) owner := coderdtest.CreateFirstUser(t, ownerClient) memberClient, memberUser := coderdtest.CreateAnotherUserMutators(t, ownerClient, owner.OrganizationID, nil, func(r *codersdk.CreateUserRequest) { @@ -75,18 +78,20 @@ func setupTestSchedule(t *testing.T, sched *cron.Schedule) (ownerClient, memberC return ownerClient, memberClient, db, ws } +//nolint:paralleltest // t.Setenv func TestScheduleShow(t *testing.T) { - t.Parallel() - // Given + // Set timezone to Asia/Kolkata to surface any timezone-related bugs. + t.Setenv("TZ", "Asia/Kolkata") + loc, err := tz.TimezoneIANA() + require.NoError(t, err) + require.Equal(t, "Asia/Kolkata", loc.String()) sched, err := cron.Weekly("CRON_TZ=Europe/Dublin 30 7 * * Mon-Fri") require.NoError(t, err, "invalid schedule") ownerClient, memberClient, _, ws := setupTestSchedule(t, sched) now := time.Now() t.Run("OwnerNoArgs", func(t *testing.T) { - t.Parallel() - // When: owner specifies no args inv, root := clitest.New(t, "schedule", "show") //nolint:gocritic // Testing that owner user sees all @@ -98,18 +103,16 @@ func TestScheduleShow(t *testing.T) { // 1st workspace: a-owner-ws1 has both autostart and autostop enabled. pty.ExpectMatch(ws[0].OwnerName + "/" + ws[0].Name) pty.ExpectMatch(sched.Humanize()) - pty.ExpectMatch(sched.Next(now).Format(time.RFC3339)) + pty.ExpectMatch(sched.Next(now).In(loc).Format(time.RFC3339)) pty.ExpectMatch("8h") - pty.ExpectMatch(ws[0].LatestBuild.Deadline.Time.Format(time.RFC3339)) + pty.ExpectMatch(ws[0].LatestBuild.Deadline.Time.In(loc).Format(time.RFC3339)) // 2nd workspace: b-owner-ws2 has only autostart enabled. pty.ExpectMatch(ws[1].OwnerName + "/" + ws[1].Name) pty.ExpectMatch(sched.Humanize()) - pty.ExpectMatch(sched.Next(now).Format(time.RFC3339)) + pty.ExpectMatch(sched.Next(now).In(loc).Format(time.RFC3339)) }) t.Run("OwnerAll", func(t *testing.T) { - t.Parallel() - // When: owner lists all workspaces inv, root := clitest.New(t, "schedule", "show", "--all") //nolint:gocritic // Testing that owner user sees all @@ -121,24 +124,22 @@ func TestScheduleShow(t *testing.T) { // 1st workspace: a-owner-ws1 has both autostart and autostop enabled. pty.ExpectMatch(ws[0].OwnerName + "/" + ws[0].Name) pty.ExpectMatch(sched.Humanize()) - pty.ExpectMatch(sched.Next(now).Format(time.RFC3339)) + pty.ExpectMatch(sched.Next(now).In(loc).Format(time.RFC3339)) pty.ExpectMatch("8h") - pty.ExpectMatch(ws[0].LatestBuild.Deadline.Time.Format(time.RFC3339)) + pty.ExpectMatch(ws[0].LatestBuild.Deadline.Time.In(loc).Format(time.RFC3339)) // 2nd workspace: b-owner-ws2 has only autostart enabled. pty.ExpectMatch(ws[1].OwnerName + "/" + ws[1].Name) pty.ExpectMatch(sched.Humanize()) - pty.ExpectMatch(sched.Next(now).Format(time.RFC3339)) + pty.ExpectMatch(sched.Next(now).In(loc).Format(time.RFC3339)) // 3rd workspace: c-member-ws3 has only autostop enabled. pty.ExpectMatch(ws[2].OwnerName + "/" + ws[2].Name) pty.ExpectMatch("8h") - pty.ExpectMatch(ws[2].LatestBuild.Deadline.Time.Format(time.RFC3339)) + pty.ExpectMatch(ws[2].LatestBuild.Deadline.Time.In(loc).Format(time.RFC3339)) // 4th workspace: d-member-ws4 has neither autostart nor autostop enabled. pty.ExpectMatch(ws[3].OwnerName + "/" + ws[3].Name) }) t.Run("OwnerSearchByName", func(t *testing.T) { - t.Parallel() - // When: owner specifies a search query inv, root := clitest.New(t, "schedule", "show", "--search", "name:"+ws[1].Name) //nolint:gocritic // Testing that owner user sees all @@ -150,12 +151,10 @@ func TestScheduleShow(t *testing.T) { // 2nd workspace: b-owner-ws2 has only autostart enabled. pty.ExpectMatch(ws[1].OwnerName + "/" + ws[1].Name) pty.ExpectMatch(sched.Humanize()) - pty.ExpectMatch(sched.Next(now).Format(time.RFC3339)) + pty.ExpectMatch(sched.Next(now).In(loc).Format(time.RFC3339)) }) t.Run("OwnerOneArg", func(t *testing.T) { - t.Parallel() - // When: owner asks for a specific workspace by name inv, root := clitest.New(t, "schedule", "show", ws[2].OwnerName+"/"+ws[2].Name) //nolint:gocritic // Testing that owner user sees all @@ -167,12 +166,10 @@ func TestScheduleShow(t *testing.T) { // 3rd workspace: c-member-ws3 has only autostop enabled. pty.ExpectMatch(ws[2].OwnerName + "/" + ws[2].Name) pty.ExpectMatch("8h") - pty.ExpectMatch(ws[2].LatestBuild.Deadline.Time.Format(time.RFC3339)) + pty.ExpectMatch(ws[2].LatestBuild.Deadline.Time.In(loc).Format(time.RFC3339)) }) t.Run("MemberNoArgs", func(t *testing.T) { - t.Parallel() - // When: a member specifies no args inv, root := clitest.New(t, "schedule", "show") clitest.SetupConfig(t, memberClient, root) @@ -183,14 +180,12 @@ func TestScheduleShow(t *testing.T) { // 1st workspace: c-member-ws3 has only autostop enabled. pty.ExpectMatch(ws[2].OwnerName + "/" + ws[2].Name) pty.ExpectMatch("8h") - pty.ExpectMatch(ws[2].LatestBuild.Deadline.Time.Format(time.RFC3339)) + pty.ExpectMatch(ws[2].LatestBuild.Deadline.Time.In(loc).Format(time.RFC3339)) // 2nd workspace: d-member-ws4 has neither autostart nor autostop enabled. pty.ExpectMatch(ws[3].OwnerName + "/" + ws[3].Name) }) t.Run("MemberAll", func(t *testing.T) { - t.Parallel() - // When: a member lists all workspaces inv, root := clitest.New(t, "schedule", "show", "--all") clitest.SetupConfig(t, memberClient, root) @@ -206,14 +201,12 @@ func TestScheduleShow(t *testing.T) { // 1st workspace: c-member-ws3 has only autostop enabled. pty.ExpectMatch(ws[2].OwnerName + "/" + ws[2].Name) pty.ExpectMatch("8h") - pty.ExpectMatch(ws[2].LatestBuild.Deadline.Time.Format(time.RFC3339)) + pty.ExpectMatch(ws[2].LatestBuild.Deadline.Time.In(loc).Format(time.RFC3339)) // 2nd workspace: d-member-ws4 has neither autostart nor autostop enabled. pty.ExpectMatch(ws[3].OwnerName + "/" + ws[3].Name) }) t.Run("JSON", func(t *testing.T) { - t.Parallel() - // When: owner lists all workspaces in JSON format inv, root := clitest.New(t, "schedule", "show", "--all", "--output", "json") var buf bytes.Buffer @@ -239,13 +232,13 @@ func TestScheduleShow(t *testing.T) { // 1st workspace: a-owner-ws1 has both autostart and autostop enabled. assert.Equal(t, ws[0].OwnerName+"/"+ws[0].Name, parsed[0]["workspace"]) assert.Equal(t, sched.Humanize(), parsed[0]["starts_at"]) - assert.Equal(t, sched.Next(now).Format(time.RFC3339), parsed[0]["starts_next"]) + assert.Equal(t, sched.Next(now).In(loc).Format(time.RFC3339), parsed[0]["starts_next"]) assert.Equal(t, "8h", parsed[0]["stops_after"]) - assert.Equal(t, ws[0].LatestBuild.Deadline.Time.Format(time.RFC3339), parsed[0]["stops_next"]) + assert.Equal(t, ws[0].LatestBuild.Deadline.Time.In(loc).Format(time.RFC3339), parsed[0]["stops_next"]) // 2nd workspace: b-owner-ws2 has only autostart enabled. assert.Equal(t, ws[1].OwnerName+"/"+ws[1].Name, parsed[1]["workspace"]) assert.Equal(t, sched.Humanize(), parsed[1]["starts_at"]) - assert.Equal(t, sched.Next(now).Format(time.RFC3339), parsed[1]["starts_next"]) + assert.Equal(t, sched.Next(now).In(loc).Format(time.RFC3339), parsed[1]["starts_next"]) assert.Empty(t, parsed[1]["stops_after"]) assert.Empty(t, parsed[1]["stops_next"]) // 3rd workspace: c-member-ws3 has only autostop enabled. @@ -253,7 +246,7 @@ func TestScheduleShow(t *testing.T) { assert.Empty(t, parsed[2]["starts_at"]) assert.Empty(t, parsed[2]["starts_next"]) assert.Equal(t, "8h", parsed[2]["stops_after"]) - assert.Equal(t, ws[2].LatestBuild.Deadline.Time.Format(time.RFC3339), parsed[2]["stops_next"]) + assert.Equal(t, ws[2].LatestBuild.Deadline.Time.In(loc).Format(time.RFC3339), parsed[2]["stops_next"]) // 4th workspace: d-member-ws4 has neither autostart nor autostop enabled. assert.Equal(t, ws[3].OwnerName+"/"+ws[3].Name, parsed[3]["workspace"]) assert.Empty(t, parsed[3]["starts_at"]) @@ -262,18 +255,20 @@ func TestScheduleShow(t *testing.T) { }) } +//nolint:paralleltest // t.Setenv func TestScheduleModify(t *testing.T) { - t.Parallel() - // Given + // Set timezone to Asia/Kolkata to surface any timezone-related bugs. + t.Setenv("TZ", "Asia/Kolkata") + loc, err := tz.TimezoneIANA() + require.NoError(t, err) + require.Equal(t, "Asia/Kolkata", loc.String()) sched, err := cron.Weekly("CRON_TZ=Europe/Dublin 30 7 * * Mon-Fri") require.NoError(t, err, "invalid schedule") ownerClient, _, _, ws := setupTestSchedule(t, sched) now := time.Now() t.Run("SetStart", func(t *testing.T) { - t.Parallel() - // When: we set the start schedule inv, root := clitest.New(t, "schedule", "start", ws[3].OwnerName+"/"+ws[3].Name, "7:30AM", "Mon-Fri", "Europe/Dublin", @@ -286,12 +281,10 @@ func TestScheduleModify(t *testing.T) { // Then: the updated schedule should be shown pty.ExpectMatch(ws[3].OwnerName + "/" + ws[3].Name) pty.ExpectMatch(sched.Humanize()) - pty.ExpectMatch(sched.Next(now).Format(time.RFC3339)) + pty.ExpectMatch(sched.Next(now).In(loc).Format(time.RFC3339)) }) t.Run("SetStop", func(t *testing.T) { - t.Parallel() - // When: we set the stop schedule inv, root := clitest.New(t, "schedule", "stop", ws[2].OwnerName+"/"+ws[2].Name, "8h30m", @@ -304,12 +297,10 @@ func TestScheduleModify(t *testing.T) { // Then: the updated schedule should be shown pty.ExpectMatch(ws[2].OwnerName + "/" + ws[2].Name) pty.ExpectMatch("8h30m") - pty.ExpectMatch(ws[2].LatestBuild.Deadline.Time.Format(time.RFC3339)) + pty.ExpectMatch(ws[2].LatestBuild.Deadline.Time.In(loc).Format(time.RFC3339)) }) t.Run("UnsetStart", func(t *testing.T) { - t.Parallel() - // When: we unset the start schedule inv, root := clitest.New(t, "schedule", "start", ws[1].OwnerName+"/"+ws[1].Name, "manual", @@ -324,8 +315,6 @@ func TestScheduleModify(t *testing.T) { }) t.Run("UnsetStop", func(t *testing.T) { - t.Parallel() - // When: we unset the stop schedule inv, root := clitest.New(t, "schedule", "stop", ws[0].OwnerName+"/"+ws[0].Name, "manual", @@ -340,10 +329,14 @@ func TestScheduleModify(t *testing.T) { }) } +//nolint:paralleltest // t.Setenv func TestScheduleOverride(t *testing.T) { - t.Parallel() - // Given + // Set timezone to Asia/Kolkata to surface any timezone-related bugs. + t.Setenv("TZ", "Asia/Kolkata") + loc, err := tz.TimezoneIANA() + require.NoError(t, err) + require.Equal(t, "Asia/Kolkata", loc.String()) sched, err := cron.Weekly("CRON_TZ=Europe/Dublin 30 7 * * Mon-Fri") require.NoError(t, err, "invalid schedule") ownerClient, _, _, ws := setupTestSchedule(t, sched) @@ -363,7 +356,7 @@ func TestScheduleOverride(t *testing.T) { // Then: the updated schedule should be shown pty.ExpectMatch(ws[0].OwnerName + "/" + ws[0].Name) pty.ExpectMatch(sched.Humanize()) - pty.ExpectMatch(sched.Next(now).Format(time.RFC3339)) + pty.ExpectMatch(sched.Next(now).In(loc).Format(time.RFC3339)) pty.ExpectMatch("8h") pty.ExpectMatch(expectedDeadline) } From e59d9cedc7802756eac8d29e0ed9b399a6f2084c Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 10 Nov 2023 11:44:46 +0000 Subject: [PATCH 3/7] fixup! ensure tz remains the same --- cli/schedule_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/schedule_test.go b/cli/schedule_test.go index 8b6f4849c2405..b942ac8d4d07d 100644 --- a/cli/schedule_test.go +++ b/cli/schedule_test.go @@ -342,7 +342,7 @@ func TestScheduleOverride(t *testing.T) { ownerClient, _, _, ws := setupTestSchedule(t, sched) now := time.Now() // To avoid the likelihood of time-related flakes, only matching up to the hour. - expectedDeadline := time.Now().Add(10 * time.Hour).Format("2006-01-02T15:") + expectedDeadline := time.Now().In(loc).Add(10 * time.Hour).Format("2006-01-02T15:") // When: we override the stop schedule inv, root := clitest.New(t, From 5d74fea5047ca2b0ea190bf2d1291c27db5d4e0a Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 10 Nov 2023 12:17:16 +0000 Subject: [PATCH 4/7] return err for completeness Co-authored-by: Mathias Fredriksson --- cli/schedule.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/schedule.go b/cli/schedule.go index d1269e84965ea..53348557c15ac 100644 --- a/cli/schedule.go +++ b/cli/schedule.go @@ -292,8 +292,8 @@ func displaySchedule(ws codersdk.Workspace, out io.Writer) error { if err != nil { return err } - _, _ = fmt.Fprintln(out, rendered) - return nil + _, err = fmt.Fprintln(out, rendered) + return err } // scheduleListRow is a row in the schedule list. From 4ab40cadaeaa52ce8c12b475c42a45e5395a7e09 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 10 Nov 2023 12:17:41 +0000 Subject: [PATCH 5/7] modify usage Co-authored-by: Mathias Fredriksson --- cli/schedule.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/schedule.go b/cli/schedule.go index 53348557c15ac..bc843eecb4767 100644 --- a/cli/schedule.go +++ b/cli/schedule.go @@ -92,7 +92,7 @@ func (r *RootCmd) scheduleShow() *clibase.Cmd { ) client := new(codersdk.Client) showCmd := &clibase.Cmd{ - Use: "show [] | [--search ] [--all]", + Use: "show | --all>", Short: "Show workspace schedules", Long: scheduleShowDescriptionLong, Middleware: clibase.Chain( From 6dd691e02614d47527a43d5ab923514830e7a961 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 10 Nov 2023 13:25:15 +0000 Subject: [PATCH 6/7] fixup! modify usage --- docs/cli/schedule_show.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cli/schedule_show.md b/docs/cli/schedule_show.md index 5035224c2be27..b09ccada131f1 100644 --- a/docs/cli/schedule_show.md +++ b/docs/cli/schedule_show.md @@ -7,7 +7,7 @@ Show workspace schedules ## Usage ```console -coder schedule show [flags] [] | [--search ] [--all] +coder schedule show [flags] | --all> ``` ## Description From 8e118a88507dfd20e62ca636ab4a49f684b33324 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 10 Nov 2023 13:37:48 +0000 Subject: [PATCH 7/7] make update-golden-files --- cli/testdata/coder_schedule_show_--help.golden | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/testdata/coder_schedule_show_--help.golden b/cli/testdata/coder_schedule_show_--help.golden index 61fd5cc4d91fb..34b49fb5993f7 100644 --- a/cli/testdata/coder_schedule_show_--help.golden +++ b/cli/testdata/coder_schedule_show_--help.golden @@ -1,7 +1,7 @@ coder v0.0.0-devel USAGE: - coder schedule show [flags] [] | [--search ] [--all] + coder schedule show [flags] | --all> Show workspace schedules