mirror of https://github.com/coder/coder.git
feat(cli): show queue position during workspace builds (#12606)
This commit is contained in:
parent
c7597fdf02
commit
93933d7905
|
@ -54,6 +54,11 @@ func (err *ProvisionerJobError) Error() string {
|
||||||
return err.Message
|
return err.Message
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
ProvisioningStateQueued = "Queued"
|
||||||
|
ProvisioningStateRunning = "Running"
|
||||||
|
)
|
||||||
|
|
||||||
// ProvisionerJob renders a provisioner job with interactive cancellation.
|
// ProvisionerJob renders a provisioner job with interactive cancellation.
|
||||||
func ProvisionerJob(ctx context.Context, wr io.Writer, opts ProvisionerJobOptions) error {
|
func ProvisionerJob(ctx context.Context, wr io.Writer, opts ProvisionerJobOptions) error {
|
||||||
if opts.FetchInterval == 0 {
|
if opts.FetchInterval == 0 {
|
||||||
|
@ -63,8 +68,9 @@ func ProvisionerJob(ctx context.Context, wr io.Writer, opts ProvisionerJobOption
|
||||||
defer cancelFunc()
|
defer cancelFunc()
|
||||||
|
|
||||||
var (
|
var (
|
||||||
currentStage = "Queued"
|
currentStage = ProvisioningStateQueued
|
||||||
currentStageStartedAt = time.Now().UTC()
|
currentStageStartedAt = time.Now().UTC()
|
||||||
|
currentQueuePos = -1
|
||||||
|
|
||||||
errChan = make(chan error, 1)
|
errChan = make(chan error, 1)
|
||||||
job codersdk.ProvisionerJob
|
job codersdk.ProvisionerJob
|
||||||
|
@ -74,7 +80,20 @@ func ProvisionerJob(ctx context.Context, wr io.Writer, opts ProvisionerJobOption
|
||||||
sw := &stageWriter{w: wr, verbose: opts.Verbose, silentLogs: opts.Silent}
|
sw := &stageWriter{w: wr, verbose: opts.Verbose, silentLogs: opts.Silent}
|
||||||
|
|
||||||
printStage := func() {
|
printStage := func() {
|
||||||
sw.Start(currentStage)
|
out := currentStage
|
||||||
|
|
||||||
|
if currentStage == ProvisioningStateQueued && currentQueuePos > 0 {
|
||||||
|
var queuePos string
|
||||||
|
if currentQueuePos == 1 {
|
||||||
|
queuePos = "next"
|
||||||
|
} else {
|
||||||
|
queuePos = fmt.Sprintf("position: %d", currentQueuePos)
|
||||||
|
}
|
||||||
|
|
||||||
|
out = pretty.Sprintf(DefaultStyles.Warn, "%s (%s)", currentStage, queuePos)
|
||||||
|
}
|
||||||
|
|
||||||
|
sw.Start(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
updateStage := func(stage string, startedAt time.Time) {
|
updateStage := func(stage string, startedAt time.Time) {
|
||||||
|
@ -103,15 +122,26 @@ func ProvisionerJob(ctx context.Context, wr io.Writer, opts ProvisionerJobOption
|
||||||
errChan <- xerrors.Errorf("fetch: %w", err)
|
errChan <- xerrors.Errorf("fetch: %w", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if job.QueuePosition != currentQueuePos {
|
||||||
|
initialState := currentQueuePos == -1
|
||||||
|
|
||||||
|
currentQueuePos = job.QueuePosition
|
||||||
|
// Print an update when the queue position changes, but:
|
||||||
|
// - not initially, because the stage is printed at startup
|
||||||
|
// - not when we're first in the queue, because it's redundant
|
||||||
|
if !initialState && currentQueuePos != 0 {
|
||||||
|
printStage()
|
||||||
|
}
|
||||||
|
}
|
||||||
if job.StartedAt == nil {
|
if job.StartedAt == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if currentStage != "Queued" {
|
if currentStage != ProvisioningStateQueued {
|
||||||
// If another stage is already running, there's no need
|
// If another stage is already running, there's no need
|
||||||
// for us to notify the user we're running!
|
// for us to notify the user we're running!
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
updateStage("Running", *job.StartedAt)
|
updateStage(ProvisioningStateRunning, *job.StartedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
if opts.Cancel != nil {
|
if opts.Cancel != nil {
|
||||||
|
@ -143,8 +173,8 @@ func ProvisionerJob(ctx context.Context, wr io.Writer, opts ProvisionerJobOption
|
||||||
}
|
}
|
||||||
|
|
||||||
// The initial stage needs to print after the signal handler has been registered.
|
// The initial stage needs to print after the signal handler has been registered.
|
||||||
printStage()
|
|
||||||
updateJob()
|
updateJob()
|
||||||
|
printStage()
|
||||||
|
|
||||||
logs, closer, err := opts.Logs()
|
logs, closer, err := opts.Logs()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -2,13 +2,16 @@ package cliui_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"regexp"
|
||||||
"runtime"
|
"runtime"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/coder/coder/v2/testutil"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
"github.com/coder/coder/v2/cli/cliui"
|
"github.com/coder/coder/v2/cli/cliui"
|
||||||
|
@ -25,7 +28,11 @@ func TestProvisionerJob(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
test := newProvisionerJob(t)
|
test := newProvisionerJob(t)
|
||||||
go func() {
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
testutil.Go(t, func() {
|
||||||
<-test.Next
|
<-test.Next
|
||||||
test.JobMutex.Lock()
|
test.JobMutex.Lock()
|
||||||
test.Job.Status = codersdk.ProvisionerJobRunning
|
test.Job.Status = codersdk.ProvisionerJobRunning
|
||||||
|
@ -39,20 +46,26 @@ func TestProvisionerJob(t *testing.T) {
|
||||||
test.Job.CompletedAt = &now
|
test.Job.CompletedAt = &now
|
||||||
close(test.Logs)
|
close(test.Logs)
|
||||||
test.JobMutex.Unlock()
|
test.JobMutex.Unlock()
|
||||||
}()
|
})
|
||||||
test.PTY.ExpectMatch("Queued")
|
testutil.Eventually(ctx, t, func(ctx context.Context) (done bool) {
|
||||||
test.Next <- struct{}{}
|
test.PTY.ExpectMatch(cliui.ProvisioningStateQueued)
|
||||||
test.PTY.ExpectMatch("Queued")
|
test.Next <- struct{}{}
|
||||||
test.PTY.ExpectMatch("Running")
|
test.PTY.ExpectMatch(cliui.ProvisioningStateQueued)
|
||||||
test.Next <- struct{}{}
|
test.PTY.ExpectMatch(cliui.ProvisioningStateRunning)
|
||||||
test.PTY.ExpectMatch("Running")
|
test.Next <- struct{}{}
|
||||||
|
test.PTY.ExpectMatch(cliui.ProvisioningStateRunning)
|
||||||
|
return true
|
||||||
|
}, testutil.IntervalFast)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("Stages", func(t *testing.T) {
|
t.Run("Stages", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
test := newProvisionerJob(t)
|
test := newProvisionerJob(t)
|
||||||
go func() {
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
testutil.Go(t, func() {
|
||||||
<-test.Next
|
<-test.Next
|
||||||
test.JobMutex.Lock()
|
test.JobMutex.Lock()
|
||||||
test.Job.Status = codersdk.ProvisionerJobRunning
|
test.Job.Status = codersdk.ProvisionerJobRunning
|
||||||
|
@ -70,13 +83,86 @@ func TestProvisionerJob(t *testing.T) {
|
||||||
test.Job.CompletedAt = &now
|
test.Job.CompletedAt = &now
|
||||||
close(test.Logs)
|
close(test.Logs)
|
||||||
test.JobMutex.Unlock()
|
test.JobMutex.Unlock()
|
||||||
}()
|
})
|
||||||
test.PTY.ExpectMatch("Queued")
|
testutil.Eventually(ctx, t, func(ctx context.Context) (done bool) {
|
||||||
test.Next <- struct{}{}
|
test.PTY.ExpectMatch(cliui.ProvisioningStateQueued)
|
||||||
test.PTY.ExpectMatch("Queued")
|
test.Next <- struct{}{}
|
||||||
test.PTY.ExpectMatch("Something")
|
test.PTY.ExpectMatch(cliui.ProvisioningStateQueued)
|
||||||
test.Next <- struct{}{}
|
test.PTY.ExpectMatch("Something")
|
||||||
test.PTY.ExpectMatch("Something")
|
test.Next <- struct{}{}
|
||||||
|
test.PTY.ExpectMatch("Something")
|
||||||
|
return true
|
||||||
|
}, testutil.IntervalFast)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Queue Position", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
stage := cliui.ProvisioningStateQueued
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
queuePos int
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "first",
|
||||||
|
queuePos: 0,
|
||||||
|
expected: fmt.Sprintf("%s$", stage),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "next",
|
||||||
|
queuePos: 1,
|
||||||
|
expected: fmt.Sprintf(`%s %s$`, stage, regexp.QuoteMeta("(next)")),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "other",
|
||||||
|
queuePos: 4,
|
||||||
|
expected: fmt.Sprintf(`%s %s$`, stage, regexp.QuoteMeta("(position: 4)")),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
tc := tc
|
||||||
|
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
test := newProvisionerJob(t)
|
||||||
|
test.JobMutex.Lock()
|
||||||
|
test.Job.QueuePosition = tc.queuePos
|
||||||
|
test.Job.QueueSize = tc.queuePos
|
||||||
|
test.JobMutex.Unlock()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
testutil.Go(t, func() {
|
||||||
|
<-test.Next
|
||||||
|
test.JobMutex.Lock()
|
||||||
|
test.Job.Status = codersdk.ProvisionerJobRunning
|
||||||
|
now := dbtime.Now()
|
||||||
|
test.Job.StartedAt = &now
|
||||||
|
test.JobMutex.Unlock()
|
||||||
|
<-test.Next
|
||||||
|
test.JobMutex.Lock()
|
||||||
|
test.Job.Status = codersdk.ProvisionerJobSucceeded
|
||||||
|
now = dbtime.Now()
|
||||||
|
test.Job.CompletedAt = &now
|
||||||
|
close(test.Logs)
|
||||||
|
test.JobMutex.Unlock()
|
||||||
|
})
|
||||||
|
testutil.Eventually(ctx, t, func(ctx context.Context) (done bool) {
|
||||||
|
test.PTY.ExpectRegexMatch(tc.expected)
|
||||||
|
test.Next <- struct{}{}
|
||||||
|
test.PTY.ExpectMatch(cliui.ProvisioningStateQueued) // step completed
|
||||||
|
test.PTY.ExpectMatch(cliui.ProvisioningStateRunning)
|
||||||
|
test.Next <- struct{}{}
|
||||||
|
test.PTY.ExpectMatch(cliui.ProvisioningStateRunning)
|
||||||
|
return true
|
||||||
|
}, testutil.IntervalFast)
|
||||||
|
})
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// This cannot be ran in parallel because it uses a signal.
|
// This cannot be ran in parallel because it uses a signal.
|
||||||
|
@ -90,7 +176,11 @@ func TestProvisionerJob(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
test := newProvisionerJob(t)
|
test := newProvisionerJob(t)
|
||||||
go func() {
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
testutil.Go(t, func() {
|
||||||
<-test.Next
|
<-test.Next
|
||||||
currentProcess, err := os.FindProcess(os.Getpid())
|
currentProcess, err := os.FindProcess(os.Getpid())
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
@ -103,12 +193,15 @@ func TestProvisionerJob(t *testing.T) {
|
||||||
test.Job.CompletedAt = &now
|
test.Job.CompletedAt = &now
|
||||||
close(test.Logs)
|
close(test.Logs)
|
||||||
test.JobMutex.Unlock()
|
test.JobMutex.Unlock()
|
||||||
}()
|
})
|
||||||
test.PTY.ExpectMatch("Queued")
|
testutil.Eventually(ctx, t, func(ctx context.Context) (done bool) {
|
||||||
test.Next <- struct{}{}
|
test.PTY.ExpectMatch(cliui.ProvisioningStateQueued)
|
||||||
test.PTY.ExpectMatch("Gracefully canceling")
|
test.Next <- struct{}{}
|
||||||
test.Next <- struct{}{}
|
test.PTY.ExpectMatch("Gracefully canceling")
|
||||||
test.PTY.ExpectMatch("Queued")
|
test.Next <- struct{}{}
|
||||||
|
test.PTY.ExpectMatch(cliui.ProvisioningStateQueued)
|
||||||
|
return true
|
||||||
|
}, testutil.IntervalFast)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"regexp"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
@ -145,16 +146,36 @@ type outExpecter struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *outExpecter) ExpectMatch(str string) string {
|
func (e *outExpecter) ExpectMatch(str string) string {
|
||||||
|
return e.expectMatchContextFunc(str, e.ExpectMatchContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *outExpecter) ExpectRegexMatch(str string) string {
|
||||||
|
return e.expectMatchContextFunc(str, e.ExpectRegexMatchContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *outExpecter) expectMatchContextFunc(str string, fn func(ctx context.Context, str string) string) string {
|
||||||
e.t.Helper()
|
e.t.Helper()
|
||||||
|
|
||||||
timeout, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium)
|
timeout, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
return e.ExpectMatchContext(timeout, str)
|
return fn(timeout, str)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO(mafredri): Rename this to ExpectMatch when refactoring.
|
// TODO(mafredri): Rename this to ExpectMatch when refactoring.
|
||||||
func (e *outExpecter) ExpectMatchContext(ctx context.Context, str string) string {
|
func (e *outExpecter) ExpectMatchContext(ctx context.Context, str string) string {
|
||||||
|
return e.expectMatcherFunc(ctx, str, func(src, pattern string) bool {
|
||||||
|
return strings.Contains(src, pattern)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *outExpecter) ExpectRegexMatchContext(ctx context.Context, str string) string {
|
||||||
|
return e.expectMatcherFunc(ctx, str, func(src, pattern string) bool {
|
||||||
|
return regexp.MustCompile(pattern).MatchString(src)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *outExpecter) expectMatcherFunc(ctx context.Context, str string, fn func(src, pattern string) bool) string {
|
||||||
e.t.Helper()
|
e.t.Helper()
|
||||||
|
|
||||||
var buffer bytes.Buffer
|
var buffer bytes.Buffer
|
||||||
|
@ -168,7 +189,7 @@ func (e *outExpecter) ExpectMatchContext(ctx context.Context, str string) string
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if strings.Contains(buffer.String(), str) {
|
if fn(buffer.String(), str) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue