mirror of https://github.com/coder/coder.git
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:
parent
177affbe4b
commit
a4f1319108
|
@ -1,6 +0,0 @@
|
||||||
package cli
|
|
||||||
|
|
||||||
const (
|
|
||||||
timeFormat = "3:04PM MST"
|
|
||||||
dateFormat = "Jan 2, 2006"
|
|
||||||
)
|
|
84
cli/list.go
84
cli/list.go
|
@ -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
|
||||||
|
}
|
||||||
|
|
146
cli/schedule.go
146
cli/schedule.go
|
@ -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
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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.
|
||||||
|
|
11
cli/util.go
11
cli/util.go
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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 |
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
Loading…
Reference in New Issue