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

* coder list: adds information about next start / stop to available columns (not default)
* coder schedule: show now essentially coder list with a different set of columns
* Updates cli schedule unit tests to use new dbfake

Co-authored-by: Mathias Fredriksson <mafredri@gmail.com>
This commit is contained in:
Cian Johnston 2023-11-10 13:51:49 +00:00 committed by GitHub
parent 177affbe4b
commit a4f1319108
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 544 additions and 466 deletions

View File

@ -1,6 +0,0 @@
package cli
const (
timeFormat = "3:04PM MST"
dateFormat = "Jan 2, 2006"
)

View File

@ -1,19 +1,17 @@
package cli package cli
import ( import (
"context"
"fmt" "fmt"
"strconv" "strconv"
"time" "time"
"github.com/google/uuid" "golang.org/x/xerrors"
"github.com/coder/pretty"
"github.com/coder/coder/v2/cli/clibase" "github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/cli/cliui" "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/coder/v2/codersdk"
"github.com/coder/pretty"
) )
// workspaceListRow is the type provided to the OutputFormatter. This is a bit // 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"` LastBuilt string `json:"-" table:"last built"`
Outdated bool `json:"-" table:"outdated"` Outdated bool `json:"-" table:"outdated"`
StartsAt string `json:"-" table:"starts at"` StartsAt string `json:"-" table:"starts at"`
StartsNext string `json:"-" table:"starts next"`
StopsAfter string `json:"-" table:"stops after"` StopsAfter string `json:"-" table:"stops after"`
StopsNext string `json:"-" table:"stops next"`
DailyCost string `json:"-" table:"daily cost"` 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) status := codersdk.WorkspaceDisplayStatus(workspace.LatestBuild.Job.Status, workspace.LatestBuild.Transition)
lastBuilt := now.UTC().Sub(workspace.LatestBuild.Job.CreatedAt).Truncate(time.Second) lastBuilt := now.UTC().Sub(workspace.LatestBuild.Job.CreatedAt).Truncate(time.Second)
autostartDisplay := "-" schedRow := scheduleListRowFromWorkspace(now, workspace)
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))
}
}
healthy := "" healthy := ""
if status == "Starting" || status == "Started" { if status == "Starting" || status == "Started" {
healthy = strconv.FormatBool(workspace.Health.Healthy) healthy = strconv.FormatBool(workspace.Health.Healthy)
} }
user := usersByID[workspace.OwnerID]
return workspaceListRow{ return workspaceListRow{
Workspace: workspace, Workspace: workspace,
WorkspaceName: user.Username + "/" + workspace.Name, WorkspaceName: workspace.OwnerName + "/" + workspace.Name,
Template: workspace.TemplateName, Template: workspace.TemplateName,
Status: status, Status: status,
Healthy: healthy, Healthy: healthy,
LastBuilt: durationDisplay(lastBuilt), LastBuilt: durationDisplay(lastBuilt),
Outdated: workspace.Outdated, Outdated: workspace.Outdated,
StartsAt: autostartDisplay, StartsAt: schedRow.StartsAt,
StopsAfter: autostopDisplay, StartsNext: schedRow.StartsNext,
StopsAfter: schedRow.StopsAfter,
StopsNext: schedRow.StopsNext,
DailyCost: strconv.Itoa(int(workspace.LatestBuild.DailyCost)), DailyCost: strconv.Itoa(int(workspace.LatestBuild.DailyCost)),
} }
} }
func (r *RootCmd) list() *clibase.Cmd { func (r *RootCmd) list() *clibase.Cmd {
var ( var (
filter cliui.WorkspaceFilter filter cliui.WorkspaceFilter
displayWorkspaces []workspaceListRow formatter = cliui.NewOutputFormatter(
formatter = cliui.NewOutputFormatter(
cliui.TableFormat( cliui.TableFormat(
[]workspaceListRow{}, []workspaceListRow{},
[]string{ []string{
@ -107,11 +92,12 @@ func (r *RootCmd) list() *clibase.Cmd {
r.InitClient(client), r.InitClient(client),
), ),
Handler: func(inv *clibase.Invocation) error { 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 { if err != nil {
return err return err
} }
if len(res.Workspaces) == 0 {
if len(res) == 0 {
pretty.Fprintf(inv.Stderr, cliui.DefaultStyles.Prompt, "No workspaces found! Create one:\n") pretty.Fprintf(inv.Stderr, cliui.DefaultStyles.Prompt, "No workspaces found! Create one:\n")
_, _ = fmt.Fprintln(inv.Stderr) _, _ = fmt.Fprintln(inv.Stderr)
_, _ = fmt.Fprintln(inv.Stderr, " "+pretty.Sprint(cliui.DefaultStyles.Code, "coder create <name>")) _, _ = fmt.Fprintln(inv.Stderr, " "+pretty.Sprint(cliui.DefaultStyles.Code, "coder create <name>"))
@ -119,23 +105,7 @@ func (r *RootCmd) list() *clibase.Cmd {
return nil return nil
} }
userRes, err := client.Users(inv.Context(), codersdk.UsersRequest{}) out, err := formatter.Format(inv.Context(), res)
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)
if err != nil { if err != nil {
return err return err
} }
@ -148,3 +118,21 @@ func (r *RootCmd) list() *clibase.Cmd {
formatter.AttachOptions(&cmd.Options) formatter.AttachOptions(&cmd.Options)
return cmd 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
}

View File

@ -3,9 +3,9 @@ package cli
import ( import (
"fmt" "fmt"
"io" "io"
"strings"
"time" "time"
"github.com/jedib0t/go-pretty/v6/table"
"golang.org/x/xerrors" "golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/clibase" "github.com/coder/coder/v2/cli/clibase"
@ -17,7 +17,7 @@ import (
) )
const ( 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 automatic start schedule
* The next scheduled start time * The next scheduled start time
* The duration after which it will stop * The duration after which it will stop
@ -72,25 +72,67 @@ func (r *RootCmd) schedules() *clibase.Cmd {
return scheduleCmd return scheduleCmd
} }
// scheduleShow() is just a wrapper for list() with some different defaults.
func (r *RootCmd) scheduleShow() *clibase.Cmd { 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) client := new(codersdk.Client)
showCmd := &clibase.Cmd{ showCmd := &clibase.Cmd{
Use: "show <workspace-name>", Use: "show <workspace | --search <query> | --all>",
Short: "Show workspace schedule", Short: "Show workspace schedules",
Long: scheduleShowDescriptionLong, Long: scheduleShowDescriptionLong,
Middleware: clibase.Chain( Middleware: clibase.Chain(
clibase.RequireNArgs(1), clibase.RequireRangeArgs(0, 1),
r.InitClient(client), r.InitClient(client),
), ),
Handler: func(inv *clibase.Invocation) error { 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 { if err != nil {
return err 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 return showCmd
} }
@ -242,50 +284,52 @@ func (r *RootCmd) scheduleOverride() *clibase.Cmd {
return overrideCmd return overrideCmd
} }
func displaySchedule(workspace codersdk.Workspace, out io.Writer) error { func displaySchedule(ws codersdk.Workspace, out io.Writer) error {
loc, err := tz.TimezoneIANA() 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 { if err != nil {
loc = time.UTC // best effort return err
}
_, err = fmt.Fprintln(out, rendered)
return err
}
// 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) {
if sched, err := cron.Weekly(*workspace.AutostartSchedule); err == nil {
autostartDisplay = sched.Humanize()
nextStartDisplay = timeDisplay(sched.Next(now))
}
}
autostopDisplay := ""
nextStopDisplay := ""
if !ptr.NilOrZero(workspace.TTLMillis) {
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)
}
}
return scheduleListRow{
WorkspaceName: workspace.OwnerName + "/" + workspace.Name,
StartsAt: autostartDisplay,
StartsNext: nextStartDisplay,
StopsAfter: autostopDisplay,
StopsNext: nextStopDisplay,
} }
var (
schedStart = "manual"
schedStop = "manual"
schedNextStart = "-"
schedNextStop = "-"
)
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
}
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)
}
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)))
}
}
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
} }

View File

@ -3,8 +3,9 @@ package cli_test
import ( import (
"bytes" "bytes"
"context" "context"
"fmt" "database/sql"
"strings" "encoding/json"
"sort"
"testing" "testing"
"time" "time"
@ -14,372 +15,348 @@ import (
"github.com/coder/coder/v2/cli/clitest" "github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database" "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/coderd/util/tz"
"github.com/coder/coder/v2/codersdk" "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) {
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) {
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
}
//nolint:paralleltest // t.Setenv
func TestScheduleShow(t *testing.T) { func TestScheduleShow(t *testing.T) {
t.Parallel() // Given
t.Run("Enabled", func(t *testing.T) { // Set timezone to Asia/Kolkata to surface any timezone-related bugs.
t.Parallel() 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()
var ( t.Run("OwnerNoArgs", func(t *testing.T) {
tz = "Europe/Dublin" // When: owner specifies no args
sched = "30 7 * * 1-5" inv, root := clitest.New(t, "schedule", "show")
schedCron = fmt.Sprintf("CRON_TZ=%s %s", tz, sched) //nolint:gocritic // Testing that owner user sees all
ttl = 8 * time.Hour clitest.SetupConfig(t, ownerClient, root)
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) pty := ptytest.New(t).Attach(inv)
user = coderdtest.CreateFirstUser(t, client) require.NoError(t, inv.Run())
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{}
)
inv, root := clitest.New(t, cmdArgs...) // Then: they should see their own workspaces.
clitest.SetupConfig(t, client, root) // 1st workspace: a-owner-ws1 has both autostart and autostop enabled.
inv.Stdout = stdoutBuf pty.ExpectMatch(ws[0].OwnerName + "/" + ws[0].Name)
pty.ExpectMatch(sched.Humanize())
err := inv.Run() pty.ExpectMatch(sched.Next(now).In(loc).Format(time.RFC3339))
require.NoError(t, err, "unexpected error") pty.ExpectMatch("8h")
lines := strings.Split(strings.TrimSpace(stdoutBuf.String()), "\n") pty.ExpectMatch(ws[0].LatestBuild.Deadline.Time.In(loc).Format(time.RFC3339))
if assert.Len(t, lines, 4) { // 2nd workspace: b-owner-ws2 has only autostart enabled.
assert.Contains(t, lines[0], "Starts at 7:30AM Mon-Fri (Europe/Dublin)") pty.ExpectMatch(ws[1].OwnerName + "/" + ws[1].Name)
assert.Contains(t, lines[1], "Starts next 7:30AM") pty.ExpectMatch(sched.Humanize())
// it should have either IST or GMT pty.ExpectMatch(sched.Next(now).In(loc).Format(time.RFC3339))
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("Manual", func(t *testing.T) { 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
clitest.SetupConfig(t, ownerClient, root)
pty := ptytest.New(t).Attach(inv)
require.NoError(t, inv.Run())
var ( // Then: they should see all workspaces
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) // 1st workspace: a-owner-ws1 has both autostart and autostop enabled.
user = coderdtest.CreateFirstUser(t, client) pty.ExpectMatch(ws[0].OwnerName + "/" + ws[0].Name)
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) pty.ExpectMatch(sched.Humanize())
_ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) pty.ExpectMatch(sched.Next(now).In(loc).Format(time.RFC3339))
project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) pty.ExpectMatch("8h")
workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID, func(cwr *codersdk.CreateWorkspaceRequest) { pty.ExpectMatch(ws[0].LatestBuild.Deadline.Time.In(loc).Format(time.RFC3339))
cwr.AutostartSchedule = nil // 2nd workspace: b-owner-ws2 has only autostart enabled.
cwr.TTLMillis = nil pty.ExpectMatch(ws[1].OwnerName + "/" + ws[1].Name)
}) pty.ExpectMatch(sched.Humanize())
_ = coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) pty.ExpectMatch(sched.Next(now).In(loc).Format(time.RFC3339))
cmdArgs = []string{"schedule", "show", workspace.Name} // 3rd workspace: c-member-ws3 has only autostop enabled.
stdoutBuf = &bytes.Buffer{} pty.ExpectMatch(ws[2].OwnerName + "/" + ws[2].Name)
) pty.ExpectMatch("8h")
pty.ExpectMatch(ws[2].LatestBuild.Deadline.Time.In(loc).Format(time.RFC3339))
inv, root := clitest.New(t, cmdArgs...) // 4th workspace: d-member-ws4 has neither autostart nor autostop enabled.
clitest.SetupConfig(t, client, root) pty.ExpectMatch(ws[3].OwnerName + "/" + ws[3].Name)
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 -")
}
}) })
t.Run("NotFound", func(t *testing.T) { 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
clitest.SetupConfig(t, ownerClient, root)
pty := ptytest.New(t).Attach(inv)
require.NoError(t, inv.Run())
var ( // Then: they should see workspaces matching that query
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) // 2nd workspace: b-owner-ws2 has only autostart enabled.
user = coderdtest.CreateFirstUser(t, client) pty.ExpectMatch(ws[1].OwnerName + "/" + ws[1].Name)
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) pty.ExpectMatch(sched.Humanize())
_ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) pty.ExpectMatch(sched.Next(now).In(loc).Format(time.RFC3339))
)
inv, root := clitest.New(t, "schedule", "show", "doesnotexist")
clitest.SetupConfig(t, client, root)
err := inv.Run()
require.ErrorContains(t, err, "status code 404", "unexpected error")
}) })
}
func TestScheduleStart(t *testing.T) { 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
clitest.SetupConfig(t, ownerClient, root)
pty := ptytest.New(t).Attach(inv)
require.NoError(t, inv.Run())
var ( // Then: they should see that workspace
ctx = context.Background() // 3rd workspace: c-member-ws3 has only autostop enabled.
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) pty.ExpectMatch(ws[2].OwnerName + "/" + ws[2].Name)
user = coderdtest.CreateFirstUser(t, client) pty.ExpectMatch("8h")
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) pty.ExpectMatch(ws[2].LatestBuild.Deadline.Time.In(loc).Format(time.RFC3339))
_ = 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) { t.Run("MemberNoArgs", func(t *testing.T) {
cwr.AutostartSchedule = nil // 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.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) {
// 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.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) {
// 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
}) })
_ = coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) // 1st workspace: a-owner-ws1 has both autostart and autostop enabled.
tz = "Europe/Dublin" assert.Equal(t, ws[0].OwnerName+"/"+ws[0].Name, parsed[0]["workspace"])
sched = "CRON_TZ=Europe/Dublin 30 9 * * Mon-Fri" assert.Equal(t, sched.Humanize(), parsed[0]["starts_at"])
stdoutBuf = &bytes.Buffer{} 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.In(loc).Format(time.RFC3339), parsed[0]["stops_next"])
// Set a well-specified autostart schedule // 2nd workspace: b-owner-ws2 has only autostart enabled.
inv, root := clitest.New(t, "schedule", "start", workspace.Name, "9:30AM", "Mon-Fri", tz) assert.Equal(t, ws[1].OwnerName+"/"+ws[1].Name, parsed[1]["workspace"])
clitest.SetupConfig(t, client, root) assert.Equal(t, sched.Humanize(), parsed[1]["starts_at"])
inv.Stdout = stdoutBuf assert.Equal(t, sched.Next(now).In(loc).Format(time.RFC3339), parsed[1]["starts_next"])
assert.Empty(t, parsed[1]["stops_after"])
err := inv.Run() assert.Empty(t, parsed[1]["stops_next"])
assert.NoError(t, err, "unexpected error") // 3rd workspace: c-member-ws3 has only autostop enabled.
lines := strings.Split(strings.TrimSpace(stdoutBuf.String()), "\n") assert.Equal(t, ws[2].OwnerName+"/"+ws[2].Name, parsed[2]["workspace"])
if assert.Len(t, lines, 4) { assert.Empty(t, parsed[2]["starts_at"])
assert.Contains(t, lines[0], "Starts at 9:30AM Mon-Fri (Europe/Dublin)") assert.Empty(t, parsed[2]["starts_next"])
assert.Contains(t, lines[1], "Starts next 9:30AM") assert.Equal(t, "8h", parsed[2]["stops_after"])
// it should have either IST or GMT assert.Equal(t, ws[2].LatestBuild.Deadline.Time.In(loc).Format(time.RFC3339), parsed[2]["stops_next"])
if !strings.Contains(lines[1], "IST") && !strings.Contains(lines[1], "GMT") { // 4th workspace: d-member-ws4 has neither autostart nor autostop enabled.
t.Error("expected either IST or GMT") 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"])
// 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 -")
}
}
func TestScheduleStop(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)
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{}
)
// 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 -")
}
}
func TestScheduleOverride(t *testing.T) {
t.Parallel()
t.Run("OK", 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{}
)
// 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 <number without units>`
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)
})
t.Run("InvalidDuration", 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{}
)
// 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)
inv, root := clitest.New(t, cmdArgs...)
clitest.SetupConfig(t, client, root)
inv.Stdout = stdoutBuf
// When: we execute `coder bump workspace <not a number>`
err = inv.WithContext(ctx).Run()
// Then: the command fails
require.ErrorContains(t, err, "invalid duration")
})
t.Run("NoDeadline", 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{}
)
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:paralleltest // t.Setenv //nolint:paralleltest // t.Setenv
func TestScheduleStartDefaults(t *testing.T) { func TestScheduleModify(t *testing.T) {
t.Setenv("TZ", "Pacific/Tongatapu") // Given
var ( // Set timezone to Asia/Kolkata to surface any timezone-related bugs.
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) t.Setenv("TZ", "Asia/Kolkata")
user = coderdtest.CreateFirstUser(t, client) loc, err := tz.TimezoneIANA()
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) require.NoError(t, err)
_ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) require.Equal(t, "Asia/Kolkata", loc.String())
project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) sched, err := cron.Weekly("CRON_TZ=Europe/Dublin 30 7 * * Mon-Fri")
workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID, func(cwr *codersdk.CreateWorkspaceRequest) { require.NoError(t, err, "invalid schedule")
cwr.AutostartSchedule = nil ownerClient, _, _, ws := setupTestSchedule(t, sched)
}) now := time.Now()
stdoutBuf = &bytes.Buffer{}
t.Run("SetStart", func(t *testing.T) {
// 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",
)
//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).In(loc).Format(time.RFC3339))
})
t.Run("SetStop", func(t *testing.T) {
// 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.In(loc).Format(time.RFC3339))
})
t.Run("UnsetStart", func(t *testing.T) {
// 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())
// Then: the updated schedule should be shown
pty.ExpectMatch(ws[1].OwnerName + "/" + ws[1].Name)
})
t.Run("UnsetStop", func(t *testing.T) {
// When: we unset the stop schedule
inv, root := clitest.New(t,
"schedule", "stop", ws[0].OwnerName+"/"+ws[0].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())
// Then: the updated schedule should be shown
pty.ExpectMatch(ws[0].OwnerName + "/" + ws[0].Name)
})
}
//nolint:paralleltest // t.Setenv
func TestScheduleOverride(t *testing.T) {
// 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()
// To avoid the likelihood of time-related flakes, only matching up to the hour.
expectedDeadline := time.Now().In(loc).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 clitest.SetupConfig(t, ownerClient, root)
inv, root := clitest.New(t, "schedule", "start", workspace.Name, "9:30AM") pty := ptytest.New(t).Attach(inv)
clitest.SetupConfig(t, client, root) require.NoError(t, inv.Run())
inv.Stdout = stdoutBuf
err := inv.Run() // Then: the updated schedule should be shown
require.NoError(t, err, "unexpected error") pty.ExpectMatch(ws[0].OwnerName + "/" + ws[0].Name)
lines := strings.Split(strings.TrimSpace(stdoutBuf.String()), "\n") pty.ExpectMatch(sched.Humanize())
if assert.Len(t, lines, 4) { pty.ExpectMatch(sched.Next(now).In(loc).Format(time.RFC3339))
assert.Contains(t, lines[0], "Starts at 9:30AM daily (Pacific/Tongatapu)") pty.ExpectMatch("8h")
assert.Contains(t, lines[1], "Starts next 9:30AM +13 on") pty.ExpectMatch(expectedDeadline)
assert.Contains(t, lines[2], "Stops at 8h after start")
}
} }

