Skip to content

Commit 93933d7

Browse files
authored
feat(cli): show queue position during workspace builds (coder#12606)
1 parent c7597fd commit 93933d7

File tree

3 files changed

+174
-30
lines changed

3 files changed

+174
-30
lines changed

cli/cliui/provisionerjob.go

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,11 @@ func (err *ProvisionerJobError) Error() string {
5454
return err.Message
5555
}
5656

57+
const (
58+
ProvisioningStateQueued = "Queued"
59+
ProvisioningStateRunning = "Running"
60+
)
61+
5762
// ProvisionerJob renders a provisioner job with interactive cancellation.
5863
func ProvisionerJob(ctx context.Context, wr io.Writer, opts ProvisionerJobOptions) error {
5964
if opts.FetchInterval == 0 {
@@ -63,8 +68,9 @@ func ProvisionerJob(ctx context.Context, wr io.Writer, opts ProvisionerJobOption
6368
defer cancelFunc()
6469

6570
var (
66-
currentStage = "Queued"
71+
currentStage = ProvisioningStateQueued
6772
currentStageStartedAt = time.Now().UTC()
73+
currentQueuePos = -1
6874

6975
errChan = make(chan error, 1)
7076
job codersdk.ProvisionerJob
@@ -74,7 +80,20 @@ func ProvisionerJob(ctx context.Context, wr io.Writer, opts ProvisionerJobOption
7480
sw := &stageWriter{w: wr, verbose: opts.Verbose, silentLogs: opts.Silent}
7581

7682
printStage := func() {
77-
sw.Start(currentStage)
83+
out := currentStage
84+
85+
if currentStage == ProvisioningStateQueued && currentQueuePos > 0 {
86+
var queuePos string
87+
if currentQueuePos == 1 {
88+
queuePos = "next"
89+
} else {
90+
queuePos = fmt.Sprintf("position: %d", currentQueuePos)
91+
}
92+
93+
out = pretty.Sprintf(DefaultStyles.Warn, "%s (%s)", currentStage, queuePos)
94+
}
95+
96+
sw.Start(out)
7897
}
7998

8099
updateStage := func(stage string, startedAt time.Time) {
@@ -103,15 +122,26 @@ func ProvisionerJob(ctx context.Context, wr io.Writer, opts ProvisionerJobOption
103122
errChan <- xerrors.Errorf("fetch: %w", err)
104123
return
105124
}
125+
if job.QueuePosition != currentQueuePos {
126+
initialState := currentQueuePos == -1
127+
128+
currentQueuePos = job.QueuePosition
129+
// Print an update when the queue position changes, but:
130+
// - not initially, because the stage is printed at startup
131+
// - not when we're first in the queue, because it's redundant
132+
if !initialState && currentQueuePos != 0 {
133+
printStage()
134+
}
135+
}
106136
if job.StartedAt == nil {
107137
return
108138
}
109-
if currentStage != "Queued" {
139+
if currentStage != ProvisioningStateQueued {
110140
// If another stage is already running, there's no need
111141
// for us to notify the user we're running!
112142
return
113143
}
114-
updateStage("Running", *job.StartedAt)
144+
updateStage(ProvisioningStateRunning, *job.StartedAt)
115145
}
116146

117147
if opts.Cancel != nil {
@@ -143,8 +173,8 @@ func ProvisionerJob(ctx context.Context, wr io.Writer, opts ProvisionerJobOption
143173
}
144174

145175
// The initial stage needs to print after the signal handler has been registered.
146-
printStage()
147176
updateJob()
177+
printStage()
148178

149179
logs, closer, err := opts.Logs()
150180
if err != nil {

cli/cliui/provisionerjob_test.go

Lines changed: 116 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@ package cliui_test
22

33
import (
44
"context"
5+
"fmt"
56
"io"
67
"os"
8+
"regexp"
79
"runtime"
810
"sync"
911
"testing"
1012
"time"
1113

14+
"github.com/coder/coder/v2/testutil"
1215
"github.com/stretchr/testify/assert"
1316

1417
"github.com/coder/coder/v2/cli/cliui"
@@ -25,7 +28,11 @@ func TestProvisionerJob(t *testing.T) {
2528
t.Parallel()
2629

2730
test := newProvisionerJob(t)
28-
go func() {
31+
32+
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
33+
defer cancel()
34+
35+
testutil.Go(t, func() {
2936
<-test.Next
3037
test.JobMutex.Lock()
3138
test.Job.Status = codersdk.ProvisionerJobRunning
@@ -39,20 +46,26 @@ func TestProvisionerJob(t *testing.T) {
3946
test.Job.CompletedAt = &now
4047
close(test.Logs)
4148
test.JobMutex.Unlock()
42-
}()
43-
test.PTY.ExpectMatch("Queued")
44-
test.Next <- struct{}{}
45-
test.PTY.ExpectMatch("Queued")
46-
test.PTY.ExpectMatch("Running")
47-
test.Next <- struct{}{}
48-
test.PTY.ExpectMatch("Running")
49+
})
50+
testutil.Eventually(ctx, t, func(ctx context.Context) (done bool) {
51+
test.PTY.ExpectMatch(cliui.ProvisioningStateQueued)
52+
test.Next <- struct{}{}
53+
test.PTY.ExpectMatch(cliui.ProvisioningStateQueued)
54+
test.PTY.ExpectMatch(cliui.ProvisioningStateRunning)
55+
test.Next <- struct{}{}
56+
test.PTY.ExpectMatch(cliui.ProvisioningStateRunning)
57+
return true
58+
}, testutil.IntervalFast)
4959
})
5060

5161
t.Run("Stages", func(t *testing.T) {
5262
t.Parallel()
5363

5464
test := newProvisionerJob(t)
55-
go func() {
65+
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
66+
defer cancel()
67+
68+
testutil.Go(t, func() {
5669
<-test.Next
5770
test.JobMutex.Lock()
5871
test.Job.Status = codersdk.ProvisionerJobRunning
@@ -70,13 +83,86 @@ func TestProvisionerJob(t *testing.T) {
7083
test.Job.CompletedAt = &now
7184
close(test.Logs)
7285
test.JobMutex.Unlock()
73-
}()
74-
test.PTY.ExpectMatch("Queued")
75-
test.Next <- struct{}{}
76-
test.PTY.ExpectMatch("Queued")
77-
test.PTY.ExpectMatch("Something")
78-
test.Next <- struct{}{}
79-
test.PTY.ExpectMatch("Something")
86+
})
87+
testutil.Eventually(ctx, t, func(ctx context.Context) (done bool) {
88+
test.PTY.ExpectMatch(cliui.ProvisioningStateQueued)
89+
test.Next <- struct{}{}
90+
test.PTY.ExpectMatch(cliui.ProvisioningStateQueued)
91+
test.PTY.ExpectMatch("Something")
92+
test.Next <- struct{}{}
93+
test.PTY.ExpectMatch("Something")
94+
return true
95+
}, testutil.IntervalFast)
96+
})
97+
98+
t.Run("Queue Position", func(t *testing.T) {
99+
t.Parallel()
100+
101+
stage := cliui.ProvisioningStateQueued
102+
103+
tests := []struct {
104+
name string
105+
queuePos int
106+
expected string
107+
}{
108+
{
109+
name: "first",
110+
queuePos: 0,
111+
expected: fmt.Sprintf("%s$", stage),
112+
},
113+
{
114+
name: "next",
115+
queuePos: 1,
116+
expected: fmt.Sprintf(`%s %s$`, stage, regexp.QuoteMeta("(next)")),
117+
},
118+
{
119+
name: "other",
120+
queuePos: 4,
121+
expected: fmt.Sprintf(`%s %s$`, stage, regexp.QuoteMeta("(position: 4)")),
122+
},
123+
}
124+
125+
for _, tc := range tests {
126+
tc := tc
127+
128+
t.Run(tc.name, func(t *testing.T) {
129+
t.Parallel()
130+
131+
test := newProvisionerJob(t)
132+
test.JobMutex.Lock()
133+
test.Job.QueuePosition = tc.queuePos
134+
test.Job.QueueSize = tc.queuePos
135+
test.JobMutex.Unlock()
136+
137+
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
138+
defer cancel()
139+
140+
testutil.Go(t, func() {
141+
<-test.Next
142+
test.JobMutex.Lock()
143+
test.Job.Status = codersdk.ProvisionerJobRunning
144+
now := dbtime.Now()
145+
test.Job.StartedAt = &now
146+
test.JobMutex.Unlock()
147+
<-test.Next
148+
test.JobMutex.Lock()
149+
test.Job.Status = codersdk.ProvisionerJobSucceeded
150+
now = dbtime.Now()
151+
test.Job.CompletedAt = &now
152+
close(test.Logs)
153+
test.JobMutex.Unlock()
154+
})
155+
testutil.Eventually(ctx, t, func(ctx context.Context) (done bool) {
156+
test.PTY.ExpectRegexMatch(tc.expected)
157+
test.Next <- struct{}{}
158+
test.PTY.ExpectMatch(cliui.ProvisioningStateQueued) // step completed
159+
test.PTY.ExpectMatch(cliui.ProvisioningStateRunning)
160+
test.Next <- struct{}{}
161+
test.PTY.ExpectMatch(cliui.ProvisioningStateRunning)
162+
return true
163+
}, testutil.IntervalFast)
164+
})
165+
}
80166
})
81167

82168
// This cannot be ran in parallel because it uses a signal.
@@ -90,7 +176,11 @@ func TestProvisionerJob(t *testing.T) {
90176
}
91177

92178
test := newProvisionerJob(t)
93-
go func() {
179+
180+
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
181+
defer cancel()
182+
183+
testutil.Go(t, func() {
94184
<-test.Next
95185
currentProcess, err := os.FindProcess(os.Getpid())
96186
assert.NoError(t, err)
@@ -103,12 +193,15 @@ func TestProvisionerJob(t *testing.T) {
103193
test.Job.CompletedAt = &now
104194
close(test.Logs)
105195
test.JobMutex.Unlock()
106-
}()
107-
test.PTY.ExpectMatch("Queued")
108-
test.Next <- struct{}{}
109-
test.PTY.ExpectMatch("Gracefully canceling")
110-
test.Next <- struct{}{}
111-
test.PTY.ExpectMatch("Queued")
196+
})
197+
testutil.Eventually(ctx, t, func(ctx context.Context) (done bool) {
198+
test.PTY.ExpectMatch(cliui.ProvisioningStateQueued)
199+
test.Next <- struct{}{}
200+
test.PTY.ExpectMatch("Gracefully canceling")
201+
test.Next <- struct{}{}
202+
test.PTY.ExpectMatch(cliui.ProvisioningStateQueued)
203+
return true
204+
}, testutil.IntervalFast)
112205
})
113206
}
114207

pty/ptytest/ptytest.go

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"context"
77
"fmt"
88
"io"
9+
"regexp"
910
"runtime"
1011
"strings"
1112
"sync"
@@ -145,16 +146,36 @@ type outExpecter struct {
145146
}
146147

147148
func (e *outExpecter) ExpectMatch(str string) string {
149+
return e.expectMatchContextFunc(str, e.ExpectMatchContext)
150+
}
151+
152+
func (e *outExpecter) ExpectRegexMatch(str string) string {
153+
return e.expectMatchContextFunc(str, e.ExpectRegexMatchContext)
154+
}
155+
156+
func (e *outExpecter) expectMatchContextFunc(str string, fn func(ctx context.Context, str string) string) string {
148157
e.t.Helper()
149158

150159
timeout, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium)
151160
defer cancel()
152161

153-
return e.ExpectMatchContext(timeout, str)
162+
return fn(timeout, str)
154163
}
155164

156165
// TODO(mafredri): Rename this to ExpectMatch when refactoring.
157166
func (e *outExpecter) ExpectMatchContext(ctx context.Context, str string) string {
167+
return e.expectMatcherFunc(ctx, str, func(src, pattern string) bool {
168+
return strings.Contains(src, pattern)
169+
})
170+
}
171+
172+
func (e *outExpecter) ExpectRegexMatchContext(ctx context.Context, str string) string {
173+
return e.expectMatcherFunc(ctx, str, func(src, pattern string) bool {
174+
return regexp.MustCompile(pattern).MatchString(src)
175+
})
176+
}
177+
178+
func (e *outExpecter) expectMatcherFunc(ctx context.Context, str string, fn func(src, pattern string) bool) string {
158179
e.t.Helper()
159180

160181
var buffer bytes.Buffer
@@ -168,7 +189,7 @@ func (e *outExpecter) ExpectMatchContext(ctx context.Context, str string) string
168189
if err != nil {
169190
return err
170191
}
171-
if strings.Contains(buffer.String(), str) {
192+
if fn(buffer.String(), str) {
172193
return nil
173194
}
174195
}

0 commit comments

Comments
 (0)