Skip to content

feat(cli): allow showing schedules for multiple workspaces #10596

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 7 commits into from
Nov 10, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 0 additions & 6 deletions cli/constants.go

This file was deleted.

84 changes: 36 additions & 48 deletions cli/list.go
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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{
Expand Down Expand Up @@ -107,35 +92,20 @@ 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 <name>"))
_, _ = fmt.Fprintln(inv.Stderr)
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
}
Expand All @@ -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
}
130 changes: 87 additions & 43 deletions cli/schedule.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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 <workspace-name>",
Short: "Show workspace schedule",
Use: "show <workspace | --search <query> | --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
}

Expand Down Expand Up @@ -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
}
_, err = fmt.Fprintln(out, rendered)
return err
}

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,
}
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 include the datapoints "calculated" here in the API response instead (obviously not the formatting, but timestamps, etc). I'm thinking it'd be better if our CLI was "dumber", and it might help other API consumers too.

Copy link
Member Author

Choose a reason for hiding this comment

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

What specific datapoints do you want included?

Copy link
Member

Choose a reason for hiding this comment

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

I suppose I'm thinking of something like this:

GET /workspace/uuid
{
  ...,
  "schedule": {
    "starts_next": time,
    "stops_next": time,
	...
  },
  ...
}

Maybe more fields if it makes sense. Basically I'm thinking if we can avoid the need to call cron.Weekly, etc since a client who wants to know this information will need to be able to parse cron tabs (+ any other business logic that may be releavant).

Just an idea.

Copy link
Member Author

Choose a reason for hiding this comment

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

It's not a bad idea! Filed #10621

}
Loading