View File

@ -13,8 +13,8 @@ OPTIONS:
-c, --column string-array (default: workspace,template,status,healthy,last built,outdated,starts at,stops after) -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, Columns to display in table output. Available columns: workspace,
template, status, healthy, last built, outdated, starts at, stops template, status, healthy, last built, outdated, starts at, starts
after, daily cost. next, stops after, stops next, daily cost.
-o, --output string (default: table) -o, --output string (default: table)
Output format. Available formats: table, json. Output format. Available formats: table, json.

View File

@ -8,7 +8,7 @@ USAGE:
SUBCOMMANDS: SUBCOMMANDS:
override-stop Override the stop time of a currently running workspace override-stop Override the stop time of a currently running workspace
instance. instance.
show Show workspace schedule show Show workspace schedules
start Edit workspace start schedule start Edit workspace start schedule
stop Edit workspace stop schedule stop Edit workspace stop schedule

View File

@ -1,15 +1,29 @@
coder v0.0.0-devel coder v0.0.0-devel
USAGE: USAGE:
coder schedule show <workspace-name> coder schedule show [flags] <workspace | --search <query> | --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 automatic start schedule
* The next scheduled start time * The next scheduled start time
* The duration after which it will stop * The duration after which it will stop
* The next scheduled stop time * 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. Run `coder --help` for a list of global options.

