carlos / platform Public

cli: carlos run follows, expires, acks and exits with the spec's codes (run spec §5.2, §5.6, codex 5, R2-6)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 1e35205752cbfec35874627e818276cb22d03652 parent 3313a53
3 files changed, +598 −2
  • approved_run_copy_test.go +1 −1
  • run.go +243 −1
  • run_test.go +354 −0
diff --git a/approved_run_copy_test.go b/approved_run_copy_test.go
index ead810535..cdeb35113 100644
--- a/approved_run_copy_test.go
+++ b/approved_run_copy_test.go
@@ -33,7 +33,7 @@ var runCopyIDs = func() []string {
// runCopyBuilt is every deck id whose text the code or docs now carry. The
// task that writes a row's text adds its id here in the same commit.
-var runCopyBuilt = []string{"RUN-C1", "RUN-C2", "RUN-C3", "RUN-C4", "RUN-C5", "RUN-C6", "RUN-C7", "RUN-C8", "RUN-C9", "RUN-C10", "RUN-C11", "RUN-C12", "RUN-C13", "RUN-C14", "RUN-C15", "RUN-C16", "RUN-C17", "RUN-C18", "RUN-C19", "RUN-C20", "RUN-C21", "RUN-C23", "RUN-C24", "RUN-C25", "RUN-C26", "RUN-C27", "RUN-C28", "RUN-C29", "RUN-C30", "RUN-C31", "RUN-C32", "RUN-C33", "RUN-C34", "RUN-C35", "RUN-C36", "RUN-C37", "RUN-C38", "RUN-C39", "RUN-C40", "RUN-C41", "RUN-C42", "RUN-C43", "RUN-C44", "RUN-C45", "RUN-C46", "RUN-C47", "RUN-C48", "RUN-C49", "RUN-C50", "RUN-C51", "RUN-C52", "RUN-C53", "RUN-C82", "RUN-C83"}
+var runCopyBuilt = []string{"RUN-C1", "RUN-C2", "RUN-C3", "RUN-C4", "RUN-C5", "RUN-C6", "RUN-C7", "RUN-C8", "RUN-C9", "RUN-C10", "RUN-C11", "RUN-C12", "RUN-C13", "RUN-C14", "RUN-C15", "RUN-C16", "RUN-C17", "RUN-C18", "RUN-C19", "RUN-C20", "RUN-C21", "RUN-C22", "RUN-C23", "RUN-C24", "RUN-C25", "RUN-C26", "RUN-C27", "RUN-C28", "RUN-C29", "RUN-C30", "RUN-C31", "RUN-C32", "RUN-C33", "RUN-C34", "RUN-C35", "RUN-C36", "RUN-C37", "RUN-C38", "RUN-C39", "RUN-C40", "RUN-C41", "RUN-C42", "RUN-C43", "RUN-C44", "RUN-C45", "RUN-C46", "RUN-C47", "RUN-C48", "RUN-C49", "RUN-C50", "RUN-C51", "RUN-C52", "RUN-C53", "RUN-C54", "RUN-C55", "RUN-C56", "RUN-C57", "RUN-C58", "RUN-C59", "RUN-C60", "RUN-C61", "RUN-C62", "RUN-C63", "RUN-C64", "RUN-C65", "RUN-C66", "RUN-C67", "RUN-C82", "RUN-C83"}
func mustParseRunRecord(t *testing.T) []copyRow {
t.Helper()
diff --git a/run.go b/run.go
index ccbe3515a..5d19855b7 100644
--- a/run.go
+++ b/run.go
@@ -7,16 +7,21 @@ import (
"flag"
"fmt"
"io"
+ "log"
"mime/multipart"
"net/http"
"net/url"
"os"
+ "os/signal"
"path/filepath"
"slices"
+ "strconv"
"strings"
"sync"
+ "syscall"
"time"
+ "github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/runs"
)
@@ -288,6 +293,225 @@ func (c runAPI) ack(ctx context.Context, id string, lastSeq int64) error {
return runAnswer(status, body, err, http.StatusOK, nil)
}
+// runTerm is how the follow loop meets the terminal and the clock; tests
+// replace all of it.
+type runTerm struct {
+ stdout, stderr io.Writer
+ interrupts <-chan os.Signal
+ after func(time.Duration) <-chan time.Time
+ now func() time.Time
+}
+
+func (t runTerm) say(s string) { fmt.Fprintln(t.stderr, s) }
+
+// runAckTries is how many times an ack that the console cannot take yet, or
+// that did not arrive, is sent; runAckWait is the pause between them.
+const (
+ runAckTries = 10
+ runAckWait = time.Second
+)
+
+// runConflict reports whether err is the console's 409 with exactly msg.
+func runConflict(err error, msg string) bool {
+ var ce *runConsoleError
+ return errors.As(err, &ce) && ce.status == http.StatusConflict && ce.msg == msg
+}
+
+// ackRun tells the console the output has been read, so it can delete the
+// run's objects. The output is already on the terminal and the exit code is
+// the command's, so an ack that cannot be made is said and nothing more.
+// An ack retried after a lost 200 finds the run gone (404), which is done.
+// An ack that beats the box's last write (409, not finished) or does not
+// arrive is sent again a few times.
+func ackRun(ctx context.Context, c runClient, t runTerm, id string, lastSeq int64) {
+ var err error
+ for try := 0; try < runAckTries; try++ {
+ if try > 0 {
+ <-t.after(runAckWait)
+ }
+ err = c.ack(ctx, id, lastSeq)
+ var ce *runConsoleError
+ switch {
+ case err == nil:
+ return
+ case errors.As(err, &ce) && ce.status == http.StatusNotFound:
+ return
+ case errors.As(err, &ce) && !runConflict(err, fmt.Sprintf("run %s has not finished yet", id)):
+ t.say(fmt.Sprintf("carlos run: %v", err))
+ return
+ }
+ }
+ t.say(fmt.Sprintf("carlos run: %v", err))
+}
+
+// followRun prints a run's output until its result, acknowledges it, and
+// returns the exit code (run spec §5.6). It polls once a second; a page
+// cut short is followed at once; the result is in hand only when a page
+// carries it and is not cut short, because the console reads the result
+// before it lists chunks. A run still queued at expireAt is expired (§5.2);
+// zero never expires. The first Ctrl-C cancels the run on the box and keeps
+// following; the second stops waiting (§5.7).
+func followRun(ctx context.Context, c runClient, t runTerm, host, id string, expireAt time.Time) int {
+ var after int64
+ cancelled := false
+ for {
+ p, err := c.page(ctx, id, after)
+ if err != nil {
+ var ce *runConsoleError
+ switch {
+ case errors.As(err, &ce) && ce.status == http.StatusNotFound:
+ t.say(fmt.Sprintf("%s has no run %s, or this console has no carlos run yet", host, id))
+ return 2
+ case errors.As(err, &ce):
+ t.say(fmt.Sprintf("carlos run: %v", err))
+ return 2
+ }
+ t.say(fmt.Sprintf("carlos run: %v", err))
+ return 1
+ }
+ for _, ch := range p.Chunks {
+ if ch.Seq <= after {
+ continue
+ }
+ w := t.stdout
+ if ch.Stream == runs.StreamErr {
+ w = t.stderr
+ }
+ w.Write(ch.Data)
+ after = ch.Seq
+ }
+ if p.More {
+ continue
+ }
+ if p.Result != nil {
+ ackRun(ctx, c, t, id, p.Result.LastSeq)
+ code, lines := runExit(host, *p.Result)
+ if p.Result.Cancelled && p.Result.Started.IsZero() {
+ // Cancelled before any box launched it: there was no command.
+ lines = []string{fmt.Sprintf("%s: run %s was cancelled before it started", host, id)}
+ }
+ for _, l := range lines {
+ t.say(l)
+ }
+ return code
+ }
+ switch p.State {
+ case provision.RunExpired:
+ t.say(fmt.Sprintf("%s: not picked up within 60 seconds. the run did not start. try again", host))
+ return 3
+ case provision.RunCancelled:
+ t.say(fmt.Sprintf("%s: run %s was cancelled before it started", host, id))
+ return 130
+ case provision.RunQueued:
+ if !expireAt.IsZero() && !t.now().Before(expireAt) {
+ state, err := c.expire(ctx, id)
+ var ce *runConsoleError
+ switch {
+ case err == nil && state == provision.RunExpired:
+ t.say(fmt.Sprintf("%s: not picked up within 60 seconds. the run did not start. try again", host))
+ return 3
+ case err == nil || errors.As(err, &ce):
+ // A box picked the run first (409), or the console will
+ // not expire it: follow it. A call that did not arrive is
+ // asked again on the next poll.
+ expireAt = time.Time{}
+ }
+ }
+ }
+ if interrupted(ctx, t, c, id, &cancelled) {
+ return 130
+ }
+ }
+}
+
+// interrupted waits out the poll interval and handles a Ctrl-C that arrives
+// first, or was already waiting: a pending Ctrl-C is taken before the next
+// tick, never after it. The first cancels the run and returns false so
+// following goes on; the second returns true: stop waiting.
+func interrupted(ctx context.Context, t runTerm, c runClient, id string, cancelled *bool) bool {
+ select {
+ case <-t.interrupts:
+ default:
+ select {
+ case <-t.after(time.Second):
+ return false
+ case <-t.interrupts:
+ }
+ }
+ if *cancelled {
+ return true
+ }
+ *cancelled = true
+ t.say(fmt.Sprintf("cancelling run %s. press Ctrl-C again to stop waiting", id))
+ // A cancel retried after a lost 200 finds the run already ended, which is
+ // what was asked for.
+ if _, err := c.cancel(ctx, id); err != nil && !runConflict(err, fmt.Sprintf("run %s has already ended", id)) {
+ t.say(fmt.Sprintf("carlos run: %v", err))
+ }
+ return false
+}
+
+// runExit is a result's exit code and what stderr says about it (run spec
+// §5.6, §8; index item 6).
+func runExit(host string, r runs.Result) (int, []string) {
+ var lines []string
+ if r.Truncated {
+ lines = append(lines, fmt.Sprintf("%s: the output passed 64 MB and was cut off there. the command ran on to its end", host))
+ }
+ switch {
+ case r.Refused == runs.RefusedExpired:
+ return 3, append(lines, fmt.Sprintf("%s: not picked up within 60 seconds. the run did not start. try again", host))
+ case r.Refused == runs.RefusedFlat:
+ return 3, append(lines, fmt.Sprintf("%s: the box is not on the nested layout yet, so carlos run cannot run there", host))
+ case r.Refused == runs.RefusedClaim:
+ return 3, append(lines, fmt.Sprintf("%s: the box has host claims armed. carlos run cannot run there yet", host))
+ case r.Refused == runs.RefusedWake:
+ return 3, append(lines, fmt.Sprintf("%s: the instance did not wake. %s", host, r.Detail))
+ case r.Refused == runs.RefusedIncarnation:
+ return 3, append(lines, fmt.Sprintf("%s was deleted and created again after this run was requested. the run did not start", host))
+ case r.Refused == runs.RefusedImport:
+ return 3, append(lines, fmt.Sprintf("%s is an import that is not committed yet. the run did not start", host))
+ case r.Refused != "":
+ return 3, append(lines, fmt.Sprintf("carlos run: %s", r.Refused))
+ case r.Cancelled:
+ return 130, append(lines, fmt.Sprintf("%s: the command was cancelled", host))
+ case r.TimedOut:
+ return 124, append(lines, fmt.Sprintf("%s: the command timed out after %s", host, r.Ended.Sub(r.Started).Round(time.Second)))
+ case r.Interrupted != "":
+ return 125, append(lines, fmt.Sprintf("%s: the run was interrupted: %s. it may have done part of its work", host, r.Interrupted))
+ case r.Signal != "":
+ n, _ := strconv.Atoi(r.Signal)
+ return 128 + n, lines
+ }
+ return r.Exit, lines
+}
+
+// startRun sends the run and follows it. A console refusal, or a --file that
+// cannot be read, is exit 2.
+func startRun(ctx context.Context, c runClient, t runTerm, o runOpts, app string) int {
+ meta, err := runMetaFor(o)
+ if err != nil {
+ t.say(err.Error())
+ return 2
+ }
+ created, err := c.create(ctx, meta, o.files)
+ if err != nil {
+ var ce *runConsoleError
+ switch {
+ case errors.Is(err, errRunLocalFile):
+ t.say(err.Error())
+ return 2
+ case errors.As(err, &ce):
+ t.say(fmt.Sprintf("carlos run: %v", err))
+ return 2
+ }
+ t.say(fmt.Sprintf("carlos run: %v", err))
+ return 1
+ }
+ t.say(fmt.Sprintf("run %s on %s. to follow it again: carlos run --app %s --instance %s --follow %s", created.ID, o.instance, app, o.instance, created.ID))
+ return followRun(ctx, c, t, o.instance, created.ID, t.now().Add(runs.PickupWindow))
+}
+
// runRun is `carlos run` (run spec §3).
func runRun(args []string) {
o, err := parseRunArgs(args, os.Stderr)
@@ -301,5 +525,23 @@ func runRun(args []string) {
fmt.Fprint(os.Stderr, runUsageText())
os.Exit(2)
}
- _ = o
+ sess, err := resolveMode(o.console)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if sess == nil {
+ log.Fatal(runDirectModeMsg)
+ }
+ printUsing(os.Stderr, sess)
+ acct := resolvedAccount(sess, o.acctRaw, o.acctIDRaw)
+ app := resolvedApp(sess, acct, o.app, runUsageText())
+ interrupts := make(chan os.Signal, 2)
+ signal.Notify(interrupts, os.Interrupt, syscall.SIGTERM)
+ t := runTerm{stdout: os.Stdout, stderr: os.Stderr, interrupts: interrupts, after: time.After, now: time.Now}
+ c := runAPI{s: sess, a: acct, app: app, host: o.instance}
+ ctx := context.Background()
+ if o.follow != "" {
+ os.Exit(followRun(ctx, c, t, o.instance, o.follow, time.Time{}))
+ }
+ os.Exit(startRun(ctx, c, t, o, app))
}
diff --git a/run_test.go b/run_test.go
index b6b4479a1..6fd238cbb 100644
--- a/run_test.go
+++ b/run_test.go
@@ -12,6 +12,7 @@ import (
"net/url"
"os"
"path/filepath"
+ "slices"
"strings"
"sync/atomic"
"testing"
@@ -328,3 +329,356 @@ func TestRunAPICreateRefusesFilesThatDisagreeWithMeta(t *testing.T) {
t.Fatal("create accepted more paths than meta files")
}
}
+
+// scriptedRun is a runClient that answers pages from a script.
+type scriptedRun struct {
+ pages []runs.Page
+ pageErr error
+ expireTo string
+ expireErr error
+ cancelErr error
+ createErr error
+ ackErrs []error // one per ack call, in order; nil once spent
+ acked []int64
+ cancels int
+ expires int
+ created runs.Meta
+}
+
+func (s *scriptedRun) create(_ context.Context, m runs.Meta, _ []string) (runs.Created, error) {
+ s.created = m
+ if s.createErr != nil {
+ return runs.Created{}, s.createErr
+ }
+ return runs.Created{ID: "run_a", State: "queued"}, nil
+}
+func (s *scriptedRun) page(context.Context, string, int64) (runs.Page, error) {
+ if s.pageErr != nil {
+ return runs.Page{}, s.pageErr
+ }
+ p := s.pages[0]
+ if len(s.pages) > 1 {
+ s.pages = s.pages[1:]
+ }
+ return p, nil
+}
+func (s *scriptedRun) cancel(context.Context, string) (string, error) {
+ s.cancels++
+ return "cancel", s.cancelErr
+}
+func (s *scriptedRun) expire(context.Context, string) (string, error) {
+ s.expires++
+ return s.expireTo, s.expireErr
+}
+func (s *scriptedRun) ack(_ context.Context, _ string, last int64) error {
+ s.acked = append(s.acked, last)
+ if len(s.ackErrs) == 0 {
+ return nil
+ }
+ err := s.ackErrs[0]
+ s.ackErrs = s.ackErrs[1:]
+ return err
+}
+
+type termFx struct {
+ out, err strings.Builder
+ sig chan os.Signal
+ now time.Time
+}
+
+func newTermFx() *termFx {
+ return &termFx{sig: make(chan os.Signal, 2), now: time.Unix(1_700_000_000, 0)}
+}
+
+func (f *termFx) term() runTerm {
+ return runTerm{stdout: &f.out, stderr: &f.err, interrupts: f.sig,
+ after: func(d time.Duration) <-chan time.Time {
+ f.now = f.now.Add(d)
+ c := make(chan time.Time, 1)
+ c <- f.now
+ return c
+ },
+ now: func() time.Time { return f.now }}
+}
+
+func chunk(seq int64, stream, data string) runs.Chunk {
+ return runs.Chunk{Seq: seq, Stream: stream, Data: []byte(data)}
+}
+
+func TestFollowRunInterleavesAndAcks(t *testing.T) {
+ f := newTermFx()
+ s := &scriptedRun{pages: []runs.Page{
+ {State: "picked", Chunks: []runs.Chunk{chunk(1, "o", "one\n"), chunk(2, "e", "warn\n")}},
+ {State: "picked", Chunks: []runs.Chunk{chunk(3, "o", "two\n")}, Result: &runs.Result{Exit: 0, LastSeq: 3}},
+ }}
+ if code := followRun(context.Background(), s, f.term(), "shop.example.com", "run_a", time.Time{}); code != 0 {
+ t.Fatalf("exit %d", code)
+ }
+ if f.out.String() != "one\ntwo\n" || f.err.String() != "warn\n" {
+ t.Fatalf("stdout %q stderr %q", f.out.String(), f.err.String())
+ }
+ if len(s.acked) != 1 || s.acked[0] != 3 {
+ t.Fatalf("acked %v", s.acked)
+ }
+}
+
+func TestFollowRunPagesUntilTheResult(t *testing.T) {
+ f := newTermFx()
+ res := &runs.Result{Exit: 0, LastSeq: 3}
+ s := &scriptedRun{pages: []runs.Page{
+ {State: "done", Chunks: []runs.Chunk{chunk(1, "o", "a")}, Result: res, More: true},
+ {State: "done", Chunks: []runs.Chunk{chunk(2, "o", "b")}, Result: res, More: true},
+ {State: "done", Chunks: []runs.Chunk{chunk(3, "o", "c")}, Result: res},
+ }}
+ followRun(context.Background(), s, f.term(), "shop.example.com", "run_a", time.Time{})
+ if f.out.String() != "abc" || len(s.acked) != 1 || s.acked[0] != 3 {
+ t.Fatalf("stdout %q acked %v (every page before the ack)", f.out.String(), s.acked)
+ }
+}
+
+func TestFollowRunExitCodes(t *testing.T) {
+ started := time.Unix(1_700_000_000, 0)
+ for _, c := range []struct {
+ res runs.Result
+ code int
+ says string
+ }{
+ {runs.Result{Exit: 7}, 7, ""},
+ {runs.Result{Signal: "9"}, 137, ""},
+ {runs.Result{TimedOut: true, Started: started, Ended: started.Add(30 * time.Minute)}, 124, "shop.example.com: the command timed out after 30m0s"},
+ {runs.Result{Interrupted: "the instance was restarted"}, 125, "shop.example.com: the run was interrupted: the instance was restarted. it may have done part of its work"},
+ {runs.Result{Interrupted: runs.InterruptedLost}, 125, "shop.example.com: the run was interrupted: its box stopped reporting. it may have done part of its work"},
+ {runs.Result{Cancelled: true, Started: started}, 130, "shop.example.com: the command was cancelled"},
+ {runs.Result{Refused: runs.RefusedExpired}, 3, "shop.example.com: not picked up within 60 seconds. the run did not start. try again"},
+ {runs.Result{Refused: runs.RefusedFlat}, 3, "shop.example.com: the box is not on the nested layout yet, so carlos run cannot run there"},
+ {runs.Result{Refused: runs.RefusedClaim}, 3, "shop.example.com: the box has host claims armed. carlos run cannot run there yet"},
+ {runs.Result{Refused: runs.RefusedWake, Detail: "wake failed: restore"}, 3, "shop.example.com: the instance did not wake. wake failed: restore"},
+ {runs.Result{Refused: runs.RefusedIncarnation}, 3, "shop.example.com was deleted and created again after this run was requested. the run did not start"},
+ {runs.Result{Refused: runs.RefusedImport}, 3, "shop.example.com is an import that is not committed yet. the run did not start"},
+ {runs.Result{Exit: 0, Truncated: true}, 0, "shop.example.com: the output passed 64 MB and was cut off there. the command ran on to its end"},
+ {runs.Result{Exit: 5, Truncated: true}, 5, "shop.example.com: the output passed 64 MB and was cut off there. the command ran on to its end"},
+ } {
+ code, lines := runExit("shop.example.com", c.res)
+ if code != c.code || (c.says != "" && !slices.Contains(lines, c.says)) || (c.says == "" && len(lines) != 0) {
+ t.Errorf("%+v: %d %q, want %d %q", c.res, code, lines, c.code, c.says)
+ }
+ }
+}
+
+func TestFollowRunTellsCancelledBeforeStartFromCancelledAfter(t *testing.T) {
+ f := newTermFx()
+ s := &scriptedRun{pages: []runs.Page{{State: "cancelled", Result: &runs.Result{Cancelled: true, LastSeq: 0}}}}
+ if code := followRun(context.Background(), s, f.term(), "shop.example.com", "run_a", time.Time{}); code != 130 {
+ t.Fatalf("exit %d", code)
+ }
+ if got := f.err.String(); got != "shop.example.com: run run_a was cancelled before it started\n" {
+ t.Fatalf("stderr %q", got)
+ }
+ if len(s.acked) != 1 {
+ t.Fatalf("acked %v", s.acked)
+ }
+}
+
+func TestFollowRunExpiresAQueuedRunAfterSixtySeconds(t *testing.T) {
+ f := newTermFx()
+ s := &scriptedRun{pages: []runs.Page{{State: "queued"}}, expireTo: "expired"}
+ code := followRun(context.Background(), s, f.term(), "shop.example.com", "run_a", f.now.Add(60*time.Second))
+ if code != 3 || s.expires != 1 || !strings.Contains(f.err.String(), "shop.example.com: not picked up within 60 seconds. the run did not start. try again") {
+ t.Fatalf("exit %d expires %d stderr %q", code, s.expires, f.err.String())
+ }
+ if f.now.Sub(time.Unix(1_700_000_000, 0)) < 60*time.Second {
+ t.Fatal("expired before 60 seconds")
+ }
+}
+
+func TestFollowRunKeepsFollowingWhenExpiryLoses(t *testing.T) {
+ f := newTermFx()
+ queued := runs.Page{State: "queued"}
+ pages := make([]runs.Page, 0, 62)
+ for range 61 {
+ pages = append(pages, queued)
+ }
+ pages = append(pages, runs.Page{State: "done", Result: &runs.Result{Exit: 4}})
+ s := &scriptedRun{pages: pages, expireErr: &runConsoleError{status: http.StatusConflict, msg: "run run_a has already started"}}
+ if code := followRun(context.Background(), s, f.term(), "shop.example.com", "run_a", f.now.Add(60*time.Second)); code != 4 || s.expires != 1 {
+ t.Fatalf("exit %d expires %d (a late pickup wins over the expiry)", code, s.expires)
+ }
+}
+
+func TestFollowRunAsksAgainWhenTheExpiryCallFailsToArrive(t *testing.T) {
+ f := newTermFx()
+ s := &scriptedRun{pages: []runs.Page{{State: "queued"}}, expireTo: "expired"}
+ calls := 0
+ c := &countingExpire{scriptedRun: s, fail: 2, calls: &calls}
+ if code := followRun(context.Background(), c, f.term(), "shop.example.com", "run_a", f.now.Add(time.Second)); code != 3 || calls != 3 {
+ t.Fatalf("exit %d, expire calls %d", code, calls)
+ }
+}
+
+// countingExpire fails its first `fail` expire calls as a lost connection.
+type countingExpire struct {
+ *scriptedRun
+ fail int
+ calls *int
+}
+
+func (c *countingExpire) expire(ctx context.Context, id string) (string, error) {
+ *c.calls++
+ if *c.calls <= c.fail {
+ return "", errors.New("connection reset")
+ }
+ return c.scriptedRun.expire(ctx, id)
+}
+
+func TestFollowRunStatesWithoutAResult(t *testing.T) {
+ for state, want := range map[string]struct {
+ code int
+ says string
+ }{
+ "expired": {3, "shop.example.com: not picked up within 60 seconds. the run did not start. try again"},
+ "cancelled": {130, "shop.example.com: run run_a was cancelled before it started"},
+ } {
+ f := newTermFx()
+ s := &scriptedRun{pages: []runs.Page{{State: state}}}
+ if code := followRun(context.Background(), s, f.term(), "shop.example.com", "run_a", time.Time{}); code != want.code || !strings.Contains(f.err.String(), want.says) {
+ t.Errorf("%s: exit %d stderr %q", state, code, f.err.String())
+ }
+ }
+}
+
+func TestFollowRunPageErrors(t *testing.T) {
+ for name, c := range map[string]struct {
+ err error
+ code int
+ says string
+ }{
+ "no run": {&runConsoleError{status: http.StatusNotFound, msg: "shop.example.com has no run run_a"}, 2, "shop.example.com has no run run_a, or this console has no carlos run yet"},
+ "console 4xx": {&runConsoleError{status: http.StatusConflict, msg: "carlos run is not available here"}, 2, "carlos run: carlos run is not available here"},
+ "network": {errors.New("connection refused"), 1, "carlos run: connection refused"},
+ } {
+ f := newTermFx()
+ s := &scriptedRun{pageErr: c.err}
+ if code := followRun(context.Background(), s, f.term(), "shop.example.com", "run_a", time.Time{}); code != c.code || f.err.String() != c.says+"\n" {
+ t.Errorf("%s: exit %d stderr %q", name, code, f.err.String())
+ }
+ }
+}
+
+func TestFollowRunAck(t *testing.T) {
+ notDone := &runConsoleError{status: http.StatusConflict, msg: "run run_a has not finished yet"}
+ gone := &runConsoleError{status: http.StatusNotFound, msg: "shop.example.com has no run run_a"}
+ page := runs.Page{State: "done", Result: &runs.Result{Exit: 6, LastSeq: 2}}
+ t.Run("a lost 200 retried finds the objects gone and is success", func(t *testing.T) {
+ f := newTermFx()
+ s := &scriptedRun{pages: []runs.Page{page}, ackErrs: []error{gone}}
+ if code := followRun(context.Background(), s, f.term(), "shop.example.com", "run_a", time.Time{}); code != 6 || f.err.String() != "" || len(s.acked) != 1 {
+ t.Fatalf("exit %d stderr %q acked %v", code, f.err.String(), s.acked)
+ }
+ })
+ t.Run("an ack before the box's done is retried", func(t *testing.T) {
+ f := newTermFx()
+ s := &scriptedRun{pages: []runs.Page{page}, ackErrs: []error{notDone, notDone}}
+ if code := followRun(context.Background(), s, f.term(), "shop.example.com", "run_a", time.Time{}); code != 6 || f.err.String() != "" || len(s.acked) != 3 {
+ t.Fatalf("exit %d stderr %q acked %v", code, f.err.String(), s.acked)
+ }
+ })
+ t.Run("a box that never finishes is given up on, the exit code stands", func(t *testing.T) {
+ f := newTermFx()
+ errs := make([]error, 100)
+ for i := range errs {
+ errs[i] = notDone
+ }
+ s := &scriptedRun{pages: []runs.Page{page}, ackErrs: errs}
+ code := followRun(context.Background(), s, f.term(), "shop.example.com", "run_a", time.Time{})
+ if code != 6 || len(s.acked) < 2 || len(s.acked) > 20 || !strings.Contains(f.err.String(), "carlos run: run run_a has not finished yet") {
+ t.Fatalf("exit %d acked %d stderr %q", code, len(s.acked), f.err.String())
+ }
+ })
+ t.Run("a refused ack is said and the exit code stands", func(t *testing.T) {
+ f := newTermFx()
+ s := &scriptedRun{pages: []runs.Page{page}, ackErrs: []error{&runConsoleError{status: http.StatusConflict, msg: "run run_a ended at chunk 2, not 3"}}}
+ if code := followRun(context.Background(), s, f.term(), "shop.example.com", "run_a", time.Time{}); code != 6 || len(s.acked) != 1 || f.err.String() != "carlos run: run run_a ended at chunk 2, not 3\n" {
+ t.Fatalf("exit %d stderr %q acked %v", code, f.err.String(), s.acked)
+ }
+ })
+}
+
+func TestFollowRunInterrupts(t *testing.T) {
+ queued := runs.Page{State: "picked"}
+ done := runs.Page{State: "done", Result: &runs.Result{Cancelled: true, Started: time.Unix(1_700_000_000, 0)}}
+ t.Run("the first cancels and keeps following", func(t *testing.T) {
+ f := newTermFx()
+ f.sig <- os.Interrupt
+ s := &scriptedRun{pages: []runs.Page{queued, done}}
+ if code := followRun(context.Background(), s, f.term(), "shop.example.com", "run_a", time.Time{}); code != 130 || s.cancels != 1 {
+ t.Fatalf("exit %d cancels %d", code, s.cancels)
+ }
+ if got := f.err.String(); got != "cancelling run run_a. press Ctrl-C again to stop waiting\nshop.example.com: the command was cancelled\n" {
+ t.Fatalf("stderr %q", got)
+ }
+ })
+ t.Run("the second stops waiting", func(t *testing.T) {
+ f := newTermFx()
+ f.sig <- os.Interrupt
+ f.sig <- os.Interrupt
+ s := &scriptedRun{pages: []runs.Page{queued}}
+ if code := followRun(context.Background(), s, f.term(), "shop.example.com", "run_a", time.Time{}); code != 130 || s.cancels != 1 || len(s.acked) != 0 {
+ t.Fatalf("exit %d cancels %d acked %v", code, s.cancels, s.acked)
+ }
+ })
+ t.Run("a cancel retried after a lost 200 is success", func(t *testing.T) {
+ f := newTermFx()
+ f.sig <- os.Interrupt
+ s := &scriptedRun{pages: []runs.Page{queued, done}, cancelErr: &runConsoleError{status: http.StatusConflict, msg: "run run_a has already ended"}}
+ if code := followRun(context.Background(), s, f.term(), "shop.example.com", "run_a", time.Time{}); code != 130 {
+ t.Fatalf("exit %d", code)
+ }
+ if got := f.err.String(); strings.Contains(got, "already ended") {
+ t.Fatalf("stderr %q", got)
+ }
+ })
+ t.Run("a cancel that failed is said", func(t *testing.T) {
+ f := newTermFx()
+ f.sig <- os.Interrupt
+ s := &scriptedRun{pages: []runs.Page{queued, done}, cancelErr: errors.New("connection reset")}
+ followRun(context.Background(), s, f.term(), "shop.example.com", "run_a", time.Time{})
+ if !strings.Contains(f.err.String(), "carlos run: connection reset") {
+ t.Fatalf("stderr %q", f.err.String())
+ }
+ })
+}
+
+func TestStartRunPrintsTheFollowCommand(t *testing.T) {
+ f := newTermFx()
+ s := &scriptedRun{pages: []runs.Page{{State: "done", Result: &runs.Result{Exit: 0}}}}
+ o := runOpts{instance: "shop.example.com", argv: []string{"import"}, timeout: time.Minute}
+ if code := startRun(context.Background(), s, f.term(), o, "shop"); code != 0 {
+ t.Fatalf("exit %d", code)
+ }
+ if !strings.Contains(f.err.String(), "run run_a on shop.example.com. to follow it again: carlos run --app shop --instance shop.example.com --follow run_a") {
+ t.Fatalf("stderr %q", f.err.String())
+ }
+ if s.created.TimeoutS != 60 {
+ t.Fatalf("meta %+v", s.created)
+ }
+}
+
+func TestStartRunFailures(t *testing.T) {
+ missing := filepath.Join(t.TempDir(), "absent.csv")
+ for name, c := range map[string]struct {
+ o runOpts
+ createErr error
+ code int
+ says string
+ }{
+ "a file that cannot be read": {runOpts{instance: "shop.example.com", argv: []string{"x"}, timeout: time.Minute, files: []string{missing}}, nil, 2, "could not read " + missing},
+ "a console refusal": {runOpts{instance: "shop.example.com", argv: []string{"x"}, timeout: time.Minute}, &runConsoleError{status: http.StatusConflict, msg: "an instance can run one command at a time"}, 2, "carlos run: an instance can run one command at a time"},
+ "a lost connection": {runOpts{instance: "shop.example.com", argv: []string{"x"}, timeout: time.Minute}, errors.New("connection refused"), 1, "carlos run: connection refused"},
+ } {
+ f := newTermFx()
+ s := &scriptedRun{createErr: c.createErr}
+ if code := startRun(context.Background(), s, f.term(), c.o, "shop"); code != c.code || !strings.Contains(f.err.String(), c.says) || strings.Contains(f.err.String(), "to follow it again") {
+ t.Errorf("%s: exit %d stderr %q", name, code, f.err.String())
+ }
+ }
+}