| index f1bd8d8e1..5bbf68104 100644 |
| --- a/internal/release/rollback_test.go |
| +++ b/internal/release/rollback_test.go |
| @@ -10,6 +10,7 @@ import ( |
| "strings" |
| "testing" |
| |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| "github.com/carlosframework/platform/internal/registry" |
| ) |
| |
| @@ -299,3 +300,187 @@ func TestRollbackGroupLeavesOtherUpstreamAlone(t *testing.T) { |
| t.Fatalf("another process's route was refused: %+v", r) |
| } |
| } |
| + |
| +// scriptedWork is one host's release selection under lifecycle host writes. |
| +// It counts With calls, refuses every effect once stale, and lets a test go |
| +// stale right after a chosen call's effect has landed. |
| +type scriptedWork struct { |
| + name string |
| + calls int |
| + stale bool |
| + after func(call int) |
| +} |
| + |
| +func (w *scriptedWork) With(_ context.Context, id hostwrite.Identity, fn func() error) error { |
| + w.calls++ |
| + if id.Release == "" { |
| + return errors.New("missing release identity") |
| + } |
| + if w.stale { |
| + return errors.New(w.name + ": release selection changed") |
| + } |
| + err := fn() |
| + if w.after != nil { |
| + w.after(w.calls) |
| + } |
| + return err |
| +} |
| + |
| +// scriptedSelector hands out each host's works in order, one per Select, and |
| +// keeps the last for any later Select. |
| +type scriptedSelector struct { |
| + works map[string][]*scriptedWork |
| + selects map[string]int |
| +} |
| + |
| +func (s *scriptedSelector) Select(_ context.Context, host, _ string) (hostwrite.Work, error) { |
| + list := s.works[host] |
| + if len(list) == 0 { |
| + return nil, errors.New("no work for " + host) |
| + } |
| + i := min(s.selects[host], len(list)-1) |
| + s.selects[host]++ |
| + return list[i], nil |
| +} |
| + |
| +func claimWrites(sel *scriptedSelector) PassOption { |
| + return WithHostWrites(hostwrite.Config{Enabled: true, Selector: sel}) |
| +} |
| + |
| +// boundNotUp is the selected restart for notUpGroupFixture: the bound route's |
| +// unit does not come up, the sibling's restart is the shared process's no-op. |
| +func boundNotUp(restarts *int) hostwrite.Callback { |
| + return func(_ context.Context, s hostwrite.Selection) error { |
| + *restarts++ |
| + if s.Host == "platform.example.com" { |
| + return &UnitNotUpError{Unit: "console.service", Reason: "exited: status=2"} |
| + } |
| + return nil |
| + } |
| +} |
| + |
| +// Merge review P1: authority lost after the group rolled back, before the |
| +// rollback start. The start is skipped, but every group host is still refused |
| +// (not recovered, carrying the authority error), so the next pass, with fresh |
| +// authority and an empty poisoned map, does not adopt the rejected build. |
| +func TestClaimAuthorityLostBeforeRollbackStartStillRefusesTheGroup(t *testing.T) { |
| + ctx, reg, s, pub, opt := notUpGroupFixture(t) |
| + bake := t.TempDir() |
| + bound := &scriptedWork{name: "platform"} |
| + // Bound's calls: 1 forward repoint, 2 the pre-restart check, 3 the |
| + // rollback repoint, 4 the pre-start check. Stale after 3 has landed. |
| + bound.after = func(call int) { |
| + if call == 3 { |
| + bound.stale = true |
| + } |
| + } |
| + sibling := &scriptedWork{name: "console"} |
| + sel := &scriptedSelector{selects: map[string]int{}, works: map[string][]*scriptedWork{ |
| + "platform.example.com": {bound}, "console.example.com": {sibling}}} |
| + restarts, starts := 0, 0 |
| + start := func(ctx context.Context, s hostwrite.Selection) error { starts++; return nil } |
| + var out bytes.Buffer |
| + withCapturedStdout(t, &out, func() { |
| + if err := Pass(ctx, reg, s, pub, opt, func(string) error { t.Fatal("legacy restart"); return nil }, |
| + BakeDir(bake), claimWrites(sel), WithSelectedRestarts(boundNotUp(&restarts), start), |
| + WithRollbackRestart(func(string) error { t.Fatal("legacy rollback restart"); return nil })); err != nil { |
| + t.Fatalf("Pass: %v", err) |
| + } |
| + }) |
| + if !bound.stale { |
| + t.Fatalf("the fixture never reached the rollback repoint:\n%s", out.String()) |
| + } |
| + if starts != 0 { |
| + t.Fatalf("rollback start ran %d times after authority changed", starts) |
| + } |
| + for _, h := range []string{"console.example.com", "platform.example.com"} { |
| + if v, _ := LiveVersion(opt, h); v != "old" { |
| + t.Fatalf("%s live = %q, want old (rolled back before authority changed)", h, v) |
| + } |
| + r, err := ReadRefusal(bake, h) |
| + if err != nil || r == nil || r.Version != "new" || r.Recovered || r.RestoredVersion != "old" || |
| + !strings.Contains(r.RecoveryError, "release authority changed before rollback start") { |
| + t.Fatalf("%s refusal = %+v, %v; want new refused, not recovered, with the authority error\n%s", h, r, err, out.String()) |
| + } |
| + } |
| + |
| + // The next pass: fresh, valid authority for both hosts, nothing poisoned. |
| + sel2 := &scriptedSelector{selects: map[string]int{}, works: map[string][]*scriptedWork{ |
| + "platform.example.com": {{name: "platform2"}}, "console.example.com": {{name: "console2"}}}} |
| + restarts = 0 |
| + out.Reset() |
| + withCapturedStdout(t, &out, func() { |
| + if err := Pass(ctx, reg, s, pub, opt, func(string) error { t.Fatal("legacy restart"); return nil }, |
| + BakeDir(bake), claimWrites(sel2), WithSelectedRestarts(boundNotUp(&restarts), start), |
| + WithRollbackRestart(func(string) error { t.Fatal("legacy rollback restart"); return nil })); err != nil { |
| + t.Fatalf("second Pass: %v", err) |
| + } |
| + }) |
| + if restarts != 0 || starts != 0 { |
| + t.Fatalf("second pass restarted %d / started %d: it re-adopted the rejected build\n%s", restarts, starts, out.String()) |
| + } |
| + for _, h := range []string{"console.example.com", "platform.example.com"} { |
| + if v, _ := LiveVersion(opt, h); v != "old" { |
| + t.Fatalf("%s live = %q after the second pass, want old", h, v) |
| + } |
| + if rt := mustRoute(t, reg, h); rt.Version == "new" { |
| + t.Fatalf("%s stamped new by the second pass", h) |
| + } |
| + } |
| +} |
| + |
| +// Merge review P1: the sibling's installation changes between its rollback |
| +// repoint and its stamp. The stamp is refused under the sibling's one |
| +// selection (the pass's original), never re-selected and handed to the |
| +// successor. |
| +func TestClaimSiblingStampRefusedWhenItsSelectionGoesStale(t *testing.T) { |
| + ctx, reg, s, pub, opt := notUpGroupFixture(t) |
| + bake := t.TempDir() |
| + bound := &scriptedWork{name: "platform"} |
| + sibling := &scriptedWork{name: "console"} |
| + // Sibling's calls: 1 forward repoint, 2 the pre-restart check, 3 the |
| + // forward stamp, 4 the rollback repoint. Stale after 4 has landed. |
| + sibling.after = func(call int) { |
| + if call == 4 { |
| + sibling.stale = true |
| + } |
| + } |
| + successor := &scriptedWork{name: "console-successor"} |
| + sel := &scriptedSelector{selects: map[string]int{}, works: map[string][]*scriptedWork{ |
| + "platform.example.com": {bound}, "console.example.com": {sibling, successor}}} |
| + restarts, starts := 0, 0 |
| + start := func(ctx context.Context, s hostwrite.Selection) error { |
| + return s.With(ctx, func() error { starts++; return nil }) |
| + } |
| + var out bytes.Buffer |
| + withCapturedStdout(t, &out, func() { |
| + if err := Pass(ctx, reg, s, pub, opt, func(string) error { t.Fatal("legacy restart"); return nil }, |
| + BakeDir(bake), claimWrites(sel), WithSelectedRestarts(boundNotUp(&restarts), start), |
| + WithRollbackRestart(func(string) error { t.Fatal("legacy rollback restart"); return nil })); err != nil { |
| + t.Fatalf("Pass: %v", err) |
| + } |
| + }) |
| + if n := sel.selects["console.example.com"]; n != 1 || successor.calls != 0 { |
| + t.Fatalf("sibling selected %d times, successor used %d times: want the pass's one selection only", n, successor.calls) |
| + } |
| + if !sibling.stale { |
| + t.Fatalf("the fixture never reached the sibling's rollback repoint:\n%s", out.String()) |
| + } |
| + if v, _ := LiveVersion(opt, "console.example.com"); v != "old" { |
| + t.Fatalf("sibling live = %q, want old (the repoint landed before the change)", v) |
| + } |
| + if rt := mustRoute(t, reg, "console.example.com"); rt.Version != "new" { |
| + t.Fatalf("sibling stamped %q over %q: the stamp should have been refused", rt.Version, rt.PrevVersion) |
| + } |
| + if !strings.Contains(out.String(), "console: release selection changed") { |
| + t.Fatalf("the refused stamp was not logged:\n%s", out.String()) |
| + } |
| + if starts != 1 { |
| + t.Fatalf("bound's rollback start ran %d times, want 1", starts) |
| + } |
| + for _, h := range []string{"console.example.com", "platform.example.com"} { |
| + if r, _ := ReadRefusal(bake, h); r == nil || r.Version != "new" { |
| + t.Fatalf("%s refusal = %+v, want one naming new", h, r) |
| + } |
| + } |
| +} |