View File

@ -62,6 +62,17 @@ func durationDisplay(d time.Duration) string {
return sign + durationDisplay 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" // relative relativizes a duration with the prefix "ago" or "in"
func relative(d time.Duration) string { func relative(d time.Duration) string {
if d > 0 { if d > 0 {

View File

@ -115,7 +115,7 @@ type Schedule struct {
cronStr string 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. // The leading CRON_TZ is maintained.
func (s Schedule) String() string { func (s Schedule) String() string {
var sb strings.Builder var sb strings.Builder
@ -126,6 +126,19 @@ func (s Schedule) String() string {
return sb.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. // Location returns the IANA location for the schedule.
func (s Schedule) Location() *time.Location { func (s Schedule) Location() *time.Location {
return s.sched.Location return s.sched.Location

View File

@ -31,7 +31,7 @@ Specifies whether all workspaces will be listed or not.
| Type | <code>string-array</code> | | Type | <code>string-array</code> |
| Default | <code>workspace,template,status,healthy,last built,outdated,starts at,stops after</code> | | Default | <code>workspace,template,status,healthy,last built,outdated,starts at,stops after</code> |
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 ### -o, --output

View File

@ -15,6 +15,6 @@ coder schedule { show | start | stop | override } <workspace>
| Name | Purpose | | Name | Purpose |
| --------------------------------------------------------- | ----------------------------------------------------------------- | | --------------------------------------------------------- | ----------------------------------------------------------------- |
| [<code>override-stop</code>](./schedule_override-stop.md) | Override the stop time of a currently running workspace instance. | | [<code>override-stop</code>](./schedule_override-stop.md) | Override the stop time of a currently running workspace instance. |
| [<code>show</code>](./schedule_show.md) | Show workspace schedule | | [<code>show</code>](./schedule_show.md) | Show workspace schedules |
| [<code>start</code>](./schedule_start.md) | Edit workspace start schedule | | [<code>start</code>](./schedule_start.md) | Edit workspace start schedule |
| [<code>stop</code>](./schedule_stop.md) | Edit workspace stop schedule | | [<code>stop</code>](./schedule_stop.md) | Edit workspace stop schedule |

View File

@ -2,21 +2,58 @@
# schedule show # schedule show
Show workspace schedule Show workspace schedules
## Usage ## Usage
```console ```console
coder schedule show <workspace-name> coder schedule show [flags] <workspace | --search <query> | --all>
``` ```
## Description ## Description
```console ```console
Shows the following information for the given workspace: Shows the following information for the given workspace(s):
* The automatic start schedule * The automatic start schedule
* The next scheduled start time * The next scheduled start time
* The duration after which it will stop * The duration after which it will stop
* The next scheduled stop time * The next scheduled stop time
``` ```
## Options
### -a, --all
| | |
| ---- | ----------------- |
| Type | <code>bool</code> |
Specifies whether all workspaces will be listed or not.
### -c, --column
| | |
| ------- | ------------------------------------------------------------------- |
| Type | <code>string-array</code> |
| Default | <code>workspace,starts at,starts next,stops after,stops next</code> |
Columns to display in table output. Available columns: workspace, starts at, starts next, stops after, stops next.
### -o, --output
| | |
| ------- | ------------------- |
| Type | <code>string</code> |
| Default | <code>table</code> |
Output format. Available formats: table, json.
### --search
| | |
| ------- | --------------------- |
| Type | <code>string</code> |
| Default | <code>owner:me</code> |
Search for a workspace with a query.

View File

@ -734,7 +734,7 @@
}, },
{ {
"title": "schedule show", "title": "schedule show",
"description": "Show workspace schedule", "description": "Show workspace schedules",
"path": "cli/schedule_show.md" "path": "cli/schedule_show.md"
}, },
{ {