carlos move: server text by type, --no-wait reads nothing after acceptance, no guessed regions (Task 1a fix round 2)
1. Copy-gate provenance by type. Text the console wrote is serverText, made only in mover.roundTrip from the body of a response the HTTP client returned, and said only through saySpoken: the refusal's sentence and the stuck reason. Everything else the CLI says is this file's own copy through moveSay; Go's errors and resolveMode's sentences (in no copy record) are never shown (MV-N365 to MV-N371 added pending). The gate's name table and its external() trust are gone; movesServerText and TestMovesServerTextHasOneMaker hold the type to one maker and one sink. A sentence field read whole at a sink now emits the sentences written to it. Fixture testdata/moves-copy/sinks/provenance: apiErrorFromBody fed literal JSON, a forged StuckReason, a reassignment inside sayRecorded, a forged serverText and a leaked one each fail the gate. 2. An accepted mutation with --no-wait says the answer's state and exits 0 with no further read. 3. A region the wait never learnt is not filled in from the record: M-C9 alone when the destination is unknown, the outcome line when the region a sentence names is. B8 census: moveMain's digest (the session refusal).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
8 files changed,
+524
−349
approved_moves_copy_scan_test.go+109 −116approved_moves_copy_test.go+49 −53docs/superpowers/plans/2026-10-06-moves-phase1-copy.md+8 −1internal/provision/plan9_b8_census_test.go+1 −1move.go+202 −117move_test.go+105 −3testdata/moves-copy/sinks/forwarded-local-error/emit.go+0 −58testdata/moves-copy/sinks/provenance/emit.go+50 −0
diff --git a/approved_moves_copy_scan_test.go b/approved_moves_copy_scan_test.go| index 7f0199c7b..b3d8149cf 100644 |
| --- a/approved_moves_copy_scan_test.go |
| +++ b/approved_moves_copy_scan_test.go |
| @@ -1134,6 +1134,28 @@ func (c *evalCtx) evalIdent(id *ast.Ident, depth int) ([]string, []problem) { |
| // any struct of the scanned files (a keyed or positional composite literal, or |
| // an assignment), is. The text of each write is checked where it is written, |
| // so the read is forwarded. |
| +// fieldWriteAlts is the sentences written to the field e reads, in the struct |
| +// type it reads (every write, when the type is unknown). A write that is |
| +// itself forwarded keeps its mark: it is checked where it is made. |
| +func (c *evalCtx) fieldWriteAlts(e *ast.SelectorExpr) []string { |
| + f := e.Sel.Name |
| + rt := lastName(baseTypeName(c.typeExprOf(e.X, 0))) |
| + var alts []string |
| + for _, w := range c.u.fieldWrites[f] { |
| + wc := c.u.newCtx(w.file, w.fd, c.noCalls) |
| + wt := lastName(w.ctype) |
| + if w.recv != nil { |
| + wt = lastName(baseTypeName(wc.typeExprOf(w.recv, 0))) |
| + } |
| + if rt != "" && wt != "" && wt != rt { |
| + continue |
| + } |
| + x, _ := wc.eval(w.expr, 0) |
| + alts = append(alts, x...) |
| + } |
| + return dedupe(alts) |
| +} |
| + |
| func (c *evalCtx) fieldRead(e *ast.SelectorExpr) ([]string, []problem) { |
| f := e.Sel.Name |
| types := c.u.fieldTypes[f] |
| @@ -1826,13 +1848,14 @@ func scanMovesFileIn(t *testing.T, u *movesUniverse, root, file string, noCalls |
| emit := func(pos token.Pos, kind string, e ast.Expr, fragment bool) { |
| line := fset.Position(pos).Line |
| end := fset.Position(e.End()).Line |
| - // Text from outside the file (movesExternalText), or a local sink's |
| - // own forwarded parameter, is no sentence of this file here: the |
| - // parameter is checked where the sink is called. |
| - if ctx.external(e, 0) { |
| - return |
| - } |
| alts, probs := ctx.eval(e, 0) |
| + // A sentence field read whole at a sink speaks what was written to it: |
| + // each write's sentence needs its row here (fix round 2 of Plan 6 |
| + // Task 1a: a StuckReason written with a literal was otherwise passed |
| + // as checked where it is made, which only checked it was a sentence). |
| + if sel, ok := e.(*ast.SelectorExpr); ok && movesSentenceFields[sel.Sel.Name] && len(probs) == 0 { |
| + alts = ctx.fieldWriteAlts(sel) |
| + } |
| fromParam := false |
| if ctx.fd != nil { |
| params := map[string]bool{} |
| @@ -2007,126 +2030,96 @@ func scanMovesFileIn(t *testing.T, u *movesUniverse, root, file string, noCalls |
| return out, vios |
| } |
| -// external reports whether e is text from outside the file, by a name in |
| -// movesExternalText (a call's result, or a field of a decoded answer, read |
| -// through .Error() and parentheses), or a call to a package helper whose |
| -// result is external at every return, or a local whose every assignment is |
| -// (or the zero value, or nil), or the local sink's own forwarded parameter, |
| -// which is checked where the sink is called. Anything else is read as usual. |
| -func (c *evalCtx) external(e ast.Expr, depth int) bool { |
| - if depth > maxEvalDepth { |
| - return false |
| +// movesServerTextSpec names a file's server text: the type that carries text |
| +// the console wrote, its one field, the one function that makes it (Maker, |
| +// which must read the body of a response the HTTP client returned), and the |
| +// one sink that says it (Sink, which takes the type). Nothing else in the |
| +// package may make the type, write its field or read it. |
| +type movesServerTextSpec struct{ Type, Field, Maker, Sink string } |
| + |
| +type serverTextProblem struct{ fn, msg string } |
| + |
| +func (p serverTextProblem) String() string { return p.fn + ": " + p.msg } |
| + |
| +// checkServerText holds a server text type to its spec, across every non-test |
| +// Go file of file's package directory. |
| +func checkServerText(t *testing.T, file string, spec movesServerTextSpec) []serverTextProblem { |
| + t.Helper() |
| + dir := filepath.Dir(file) |
| + names, err := filepath.Glob(filepath.Join(dir, "*.go")) |
| + if err != nil { |
| + t.Fatal(err) |
| } |
| - ext := movesExternalText[c.file] |
| - named := func(n string) bool { _, ok := ext[n]; return ok } |
| - switch e := e.(type) { |
| - case *ast.ParenExpr: |
| - return c.external(e.X, depth+1) |
| - case *ast.CallExpr: |
| - if f, ok := e.Fun.(*ast.SelectorExpr); ok && f.Sel.Name == "Error" && len(e.Args) == 0 { |
| - return c.external(f.X, depth+1) |
| - } |
| - return c.externalCall(e, 0, depth+1) |
| - case *ast.SelectorExpr: |
| - return named(e.Sel.Name) |
| - case *ast.Ident: |
| - if e.Name == "nil" || c.fwd[e.Name] { |
| - return true |
| + var out []serverTextProblem |
| + maker, sink := false, false |
| + for _, name := range names { |
| + if strings.HasSuffix(name, "_test.go") { |
| + continue |
| } |
| - as := c.locals[e.Name] |
| - if len(as) == 0 { |
| - return false |
| + f, err := parser.ParseFile(token.NewFileSet(), name, nil, 0) |
| + if err != nil { |
| + t.Fatal(err) |
| } |
| - for _, a := range as { |
| - switch { |
| - case a.zero: |
| - case a.rhs != nil: |
| - if !c.external(a.rhs, depth+1) { |
| - return false |
| + for _, d := range f.Decls { |
| + fd, ok := d.(*ast.FuncDecl) |
| + if !ok || fd.Body == nil { |
| + continue |
| + } |
| + fn := fd.Name.Name |
| + if fd.Recv != nil { |
| + fn = recvBase(fd) + "." + fn |
| + } |
| + writes := map[ast.Node]bool{} |
| + readsBody, callsDo := false, false |
| + ast.Inspect(fd.Body, func(n ast.Node) bool { |
| + switch n := n.(type) { |
| + case *ast.AssignStmt: |
| + for _, l := range n.Lhs { |
| + if s, ok := l.(*ast.SelectorExpr); ok && s.Sel.Name == spec.Field { |
| + writes[s] = true |
| + if fn != spec.Maker { |
| + out = append(out, serverTextProblem{fd.Name.Name, fmt.Sprintf("writes %s.%s outside %s", spec.Type, spec.Field, spec.Maker)}) |
| + } |
| + } |
| + } |
| + case *ast.CompositeLit: |
| + if id, ok := n.Type.(*ast.Ident); ok && id.Name == spec.Type && fn != spec.Maker { |
| + out = append(out, serverTextProblem{fd.Name.Name, fmt.Sprintf("makes a %s outside %s", spec.Type, spec.Maker)}) |
| + } |
| + case *ast.SelectorExpr: |
| + switch { |
| + case n.Sel.Name == "Body": |
| + readsBody = true |
| + case n.Sel.Name == "Do": |
| + callsDo = true |
| + case n.Sel.Name == spec.Field && !writes[n] && fn != spec.Maker && fn != spec.Sink: |
| + out = append(out, serverTextProblem{fd.Name.Name, fmt.Sprintf("reads %s.%s outside %s", spec.Type, spec.Field, spec.Sink)}) |
| + } |
| } |
| - case a.call != nil: |
| - if !c.externalCall(a.call, a.pos, depth+1) { |
| - return false |
| + return true |
| + }) |
| + if fn == spec.Maker { |
| + maker = true |
| + if !readsBody || !callsDo { |
| + out = append(out, serverTextProblem{fn, "makes server text but reads no body of a response the HTTP client returned"}) |
| + } |
| + } |
| + if fn == spec.Sink { |
| + for _, fld := range fd.Type.Params.List { |
| + if id, ok := fld.Type.(*ast.Ident); ok && id.Name == spec.Type { |
| + sink = true |
| + } |
| } |
| - default: |
| - return false |
| } |
| } |
| - return true |
| - } |
| - return false |
| -} |
| - |
| -// externalCall reports whether result pos of call is external: the callee is |
| -// named in movesExternalText, or it is a package helper each of whose returns |
| -// gives an external value at pos (read in the helper's own context). |
| -func (c *evalCtx) externalCall(call *ast.CallExpr, pos, depth int) bool { |
| - if depth > maxEvalDepth { |
| - return false |
| - } |
| - name := "" |
| - switch f := call.Fun.(type) { |
| - case *ast.SelectorExpr: |
| - name = f.Sel.Name |
| - case *ast.Ident: |
| - name = f.Name |
| - } |
| - if _, ok := movesExternalText[c.file][name]; ok { |
| - return true |
| } |
| - decls, p := c.resolveCallees(call) |
| - if p != nil || len(decls) == 0 { |
| - return false |
| + if !maker && !strings.Contains(dir, "testdata") { |
| + out = append(out, serverTextProblem{spec.Maker, "the maker is not declared"}) |
| } |
| - for _, d := range decls { |
| - if d.Body == nil { |
| - return false |
| - } |
| - key := fmt.Sprintf("external %p %d", d, pos) |
| - if c.visiting[key] { |
| - return false |
| - } |
| - c.visiting[key] = true |
| - hc := c.u.newCtx(strings.TrimPrefix(movesFset.Position(d.Pos()).Filename, c.u.root+"/"), d, c.noCalls) |
| - hc.visiting = c.visiting |
| - ok := hc.returnsExternal(d, pos, depth+1) |
| - delete(c.visiting, key) |
| - if !ok { |
| - return false |
| - } |
| + if !sink { |
| + out = append(out, serverTextProblem{spec.Sink, "the sink does not take " + spec.Type}) |
| } |
| - return true |
| -} |
| - |
| -// returnsExternal reports whether every return of d gives an external value at |
| -// pos (c is d's own context). |
| -func (c *evalCtx) returnsExternal(d *ast.FuncDecl, pos, depth int) bool { |
| - nres := resultCount(d) |
| - named := namedResults(d) |
| - ok := true |
| - ast.Inspect(d.Body, func(n ast.Node) bool { |
| - if !ok { |
| - return false |
| - } |
| - switch n := n.(type) { |
| - case *ast.FuncLit: |
| - return false |
| - case *ast.ReturnStmt: |
| - switch { |
| - case len(n.Results) == nres && pos < len(n.Results): |
| - ok = c.external(n.Results[pos], depth) |
| - case len(n.Results) == 1 && nres > 1: |
| - cl, isCall := n.Results[0].(*ast.CallExpr) |
| - ok = isCall && c.externalCall(cl, pos, depth) |
| - case len(n.Results) == 0 && pos < len(named): |
| - ok = c.external(&ast.Ident{Name: named[pos], NamePos: n.Pos()}, depth) |
| - default: |
| - ok = false |
| - } |
| - } |
| - return true |
| - }) |
| - return ok |
| + return out |
| } |
| // checkFuncArg checks the function a call hands to a sentence-function |
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go| index 3f7f4a1f7..8cad37a64 100644 |
| --- a/approved_moves_copy_test.go |
| +++ b/approved_moves_copy_test.go |
| @@ -78,6 +78,7 @@ var movesCopyIDs = []string{ |
| "MV-N319", |
| "MV-N350", "MV-N351", "MV-N352", "MV-N353", "MV-N354", "MV-N355", "MV-N356", |
| "MV-N357", "MV-N358", "MV-N359", "MV-N360", "MV-N361", "MV-N362", "MV-N363", "MV-N364", |
| + "MV-N365", "MV-N366", "MV-N367", "MV-N368", "MV-N369", "MV-N370", "MV-N371", |
| } |
| // copyRow is one row of the record. The File cell is the file the sentence is |
| @@ -738,9 +739,9 @@ var movesLocalSinks = map[string]map[string]int{ |
| // Every operator sentence of `carlos move` goes through moveSay, L13's |
| // say (named so because the scan resolves helpers by name, and |
| // internal/release has a say of its own). |
| - // sayError and sayRecorded forward text this file does not write; their |
| - // arguments are traced where they are called (movesExternalText). |
| - "move.go": {"moveSay": 1, "sayError": 1, "sayRecorded": 1}, |
| + // Text the console wrote goes through saySpoken instead, which is no |
| + // sink here: it takes only serverText (movesServerText). |
| + "move.go": {"moveSay": 1}, |
| } |
| var movesCopyTables = map[string][]string{ |
| @@ -825,24 +826,14 @@ var movesSinkAllow = map[string]map[string]string{ |
| }, |
| } |
| -// movesExternalText names, per file, what a forwarding sink may pass on |
| -// without a row: text whose provenance is the console's answer, or an error |
| -// that crossed the API, never a sentence the file makes itself. A name is a |
| -// called function or method (its result, error or text) or a field read from |
| -// a decoded answer. Each entry says why. A local sink's argument is traced to |
| -// these names where the sink is called; a locally made error |
| -// (errors.New, fmt.Errorf) is a sentence and needs its row. |
| -var movesExternalText = map[string]map[string]string{ |
| - "move.go": { |
| - "apiErrorFromBody": "the console's answer: its own JSON error sentence, recorded with the door that speaks it (M-C13, M-C17, MV-N1 and the rest), or apimode.go's reading of a body with none", |
| - "getJSON": "an error that crossed the API on a read: net/http's transport error, or apimode.go's console-moved explanation", |
| - "postJSON": "an error that crossed the API on a mutation, as getJSON's", |
| - "deleteJSON": "an error that crossed the API on an abort, as getJSON's", |
| - "Unmarshal": "encoding/json's error decoding the console's answer", |
| - "sleep": "mover.sleep's only error is ctx.Err(): the context's own cancellation, Go's text", |
| - "session": "resolveMode's refusal (moveDeps.session): apimode.go's own sentences, which every API verb says", |
| - "StuckReason": "the stuck reason the console's detail answer carries from the record: a sentence a provision transition recorded (MV-N300 to MV-N312 and the rows before them)", |
| - }, |
| +// movesServerText names, per file, the type that carries text the console |
| +// wrote (fix round 2 of Plan 6 Task 1a, ruling 1). It is made in one function |
| +// that reads an HTTP response body, and said through one sink; the gate |
| +// exempts that sink by its type (the scan never sees it: it is no local |
| +// sink), and TestMovesServerTextHasOneMaker holds the rest. Every other value |
| +// at a sink is traced to a copy row. |
| +var movesServerText = map[string]movesServerTextSpec{ |
| + "move.go": {Type: "serverText", Field: "spoken", Maker: "mover.roundTrip", Sink: "saySpoken"}, |
| } |
| // movesMappingHelpers are functions that map a refusal from another package |
| @@ -1696,46 +1687,51 @@ func checkFuncValueAllow(allow map[string]map[string]string, refs []funcRef) []s |
| return out |
| } |
| -// Fix round 1 of Plan 6 Task 1a, ruling 2: a forwarding sink's argument is |
| -// traced where the sink is called. Text whose provenance is the console's |
| -// answer or an error that crossed the API is exempt by name |
| -// (movesExternalText); a locally made error is a sentence that needs its row |
| -// (sayError(out, errors.New("unreviewed copy")) fails the gate); an error the |
| -// gate cannot trace is a violation. |
| -func TestMovesForwardedErrorsAreTraced(t *testing.T) { |
| - file := filepath.Join("testdata", "moves-copy", "sinks", "forwarded-local-error", "emit.go") |
| - savedSinks, savedExt := movesLocalSinks[file], movesExternalText[file] |
| - movesLocalSinks[file] = map[string]int{"moveSay": 1, "sayError": 1} |
| - movesExternalText[file] = map[string]string{"apiErrorFromBody": "the console's answer", "getJSON": "an error that crossed the API"} |
| +// Fix round 2 of Plan 6 Task 1a, ruling 1: text the console wrote reaches an |
| +// operator only as serverText, made in one function that reads an HTTP |
| +// response body and said only through its one sink. Every other value at a |
| +// sink is traced to a copy row. The re-review's three ways round that, and a |
| +// forged serverText, each fail the gate. |
| +func TestMovesProvenanceBypassesFail(t *testing.T) { |
| + file := filepath.Join("testdata", "moves-copy", "sinks", "provenance", "emit.go") |
| + saved := movesLocalSinks[file] |
| + movesLocalSinks[file] = map[string]int{"moveSay": 1, "sayError": 1, "sayRecorded": 1} |
| defer func() { |
| - movesLocalSinks[file], movesExternalText[file] = savedSinks, savedExt |
| - if savedSinks == nil { |
| + if saved == nil { |
| delete(movesLocalSinks, file) |
| - } |
| - if savedExt == nil { |
| - delete(movesExternalText, file) |
| + } else { |
| + movesLocalSinks[file] = saved |
| } |
| }() |
| u := newUniverse(t, ".", []string{file}) |
| ems, vios := scanMovesFileIn(t, u, ".", file, false) |
| - var unrecorded []string |
| + failed := map[string]bool{} |
| for _, e := range findUnrecorded(ems, nil) { |
| - unrecorded = append(unrecorded, e.fn+": "+strings.ReplaceAll(e.pattern, wild, "%")) |
| - } |
| - sort.Strings(unrecorded) |
| - // The two locally made errors are sentences with no row. The untraced |
| - // error, and a helper's own error, are values the gate cannot derive (and |
| - // violations, below). A helper that passes on getJSON's error (viaFetch) |
| - // is external. |
| - want := []string{"formatted: % boxes are unreviewed", "local: unreviewed copy", "untraced: ", "viaHelper: "} |
| - if strings.Join(unrecorded, "|") != strings.Join(want, "|") { |
| - t.Errorf("unrecorded = %q, want %q", unrecorded, want) |
| - } |
| - var where []string |
| + failed[e.fn] = true |
| + } |
| for _, v := range vios { |
| - where = append(where, v.fn) |
| + failed[v.fn] = true |
| + } |
| + spec := movesServerTextSpec{Type: "serverText", Field: "spoken", Maker: "roundTrip", Sink: "saySpoken"} |
| + for _, p := range checkServerText(t, file, spec) { |
| + failed[p.fn] = true |
| + } |
| + for _, fn := range []string{"literalJSON", "forgedStuck", "sayRecorded", "forgedServer", "leaked"} { |
| + if !failed[fn] { |
| + t.Errorf("%s gets round the gate", fn) |
| + } |
| + } |
| +} |
| + |
| +// move.go's serverText has one maker, roundTrip, which reads the body of a |
| +// response it got from the HTTP client, and one sink, saySpoken. |
| +func TestMovesServerTextHasOneMaker(t *testing.T) { |
| + for file, spec := range movesServerText { |
| + for _, p := range checkServerText(t, file, spec) { |
| + t.Errorf("%s: %s", file, p) |
| + } |
| } |
| - if strings.Join(where, ",") != "untraced,viaHelper" { |
| - t.Errorf("violations in %v, want untraced and viaHelper: %v", where, vios) |
| + if _, ok := movesServerText["move.go"]; !ok { |
| + t.Fatal("move.go declares no serverText") |
| } |
| } |
diff --git a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md| index 6ff1be194..fb8dd90f2 100644 |
| --- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| +++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| @@ -284,7 +284,7 @@ Columns: id, file, text, status. |
| ## Plan 6 |
| -(completed by Plan 6 Task 7. Task 2 takes MV-N319 to MV-N324, and made the abort door speak M-C13 on a console without the host claim, so M-C13 is now approved verbatim. Task 2b made the delete door speak M-C19 through moveDeleteRefusal in internal/console/apiinstancemove.go, so its File cell moves there (preflight L6) and it is approved verbatim. Task 1a takes MV-N350 to MV-N364 (Plan 5 owns MV-N325 to MV-N349) for `carlos move` (move.go, and its usage line in main.go), and speaks M-C1 to M-C12 and M-C22 verbatim from move.go, so those rows are approved) |
| +(completed by Plan 6 Task 7. Task 2 takes MV-N319 to MV-N324, and made the abort door speak M-C13 on a console without the host claim, so M-C13 is now approved verbatim. Task 2b made the delete door speak M-C19 through moveDeleteRefusal in internal/console/apiinstancemove.go, so its File cell moves there (preflight L6) and it is approved verbatim. Task 1a takes MV-N350 to MV-N371 (Plan 5 owns MV-N325 to MV-N349) for `carlos move` (move.go, and its usage line in main.go), and speaks M-C1 to M-C12 and M-C22 verbatim from move.go, so those rows are approved) |
| | id | file | text | status | |
| |---|---|---|---| |
| @@ -304,6 +304,13 @@ Columns: id, file, text, status. |
| | MV-N362 | move.go | <host> is relocating after its box was terminated. This move will not finish. | pending | |
| | MV-N363 | main.go | move an instance to another region: status | cut | abort | resolve --retry (operators; off unless the console enables it) | pending | |
| | MV-N364 | move.go (M-C10's <box> when the claim names none) | the target box | pending | |
| +| MV-N365 | move.go | Could not reach the console. Check the connection and try again. | pending | |
| +| MV-N366 | move.go | The console's answer could not be read. | pending | |
| +| MV-N367 | move.go | Not found. Check --account and --app, and that you are a member of the instance's account. | pending | |
| +| MV-N368 | move.go | The console answered <status> and gave no reason. | pending | |
| +| MV-N369 | move.go | carlos move could not sign in to a console. Run carlos auth login, or check --console, CARLOS_CONSOLE and CARLOS_SERVICE_TOKEN. | pending | |
| +| MV-N370 | move.go | This console has moved to <url>. Run carlos auth login --console <url>, then run the command again. | pending | |
| +| MV-N371 | move.go | Stopped waiting. | pending | |
| ## Review of 2026-10-09 |
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go| index 28e1059c2..232730442 100644 |
| --- a/internal/provision/plan9_b8_census_test.go |
| +++ b/internal/provision/plan9_b8_census_test.go |
| @@ -564,7 +564,7 @@ var plan9B8CensusInventory = []censusEntry{ |
| {"main.go", "main", "key-site", "lit:instances,lit:sidecars", "3eb1b1a9b8a7fd9cc2820b5a4348046b4f6bac3496d5ac6c4e2c32722233bd15"}, |
| // Plan 6 Task 1a: `carlos move` builds the console's move door path |
| // (/api/cli/apps/.../instances/<host>/move); it writes no record. |
| - {"move.go", "moveMain", "key-site", "lit:instances", "b41a91fb74208d9f9a08ad797bcb483162eb19f79d176bd4acd52de76805dcd8"}, |
| + {"move.go", "moveMain", "key-site", "lit:instances", "d6c44f466f50f7454703d410bcefa624d932c2f58d430980ef2f08a426e0a029"}, |
| {"schedule.go", "renderScheduleList", "key-site", "lit:instances", "af6d666083fbbc753eecc98809d3d307344cc15110362a7e93afab573749ea6e"}, |
| {"internal/activator/layout.go", "LayoutInputs.sockets", "key-site", "lit:instances", "1d11ac5ac5be6a42e676e064771141fe8f4367c78cbb7e5c5cca072f40c27c42"}, |
| {"internal/activator/paths.go", "ExecBackend.pathsFor", "key-site", "lit:instances", "cbc01865e27934cb38baab9329a0867b899149531b4706a533a6956afc7c71b3"}, |
diff --git a/move.go b/move.go| index b9898baef..cb3db48ab 100644 |
| --- a/move.go |
| +++ b/move.go |
| @@ -1,6 +1,7 @@ |
| package main |
| import ( |
| + "bytes" |
| "context" |
| "encoding/json" |
| "errors" |
| @@ -29,18 +30,19 @@ import ( |
| // ends the command: the doors' rate limit answers before any handler, so the |
| // same call is sent again. The outcome is read only from `last`, bound to the |
| // id the console gave the move (L2, Review Focus 1), and its regions are that |
| -// move's own, bound from its own answers. Exit codes (L24): 0 done, or a |
| -// request accepted with --no-wait; 3 rolled back or aborted; 4 stuck, |
| -// rolling back past its release deadline, relocating, ready for a manual |
| -// cut, or an outcome not recorded; 5 refused (by the console, or before |
| -// anything was sent); 1 a transport error or a 5xx; 2 a usage error. |
| +// move's own, bound from its own answers; a region the wait never learnt is |
| +// never guessed. Exit codes (L24): 0 done, or a command accepted with |
| +// --no-wait (which reads nothing after the acceptance); 3 rolled back or |
| +// aborted; 4 stuck, rolling back past its release deadline, relocating, |
| +// ready for a manual cut, or an outcome not recorded; 5 refused (by the |
| +// console, or before anything was sent); 1 the console not reached, its |
| +// answer unreadable, or a 5xx; 2 a usage error. |
| // |
| -// Every operator sentence goes through moveSay, the copy gate's sink for this |
| -// file (movesLocalSinks). Text this file does not write goes through the two |
| -// forwarding sinks, sayError and sayRecorded, whose arguments the gate traces |
| -// where they are called, to the console's answer or an error that crossed the |
| -// API (movesExternalText). The flag help is said by moveFlagHelp |
| -// (movesSinkAllow). |
| +// What the operator reads is this file's own copy, through moveSay (the copy |
| +// gate's sink, movesLocalSinks), or text the console wrote, as serverText |
| +// through saySpoken. serverText is made only in roundTrip, from the body of a |
| +// response the HTTP client returned (movesServerText). Go's own errors are |
| +// never shown. The flag help is said by moveFlagHelp (movesSinkAllow). |
| const ( |
| moveExitDone = 0 |
| @@ -52,7 +54,7 @@ const ( |
| moveProtocolMark = "phase1" |
| ) |
| -// The sentences this file adds (copy record MV-N350 to MV-N364, pending). |
| +// The sentences this file adds (copy record MV-N350 to MV-N371, pending). |
| const ( |
| moveNotEnabled = "Moves are not enabled on this console." |
| moveNoApp = "carlos move needs --app: name the app the instance belongs to." |
| @@ -63,25 +65,27 @@ const ( |
| moveNeedsHost = "carlos move needs --host <host>." |
| moveNeedsTo = "carlos move needs --to <region>." |
| moveNeedsRetry = "carlos move resolve needs --retry." |
| + moveUnreachable = "Could not reach the console. Check the connection and try again." |
| + moveUnreadable = "The console's answer could not be read." |
| + moveNotFound = "Not found. Check --account and --app, and that you are a member of the instance's account." |
| + moveNoSession = "carlos move could not sign in to a console. Run carlos auth login, or check --console, CARLOS_CONSOLE and CARLOS_SERVICE_TOKEN." |
| + moveStopped = "Stopped waiting." |
| ) |
| func moveSay(out io.Writer, line string) { |
| fmt.Fprintln(out, line) |
| } |
| -// sayError passes on an error this file did not write: the console's answer |
| -// (apiErrorFromBody) or an error from a call across the API. The copy gate |
| -// traces each call's argument to one of those (movesExternalText); a locally |
| -// made error is a sentence and needs its row. |
| -func sayError(out io.Writer, err error) { |
| - moveSay(out, err.Error()) |
| -} |
| +// serverText is text the console wrote: its refusal sentence, or the stuck |
| +// reason its detail answer carries from the record (a sentence a provision |
| +// transition recorded). It is made only in roundTrip, from the body of a |
| +// response the HTTP client returned, and said only through saySpoken. The |
| +// copy gate holds both (TestMovesServerTextHasOneMaker). |
| +type serverText struct{ spoken string } |
| -// sayRecorded passes on a sentence the console's answer carries from the |
| -// record: the stuck reason a provision transition recorded (after M-C11, on |
| -// its own line, preflight L28). |
| -func sayRecorded(out io.Writer, text string) { |
| - moveSay(out, text) |
| +// saySpoken says text the console wrote, as it wrote it. |
| +func saySpoken(out io.Writer, t serverText) { |
| + fmt.Fprintln(out, t.spoken) |
| } |
| func runMove(args []string) { |
| @@ -191,9 +195,11 @@ func moveMain(ctx context.Context, args []string, out io.Writer, d moveDeps) int |
| moveSay(out, moveAsRoot) |
| return moveExitRefused |
| } |
| - sess, sessErr := d.session(a.console) |
| - if sessErr != nil { |
| - sayError(out, sessErr) |
| + // resolveMode's own sentences are in no copy record (the general record |
| + // is the nearest-region plan's), so carlos move says its own. |
| + sess, err := d.session(a.console) |
| + if err != nil { |
| + moveSay(out, moveNoSession) |
| return moveExitError |
| } |
| if sess == nil { |
| @@ -260,6 +266,10 @@ type moveOutcome struct { |
| type moveAnswer struct { |
| moveFlat |
| Detail *moveDetailView `json:"detail"` |
| + // stuck is the journal's stuck reason as server text, set by roundTrip |
| + // (hasStuck when there is one). |
| + stuck serverText |
| + hasStuck bool |
| } |
| type moveDetailView struct { |
| @@ -272,15 +282,15 @@ type moveDetailView struct { |
| } |
| type moveJournal struct { |
| - ID string `json:"id"` |
| - To string `json:"to"` |
| - Cut string `json:"cut"` |
| - State string `json:"state"` |
| - CutPhase string `json:"cut_phase"` |
| - CutBudgetS int `json:"cut_budget_s"` |
| - Reason string `json:"reason"` |
| - StuckReason string `json:"stuck_reason"` |
| - Rollback *moveRollback `json:"rollback"` |
| + ID string `json:"id"` |
| + To string `json:"to"` |
| + Cut string `json:"cut"` |
| + State string `json:"state"` |
| + CutPhase string `json:"cut_phase"` |
| + CutBudgetS int `json:"cut_budget_s"` |
| + Reason string `json:"reason"` |
| + WireStuck string `json:"stuck_reason"` // read only by roundTrip |
| + Rollback *moveRollback `json:"rollback"` |
| } |
| type moveRollback struct { |
| @@ -324,6 +334,101 @@ func (m *mover) withDetail() string { |
| // it gives up with exit 1: a console restart is not a failed move. |
| const moveTransient = 5 |
| +// moveReply is one response from the move door, as roundTrip read it. |
| +type moveReply struct { |
| + status int |
| + answer moveAnswer // decoded, on a 2xx |
| + refusal serverText // the console's own sentence, on another status |
| + refused bool // refusal holds one |
| + moved string // the console's new origin, on a console-shaped redirect |
| + failed bool // the console was not reached, or its answer not read |
| + unread bool // ... and it was the answer that could not be read |
| + stopped bool // the context ended the call |
| +} |
| + |
| +// roundTrip sends one request to the move door and reads its response. It is |
| +// the one maker of serverText (movesServerText): the refusal's "error" |
| +// sentence, and the stuck reason of a detail answer, read from the body of |
| +// the response the HTTP client returned. |
| +func (m *mover) roundTrip(ctx context.Context, method, path string, in any) moveReply { |
| + var body io.Reader |
| + if in != nil { |
| + raw, err := json.Marshal(in) |
| + if err != nil { |
| + return moveReply{failed: true, unread: true} |
| + } |
| + body = bytes.NewReader(raw) |
| + } |
| + req, err := http.NewRequestWithContext(ctx, method, m.sess.c.base+path, body) |
| + if err != nil { |
| + return moveReply{failed: true} |
| + } |
| + if in != nil { |
| + req.Header.Set("Content-Type", "application/json") |
| + } |
| + if m.sess.c.token != "" { |
| + req.Header.Set("Authorization", "Bearer "+m.sess.c.token) |
| + } |
| + res, err := m.sess.c.httpc.Do(req) |
| + if err != nil { |
| + return moveReply{failed: true, stopped: ctx.Err() != nil} |
| + } |
| + defer res.Body.Close() |
| + r := moveReply{status: res.StatusCode} |
| + if moved := consoleMoved(m.sess.c.base, res.StatusCode, res.Header.Get("Location")); moved != nil { |
| + r.moved = moved.To |
| + return r |
| + } |
| + raw, err := io.ReadAll(io.LimitReader(res.Body, 1<<20)) |
| + if err != nil { |
| + r.failed = true |
| + return r |
| + } |
| + if res.StatusCode/100 == 2 { |
| + if json.Unmarshal(raw, &r.answer) != nil { |
| + r.failed, r.unread = true, true |
| + return r |
| + } |
| + if d := r.answer.Detail; d != nil && d.Move != nil && d.Move.WireStuck != "" { |
| + r.answer.stuck, r.answer.hasStuck = serverText{spoken: d.Move.WireStuck}, true |
| + } |
| + return r |
| + } |
| + var e struct { |
| + Error string `json:"error"` |
| + } |
| + if json.Unmarshal(raw, &e) == nil && e.Error != "" { |
| + r.refusal, r.refused = serverText{spoken: e.Error}, true |
| + } |
| + return r |
| +} |
| + |
| +// flat is the reply's flat view. |
| +func (r moveReply) flat() moveFlat { |
| + return r.answer.moveFlat |
| +} |
| + |
| +// sayReply says why a reply ended the command: the console's own sentence |
| +// when it gave one, else this file's. |
| +func (m *mover) sayReply(r moveReply) { |
| + switch { |
| + case r.moved != "": |
| + moveSay(m.out, fmt.Sprintf("This console has moved to %s. Run carlos auth login --console %s, then run the command again.", r.moved, r.moved)) |
| + case r.stopped: |
| + moveSay(m.out, moveStopped) |
| + case r.unread: |
| + moveSay(m.out, moveUnreadable) |
| + case r.failed: |
| + moveSay(m.out, moveUnreachable) |
| + case r.refused: |
| + saySpoken(m.out, r.refusal) |
| + case r.status == http.StatusNotFound: |
| + moveSay(m.out, moveNotFound) |
| + default: |
| + moveSay(m.out, fmt.Sprintf("The console answered %d and gave no reason.", r.status)) |
| + } |
| +} |
| + |
| // limit notes a 429: from now on the command waits 10 seconds between calls, |
| // and says so the first time. |
| func (m *mover) limit() { |
| @@ -335,35 +440,32 @@ func (m *mover) limit() { |
| // get reads one answer, riding out 429s (backing off, said once) and a few |
| // transient failures. code is -1 on success, else the exit code to return. |
| -func (m *mover) get(ctx context.Context, path string, into any) (code int) { |
| +func (m *mover) get(ctx context.Context, path string) (moveAnswer, int) { |
| failures := 0 |
| for { |
| - status, body, err := m.sess.getJSON(ctx, path) |
| + r := m.roundTrip(ctx, http.MethodGet, path, nil) |
| + ok := !r.failed && r.moved == "" |
| switch { |
| - case err == nil && status == http.StatusOK: |
| - if err := json.Unmarshal(body, into); err != nil { |
| - sayError(m.out, err) |
| - return moveExitError |
| - } |
| - return -1 |
| - case err == nil && status == http.StatusTooManyRequests: |
| + case ok && r.status == http.StatusOK: |
| + return r.answer, -1 |
| + case r.moved != "" || r.stopped: |
| + m.sayReply(r) |
| + return moveAnswer{}, moveExitError |
| + case ok && r.status == http.StatusTooManyRequests: |
| m.limit() |
| - case err == nil && status >= 400 && status < 500: |
| - sayError(m.out, apiErrorFromBody(status, body)) |
| - return moveExitRefused |
| + case ok && r.status >= 400 && r.status < 500: |
| + m.sayReply(r) |
| + return moveAnswer{}, moveExitRefused |
| default: |
| failures++ |
| if failures >= moveTransient { |
| - if err == nil { |
| - err = apiErrorFromBody(status, body) |
| - } |
| - sayError(m.out, err) |
| - return moveExitError |
| + m.sayReply(r) |
| + return moveAnswer{}, moveExitError |
| } |
| } |
| - if err := m.sleep(ctx); err != nil { |
| - sayError(m.out, err) |
| - return moveExitError |
| + if !m.sleep(ctx) { |
| + moveSay(m.out, moveStopped) |
| + return moveAnswer{}, moveExitError |
| } |
| } |
| } |
| @@ -375,25 +477,25 @@ func (m *mover) interval() time.Duration { |
| return m.poll |
| } |
| -func (m *mover) sleep(ctx context.Context) error { |
| +// sleep waits one interval; false when the context ended first. |
| +func (m *mover) sleep(ctx context.Context) bool { |
| t := time.NewTimer(m.interval()) |
| defer t.Stop() |
| select { |
| case <-ctx.Done(): |
| - return ctx.Err() |
| + return false |
| case <-t.C: |
| - return nil |
| + return true |
| } |
| } |
| func (m *mover) readFlat(ctx context.Context) (moveFlat, int) { |
| - var v moveFlat |
| - return v, m.get(ctx, m.path, &v) |
| + a, code := m.get(ctx, m.path) |
| + return a.moveFlat, code |
| } |
| func (m *mover) readDetail(ctx context.Context) (moveAnswer, int) { |
| - var a moveAnswer |
| - code := m.get(ctx, m.withDetail(), &a) |
| + a, code := m.get(ctx, m.withDetail()) |
| if code < 0 && a.Detail == nil { |
| a.Detail = &moveDetailView{} |
| } |
| @@ -401,53 +503,48 @@ func (m *mover) readDetail(ctx context.Context) (moveAnswer, int) { |
| } |
| // send sends one mutation, sending the same call again after the back-off |
| -// while the doors' rate limit answers 429 (ruling 1 of Task 1a's fix round: |
| -// the limit answers before any handler, so nothing was done). |
| -func (m *mover) send(ctx context.Context, method, tail string, body any) (status int, raw []byte, err error) { |
| +// while the doors' rate limit answers 429 (fix round 1, ruling 1: the limit |
| +// answers before any handler, so nothing was done). |
| +func (m *mover) send(ctx context.Context, method, tail string, body any) moveReply { |
| for { |
| - if method == http.MethodDelete { |
| - status, raw, err = m.sess.deleteJSON(ctx, m.path) |
| - } else { |
| - status, raw, err = m.sess.postJSON(ctx, m.path+tail, body) |
| - } |
| - if err != nil || status != http.StatusTooManyRequests { |
| - return status, raw, err |
| + r := m.roundTrip(ctx, method, m.path+tail, body) |
| + if r.failed || r.moved != "" || r.status != http.StatusTooManyRequests { |
| + return r |
| } |
| m.limit() |
| - if err = m.sleep(ctx); err != nil { |
| - return 0, nil, err |
| + if !m.sleep(ctx) { |
| + return moveReply{failed: true, stopped: true} |
| } |
| } |
| } |
| // mutate sends one move mutation. A refusal is the console's own sentence |
| -// and exit 5. On acceptance the answer names the move (Task 2), and the move's |
| -// detail, read once now, binds its regions: the sentences about this move |
| -// name them, whatever a later move does to the record. A request says M-C1; |
| -// then the wait (unless --no-wait) follows the move's id. |
| +// and exit 5. On acceptance the answer names the move (Task 2). With |
| +// --no-wait that answer is all: its state is said, and nothing more is read |
| +// (fix round 2, ruling 2). Else the move's detail, read once now, binds its |
| +// regions, a request says M-C1, and the wait follows the move's id. |
| func (m *mover) mutate(ctx context.Context, method, tail string, body any, wait, request bool) int { |
| - status, raw, err := m.send(ctx, method, tail, body) |
| - if err != nil { |
| - sayError(m.out, err) |
| + r := m.send(ctx, method, tail, body) |
| + switch { |
| + case r.failed || r.moved != "": |
| + m.sayReply(r) |
| return moveExitError |
| - } |
| - if status >= 400 && status < 500 { |
| - sayError(m.out, apiErrorFromBody(status, raw)) |
| + case r.status >= 400 && r.status < 500: |
| + m.sayReply(r) |
| return moveExitRefused |
| - } |
| - if status < 200 || status > 299 { |
| - sayError(m.out, apiErrorFromBody(status, raw)) |
| - return moveExitError |
| - } |
| - var view moveFlat |
| - if err := json.Unmarshal(raw, &view); err != nil { |
| - sayError(m.out, err) |
| + case r.status < 200 || r.status > 299: |
| + m.sayReply(r) |
| return moveExitError |
| } |
| + view := r.flat() |
| if view.ID == "" || view.Protocol != moveProtocolMark { |
| moveSay(m.out, moveNotEnabled) |
| return moveExitRefused |
| } |
| + if !wait { |
| + moveSay(m.out, fmt.Sprintf("Move %s of %s is %s.", view.ID, m.host, view.State)) |
| + return moveExitDone |
| + } |
| w := newMoveWatch(m, view.ID) |
| w.toRegion = view.Target |
| a, code := m.readDetail(ctx) |
| @@ -458,9 +555,6 @@ func (m *mover) mutate(ctx context.Context, method, tail string, body any, wait, |
| if request && w.first("start") { |
| moveSay(m.out, fmt.Sprintf("Moving %s from %s to %s.", m.host, w.fromRegion, w.toRegion)) |
| } |
| - if !wait { |
| - return moveExitDone |
| - } |
| return w.run(ctx) |
| } |
| @@ -541,11 +635,9 @@ func (w *moveWatch) learn(a moveAnswer) { |
| // never inferred from a state seen: it is last, bound to this move's id. |
| func (w *moveWatch) run(ctx context.Context) int { |
| for first := true; ; first = false { |
| - if !first { |
| - if err := w.m.sleep(ctx); err != nil { |
| - sayError(w.m.out, err) |
| - return moveExitError |
| - } |
| + if !first && !w.m.sleep(ctx) { |
| + moveSay(w.m.out, moveStopped) |
| + return moveExitError |
| } |
| view, code := w.m.readFlat(ctx) |
| if code >= 0 { |
| @@ -638,8 +730,8 @@ func (w *moveWatch) observe(a moveAnswer) int { |
| } |
| case "stuck": |
| moveSay(w.m.out, fmt.Sprintf("%s could not start in %s or in %s. It is offline. Run carlos move resolve --host %s --retry once the cause is fixed.", host, to, from, host)) |
| - if j.StuckReason != "" { |
| - sayRecorded(w.m.out, j.StuckReason) |
| + if a.hasStuck { |
| + saySpoken(w.m.out, a.stuck) |
| } |
| return moveExitWaiting |
| case "relocating": |
| @@ -654,10 +746,11 @@ func (w *moveWatch) observe(a moveAnswer) int { |
| } |
| // outcome is the end of a move that is no longer in flight: last, if it is |
| -// this move's, else not recorded. Its regions are the move's own. Only with |
| -// no move in flight at all may the record's region stand in for one the wait |
| -// never learnt: then no later move can have changed it (a later move that |
| -// closed would be last, and one in flight would be shown). |
| +// this move's, else not recorded. Its regions are the move's own, as the |
| +// wait learnt them; one it never learnt is never filled in from the record |
| +// (fix round 2, ruling 3). Without its region a sentence that names it gives |
| +// way: M-C9 alone for M-C8 (whose destination is unknown), and the outcome |
| +// line for M-C7, M-C9 and the aborted line. |
| func (w *moveWatch) outcome(a moveAnswer) int { |
| host := w.m.host |
| l := a.Last |
| @@ -666,14 +759,6 @@ func (w *moveWatch) outcome(a moveAnswer) int { |
| return moveExitWaiting |
| } |
| from, to := w.fromRegion, w.toRegion |
| - if a.State == "none" { |
| - if from == "" { |
| - from = a.Detail.Region |
| - } |
| - if to == "" { |
| - to = a.Detail.Region |
| - } |
| - } |
| switch l.Outcome { |
| case "done": |
| if to == "" { |
| @@ -686,7 +771,7 @@ func (w *moveWatch) outcome(a moveAnswer) int { |
| return w.outcomeLine(l, moveExitRolled) |
| } |
| // M-C8 for a rollback the wait missed only when the outcome carries |
| - // its reason (ruling 4): a reason is never made up. |
| + // its reason (ruling 4), and only with its destination known. |
| if l.Reason != "" && to != "" && w.first("rollback") { |
| moveSay(w.m.out, fmt.Sprintf("%s could not start %s: %s. Moving it back to %s.", to, host, l.Reason, from)) |
| } |
| @@ -704,7 +789,7 @@ func (w *moveWatch) outcome(a moveAnswer) int { |
| } |
| // outcomeLine says the outcome without a region, when the wait never learnt |
| -// the move's own and another move holds the record now. |
| +// the move's own. |
| func (w *moveWatch) outcomeLine(l *moveOutcome, code int) int { |
| moveSay(w.m.out, fmt.Sprintf("Move %s of %s is %s.", l.ID, w.m.host, l.Outcome)) |
| return code |
diff --git a/move_test.go b/move_test.go| index f6d85006b..269bae9ab 100644 |
| --- a/move_test.go |
| +++ b/move_test.go |
| @@ -53,6 +53,8 @@ type fakeMoveConsole struct { |
| // startDetail, when set, is the detail answer right after a mutation |
| // (the move's own start), whatever the next scripted frame is. |
| startDetail frame |
| + // detailStatus, when set, answers every detail GET with that status. |
| + detailStatus int |
| } |
| func (c *fakeMoveConsole) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| @@ -71,6 +73,10 @@ func (c *fakeMoveConsole) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| if r.Method == http.MethodGet && path == tMovePth { |
| if r.URL.Query().Get("detail") == "1" { |
| c.details++ |
| + if c.detailStatus != 0 { |
| + write(c.detailStatus, map[string]string{"error": "detail refused by the test"}) |
| + return |
| + } |
| cur := c.last |
| if c.mutated && c.startDetail != nil { |
| cur = c.startDetail |
| @@ -468,17 +474,37 @@ func TestMoveRefusesRoot(t *testing.T) { |
| wantLines(t, out.String(), "Run carlos move as yourself, not as root. It uses your own credentials.") |
| } |
| -// --no-wait answers after the request: M-C1, exit 0. |
| +// --no-wait answers from the mutation's own answer, and reads nothing after |
| +// it (fix round 2, ruling 2): a detail read that fails or is rate limited |
| +// cannot change the exit of a command the console accepted. |
| func TestMoveNoWait(t *testing.T) { |
| c := &fakeMoveConsole{frames: []frame{none(nil), inFlight("preparing", nil, nil)}} |
| code, out := runMoveAgainst(t, c, "--host", tHost, "--to", "uk", "--no-wait") |
| if code != 0 { |
| t.Fatalf("exit %d:\n%s", code, out) |
| } |
| - wantLines(t, out, mc1) |
| + wantLines(t, out, "Move "+tMoveID+" of acme.go.tito.io is preparing.") |
| wantNoLine(t, out, mc2) |
| } |
| +func TestMoveNoWaitReadsNothingAfterAcceptance(t *testing.T) { |
| + for _, status := range []int{http.StatusInternalServerError, http.StatusTooManyRequests} { |
| + for _, args := range [][]string{ |
| + {"--host", tHost, "--to", "uk", "--no-wait"}, {"cut", "--host", tHost, "--no-wait"}, |
| + {"abort", "--host", tHost, "--no-wait"}, {"resolve", "--host", tHost, "--retry", "--no-wait"}, |
| + } { |
| + c := &fakeMoveConsole{frames: []frame{inFlight("ready", nil, nil)}, detailStatus: status} |
| + if args[0] == "--host" { |
| + c.frames = []frame{none(nil)} |
| + } |
| + code, out := runMoveAgainst(t, c, args...) |
| + if code != 0 || c.details != 0 { |
| + t.Fatalf("%v with detail answering %d: exit %d after %d detail reads, want 0 and none:\n%s", args, status, code, c.details, out) |
| + } |
| + } |
| + } |
| +} |
| + |
| // status: one read, the state in a line, exit 0; --wait waits for this |
| // move's outcome; with nothing in flight and nothing closed, it says so. |
| func TestMoveStatus(t *testing.T) { |
| @@ -499,7 +525,9 @@ func TestMoveStatus(t *testing.T) { |
| // move this operator was waiting on. |
| closed := none(outcome(tMoveID, "rolled_back", 0)) |
| c = &fakeMoveConsole{frames: []frame{closed}} |
| - if code, out := runMoveAgainst(t, c, "status", "--host", tHost, "--wait"); code != 3 || !strings.Contains(out, mc9+"\n") { |
| + // The wait never saw the move, so it never learnt its regions, and the |
| + // record's region now is not taken for them (fix round 2, ruling 3). |
| + if code, out := runMoveAgainst(t, c, "status", "--host", tHost, "--wait"); code != 3 || !strings.Contains(out, "Move "+tMoveID+" of acme.go.tito.io is rolled_back.\n") || strings.Contains(out, "back in") { |
| t.Fatalf("status --wait after a rollback = %d:\n%s", code, out) |
| } |
| } |
| @@ -744,3 +772,77 @@ func TestMoveFlagHelp(t *testing.T) { |
| t.Errorf("a bad flag's help:\n%s", out.String()) |
| } |
| } |
| + |
| +// Fix round 2, ruling 3: a region the wait never learnt is not filled in. A |
| +// known origin with an unknown destination says M-C9 alone, even when the |
| +// outcome carries a reason, since M-C8 names the destination. |
| +func TestMoveOutcomeNeverGuessesARegion(t *testing.T) { |
| + var out bytes.Buffer |
| + m := &mover{out: &out, host: tHost} |
| + w := newMoveWatch(m, tMoveID) |
| + w.fromRegion = "ie" |
| + last := &moveOutcome{ID: tMoveID, Outcome: "rolled_back", Reason: "the landing ran past its deadline"} |
| + a := moveAnswer{moveFlat: moveFlat{State: "none", Last: last}, Detail: &moveDetailView{Region: "fr"}} |
| + if code := w.outcome(a); code != 3 { |
| + t.Fatalf("exit %d", code) |
| + } |
| + if got := out.String(); got != mc9+"\n" { |
| + t.Fatalf("output %q, want M-C9 alone", got) |
| + } |
| +} |
| + |
| +// Fix round 2, ruling 1: what the CLI says when the console is not reached, |
| +// answers with no reason, or the session cannot be made, is this file's own |
| +// copy; only the console's own sentence is passed on, as server text. |
| +func TestMoveFixedSentences(t *testing.T) { |
| + // Nothing listens: the transport fails. |
| + ts := httptest.NewServer(http.NotFoundHandler()) |
| + dead := ts.URL |
| + ts.Close() |
| + deps := moveDeps{ |
| + session: func(string) (*apiSession, error) { |
| + return &apiSession{c: newConsoleClient(dead, "cli-token"), console: dead, address: "op@keymail.dev"}, nil |
| + }, |
| + euid: func() int { return 1000 }, poll: time.Millisecond, |
| + } |
| + var out bytes.Buffer |
| + if code := moveMain(context.Background(), []string{"status", "--host", tHost, "--account", "bab", "--app", "titogo"}, &out, deps); code != 1 { |
| + t.Fatalf("unreachable console: exit %d:\n%s", code, out.String()) |
| + } |
| + wantLines(t, out.String(), "Could not reach the console. Check the connection and try again.") |
| + |
| + // The session cannot be made: one sentence of this file. |
| + out.Reset() |
| + deps.session = func(string) (*apiSession, error) { return nil, errors.New("unreviewed resolveMode text") } |
| + if code := moveMain(context.Background(), []string{"status", "--host", tHost, "--account", "bab", "--app", "titogo"}, &out, deps); code != 1 { |
| + t.Fatalf("no session: exit %d:\n%s", code, out.String()) |
| + } |
| + wantLines(t, out.String(), "carlos move could not sign in to a console. Run carlos auth login, or check --console, CARLOS_CONSOLE and CARLOS_SERVICE_TOKEN.") |
| + wantNoLine(t, out.String(), "unreviewed resolveMode text") |
| + |
| + // A refusal with no JSON reason (the family's 404), and a 5xx with none. |
| + for _, c := range []struct { |
| + status int |
| + code int |
| + want string |
| + }{ |
| + {http.StatusNotFound, 5, "Not found. Check --account and --app, and that you are a member of the instance's account."}, |
| + {http.StatusBadGateway, 1, "The console answered 502 and gave no reason."}, |
| + } { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.WriteHeader(c.status) |
| + _, _ = w.Write([]byte("plain text")) |
| + })) |
| + deps.session = func(string) (*apiSession, error) { |
| + return &apiSession{c: newConsoleClient(ts.URL, "cli-token"), console: ts.URL, address: "op@keymail.dev"}, nil |
| + } |
| + out.Reset() |
| + code := moveMain(context.Background(), []string{"status", "--host", tHost, "--account", "bab", "--app", "titogo"}, &out, deps) |
| + ts.Close() |
| + if code != c.code { |
| + t.Fatalf("%d with no reason: exit %d, want %d:\n%s", c.status, code, c.code, out.String()) |
| + } |
| + wantLines(t, out.String(), c.want) |
| + wantNoLine(t, out.String(), "plain text") |
| + } |
| +} |
diff --git a/testdata/moves-copy/sinks/forwarded-local-error/emit.go b/testdata/moves-copy/sinks/forwarded-local-error/emit.go| deleted file mode 100644 |
| index 2af6950ee..000000000 |
| --- a/testdata/moves-copy/sinks/forwarded-local-error/emit.go |
| +++ /dev/null |
| @@ -1,58 +0,0 @@ |
| -package fixture |
| - |
| -// Not compiled. sayError is a local sink that forwards an error, as move.go's |
| -// does. What reaches it is traced at each call: the console's own text |
| -// (apiErrorFromBody) and an error from a call across the API (getJSON) are |
| -// external, by name; a locally made error is a sentence and needs its row; |
| -// an error the gate cannot trace is a violation. |
| - |
| -func moveSay(out int, line string) {} |
| - |
| -func sayError(out int, err error) { |
| - moveSay(out, err.Error()) |
| -} |
| - |
| -func local(out int) { |
| - sayError(out, errors.New("unreviewed copy")) |
| -} |
| - |
| -func formatted(out int, n int) { |
| - sayError(out, fmt.Errorf("%d boxes are unreviewed", n)) |
| -} |
| - |
| -func external(out int, status int, body []byte) { |
| - sayError(out, apiErrorFromBody(status, body)) |
| -} |
| - |
| -func crossed(out int) { |
| - _, _, err := getJSON("/x") |
| - if err != nil { |
| - sayError(out, err) |
| - } |
| -} |
| - |
| -func untraced(out int, err error) { |
| - sayError(out, err) |
| -} |
| - |
| -// A package helper's error result is traced through its returns: one that |
| -// makes its own error is not external and fails the gate; one that passes on |
| -// an external error is external. |
| -func helper() error { |
| - return errors.New("helper copy") |
| -} |
| - |
| -func viaHelper(out int) { |
| - sayError(out, helper()) |
| -} |
| - |
| -func fetch() (int, error) { |
| - _, _, err := getJSON("/y") |
| - return 0, err |
| -} |
| - |
| -func viaFetch(out int) { |
| - if _, err := fetch(); err != nil { |
| - sayError(out, err) |
| - } |
| -} |
diff --git a/testdata/moves-copy/sinks/provenance/emit.go b/testdata/moves-copy/sinks/provenance/emit.go| new file mode 100644 |
| index 000000000..166f75172 |
| --- /dev/null |
| +++ b/testdata/moves-copy/sinks/provenance/emit.go |
| @@ -0,0 +1,50 @@ |
| +package fixture |
| + |
| +// Not compiled. The ways round the provenance rule that the 2026-10-11 |
| +// re-review of Plan 6 Task 1a found, plus a forged serverText. Each one |
| +// must fail the gate (TestMovesProvenanceBypassesFail). |
| + |
| +type serverText struct{ spoken string } |
| + |
| +type moveJournal struct{ StuckReason string } |
| + |
| +func moveSay(out int, line string) {} |
| + |
| +func sayError(out int, err error) { moveSay(out, err.Error()) } |
| + |
| +// Bypass 3: the forwarding sink reassigns its own parameter. |
| +func sayRecorded(out int, text string) { |
| + text = "unreviewed reassignment" |
| + moveSay(out, text) |
| +} |
| + |
| +func saySpoken(out int, t serverText) {} |
| + |
| +func apiErrorFromBody(status int, body []byte) error { |
| + var e struct{ Error string } |
| + if json.Unmarshal(body, &e) == nil && e.Error != "" { |
| + return errors.New(e.Error) |
| + } |
| + return fmt.Errorf("unexpected response (status %d)", status) |
| +} |
| + |
| +// Bypass 1: the console's error decoder, fed literal JSON. |
| +func literalJSON(out int) { |
| + sayError(out, apiErrorFromBody(400, []byte(`{"error":"unreviewed copy"}`))) |
| +} |
| + |
| +// Bypass 2: a stuck reason written here, not read from an answer. |
| +func forgedStuck(out int) { |
| + j := moveJournal{StuckReason: "unreviewed stuck reason"} |
| + sayRecorded(out, j.StuckReason) |
| +} |
| + |
| +// Server text made outside its one maker. |
| +func forgedServer(out int) { |
| + saySpoken(out, serverText{spoken: "unreviewed server text"}) |
| +} |
| + |
| +// Server text read outside its one sink. |
| +func leaked(out int, t serverText) { |
| + moveSay(out, "the console says "+t.spoken) |
| +} |