| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "encoding/json" |
| 7 | "errors" |
| 8 | "os" |
| 9 | "testing" |
| 10 | |
| 11 | "github.com/carlosframework/platform/internal/hostwrite" |
| 12 | "github.com/carlosframework/platform/internal/provision" |
| 13 | "github.com/carlosframework/platform/internal/release" |
| 14 | "github.com/carlosframework/platform/internal/rootstate" |
| 15 | ) |
| 16 | |
| 17 | func TestPlan14HistoryRollbackLink(t *testing.T) { |
| 18 | t.Parallel() |
| 19 | for _, scenario := range []string{"success", "rollback", "failed rollback", "intent crash", "link crash", "restart crash"} { |
| 20 | t.Run(scenario, func(t *testing.T) { |
| 21 | e, rt, u, c, pub := plan14AdoptionFixture(t) |
| 22 | h := seedHostRootHistory(t, rt) |
| 23 | deps := boundRootHistory(h, c.resolve, e.root.obs) |
| 24 | c.history = func() (*release.RootHistoryPass, error) { return deps, nil } |
| 25 | linkRootHistory(deps, c, u.opt, func(string) string { return "5s" }) |
| 26 | u.crashNew = scenario != "success" |
| 27 | u.badRollback = scenario == "failed rollback" |
| 28 | stopped := errors.New("crash") |
| 29 | prepare := deps.Prepare |
| 30 | deps.Prepare = func(ctx context.Context, b release.HistoryBinding, a release.HistoryAttempt, id hostwrite.Identity) error { |
| 31 | if scenario == "intent crash" { |
| 32 | panic(stopped) |
| 33 | } |
| 34 | if err := prepare(ctx, b, a, id); err != nil { |
| 35 | return err |
| 36 | } |
| 37 | live, _ := release.LiveVersion(u.opt, rt.Host) |
| 38 | if live != "v1" { |
| 39 | t.Fatal("rollback captured after live effect") |
| 40 | } |
| 41 | s, _ := c.attempts() |
| 42 | journal, err := readRollbackAttempt(s, rt.Host) |
| 43 | if err != nil || journal.History == nil || journal.History.ID != a.ID { |
| 44 | t.Fatalf("unlinked intent: %+v %v", journal, err) |
| 45 | } |
| 46 | if scenario == "link crash" { |
| 47 | panic(stopped) |
| 48 | } |
| 49 | return nil |
| 50 | } |
| 51 | if scenario == "restart crash" { |
| 52 | u.afterEffect = func() { panic(stopped) } |
| 53 | } |
| 54 | forward := rootRestartRouteWith(e.root.reg, u, u.opt, func(string) string { return "5s" }, nil, c) |
| 55 | var passErr error |
| 56 | func() { |
| 57 | defer func() { |
| 58 | if p := recover(); p != nil && p != stopped { |
| 59 | panic(p) |
| 60 | } |
| 61 | }() |
| 62 | passErr = release.Pass(t.Context(), e.root.reg, e.store, pub, u.opt, forward, |
| 63 | release.WithRootHistory(deps), release.WithRestartFailurePropagation(), |
| 64 | release.WithRollbackRestart(rollbackUnitRestart(e.root.reg, u, u.opt, func(string) string { return "5s" }, c))) |
| 65 | }() |
| 66 | u.afterEffect = nil |
| 67 | if scenario == "success" && passErr != nil { |
| 68 | t.Fatal(passErr) |
| 69 | } |
| 70 | b := release.HistoryBinding{Host: rt.Host, Account: rt.Account, App: rt.App} |
| 71 | if scenario == "intent crash" || scenario == "failed rollback" { |
| 72 | if _, err := h.Refusals(b); err == nil { |
| 73 | t.Fatal("unresolved attempt granted permission") |
| 74 | } |
| 75 | return |
| 76 | } |
| 77 | if err := recoverRootRestarts(t.Context(), u, u.opt, func(string) string { return "5s" }, c, hostwrite.Config{}); err != nil { |
| 78 | t.Fatal(err) |
| 79 | } |
| 80 | refs, err := h.Refusals(b) |
| 81 | if err != nil { |
| 82 | t.Fatal(err) |
| 83 | } |
| 84 | if scenario == "success" { |
| 85 | if len(refs) != 0 { |
| 86 | t.Fatalf("successful target refused: %+v", refs) |
| 87 | } |
| 88 | } else if len(refs) == 0 || refs[0].Version != "v2" || !refs[0].Recovered || refs[0].RestoredVersion != "v1" { |
| 89 | t.Fatalf("rollback lost exact deny: %+v", refs) |
| 90 | } |
| 91 | before := len(u.effects) |
| 92 | if err := recoverRootRestarts(t.Context(), u, u.opt, func(string) string { return "5s" }, c, hostwrite.Config{}); err != nil || len(u.effects) != before { |
| 93 | t.Fatalf("recovery replay: %v", err) |
| 94 | } |
| 95 | }) |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | type historyVisibleFailure struct { |
| 100 | rootstate.Store |
| 101 | armed bool |
| 102 | visible []byte |
| 103 | } |
| 104 | |
| 105 | func (s *historyVisibleFailure) Write(k string, raw []byte) error { |
| 106 | if err := s.Store.Write(k, raw); err != nil { |
| 107 | return err |
| 108 | } |
| 109 | var record struct { |
| 110 | Binding release.HistoryBinding `json:"binding"` |
| 111 | Attempt *release.HistoryAttempt `json:"attempt"` |
| 112 | } |
| 113 | if s.armed && json.Unmarshal(raw, &record) == nil && record.Binding.Host != "" && record.Attempt == nil { |
| 114 | s.visible = append([]byte(nil), raw...) |
| 115 | return os.ErrPermission |
| 116 | } |
| 117 | return nil |
| 118 | } |
| 119 | |
| 120 | func TestPlan14HistorySettlementRetriesVisibleSync(t *testing.T) { |
| 121 | t.Parallel() |
| 122 | e, rt, u, c, pub := plan14AdoptionFixture(t) |
| 123 | h := seedHostRootHistory(t, rt) |
| 124 | fault := &historyVisibleFailure{Store: h.Store} |
| 125 | h.Store = fault |
| 126 | deps := boundRootHistory(h, c.resolve, e.root.obs) |
| 127 | c.history = func() (*release.RootHistoryPass, error) { return deps, nil } |
| 128 | linkRootHistory(deps, c, u.opt, func(string) string { return "5s" }) |
| 129 | u.afterEffect = func() { fault.armed = true } |
| 130 | forward := rootRestartRouteWith(e.root.reg, u, u.opt, func(string) string { return "5s" }, nil, c) |
| 131 | if err := release.Pass(t.Context(), e.root.reg, e.store, pub, u.opt, forward, release.WithRootHistory(deps)); err == nil { |
| 132 | t.Fatal("failed settlement sync reported success") |
| 133 | } |
| 134 | if len(fault.visible) == 0 { |
| 135 | t.Fatal("settlement did not become visible") |
| 136 | } |
| 137 | binding := release.HistoryBinding{Host: rt.Host, Account: rt.Account, App: rt.App} |
| 138 | before := len(u.effects) |
| 139 | if _, err := h.Refusals(binding); err == nil { |
| 140 | t.Fatal("unsynced settlement granted mint") |
| 141 | } |
| 142 | if err := recoverRootRestarts(t.Context(), u, u.opt, func(string) string { return "5s" }, c, hostwrite.Config{}); err == nil { |
| 143 | t.Fatal("recovery ignored failed sync") |
| 144 | } |
| 145 | if len(u.effects) != before { |
| 146 | t.Fatal("sync recovery repeated restart") |
| 147 | } |
| 148 | want := append([]byte(nil), fault.visible...) |
| 149 | fault.armed = false |
| 150 | if err := recoverRootRestarts(t.Context(), u, u.opt, func(string) string { return "5s" }, c, hostwrite.Config{}); err != nil { |
| 151 | t.Fatal(err) |
| 152 | } |
| 153 | if refs, err := h.Refusals(binding); err != nil || len(refs) != 0 { |
| 154 | t.Fatalf("durable settlement: %+v %v", refs, err) |
| 155 | } |
| 156 | if len(u.effects) != before { |
| 157 | t.Fatal("durable recovery repeated restart") |
| 158 | } |
| 159 | found := false |
| 160 | for _, raw := range fault.Store.(*rollbackMemory).files { |
| 161 | if bytes.Equal(raw, want) { |
| 162 | found = true |
| 163 | } |
| 164 | } |
| 165 | if !found { |
| 166 | t.Fatal("sync retry refreshed settlement evidence") |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | func TestPlan14HistoryRecoveryRefusesChanges(t *testing.T) { |
| 171 | t.Parallel() |
| 172 | for _, attack := range []string{"record", "unit", "attempt", "missing capability", "stopped", "target"} { |
| 173 | t.Run(attack, func(t *testing.T) { |
| 174 | e, rt, u, c, pub := plan14AdoptionFixture(t) |
| 175 | h := seedHostRootHistory(t, rt) |
| 176 | deps := boundRootHistory(h, c.resolve, e.root.obs) |
| 177 | c.history = func() (*release.RootHistoryPass, error) { return deps, nil } |
| 178 | linkRootHistory(deps, c, u.opt, func(string) string { return "5s" }) |
| 179 | stopped := errors.New("crash") |
| 180 | u.crashNew = true |
| 181 | u.afterEffect = func() { panic(stopped) } |
| 182 | forward := rootRestartRouteWith(e.root.reg, u, u.opt, func(string) string { return "5s" }, nil, c) |
| 183 | func() { |
| 184 | defer func() { |
| 185 | if p := recover(); p != stopped { |
| 186 | t.Fatalf("crash: %v", p) |
| 187 | } |
| 188 | }() |
| 189 | _ = release.Pass(t.Context(), e.root.reg, e.store, pub, u.opt, forward, release.WithRootHistory(deps)) |
| 190 | }() |
| 191 | u.afterEffect = nil |
| 192 | s, _ := c.attempts() |
| 193 | a, err := readRollbackAttempt(s, rt.Host) |
| 194 | if err != nil { |
| 195 | t.Fatal(err) |
| 196 | } |
| 197 | switch attack { |
| 198 | case "record": |
| 199 | rec, _, err := provision.FindRecordByHost(t.Context(), e.store, rt.Host) |
| 200 | if err != nil { |
| 201 | t.Fatal(err) |
| 202 | } |
| 203 | rec.RecordSeq++ |
| 204 | plan14PutRecord(t, e.store, *rec) |
| 205 | case "unit": |
| 206 | if err := os.Rename(u.props.FragmentPath, u.props.FragmentPath+".old"); err != nil { |
| 207 | t.Fatal(err) |
| 208 | } |
| 209 | if err := os.WriteFile(u.props.FragmentPath, []byte("changed"), 0644); err != nil { |
| 210 | t.Fatal(err) |
| 211 | } |
| 212 | case "attempt": |
| 213 | a.History.ID = "another-attempt" |
| 214 | if err := writeRollbackState(s, rollbackJournalKey(rt.Host), a); err != nil { |
| 215 | t.Fatal(err) |
| 216 | } |
| 217 | case "missing capability": |
| 218 | if err := s.Remove(rollbackCapabilityKey(rt.Host, a.ID)); err != nil { |
| 219 | t.Fatal(err) |
| 220 | } |
| 221 | case "stopped": |
| 222 | u.props.ActiveState, u.props.SubState = "inactive", "dead" |
| 223 | case "target": |
| 224 | if err := os.Remove(release.LiveLink(u.opt, rt.Host)); err != nil { |
| 225 | t.Fatal(err) |
| 226 | } |
| 227 | } |
| 228 | before := len(u.effects) |
| 229 | if err := recoverRootRestarts(t.Context(), u, u.opt, func(string) string { return "5s" }, c, hostwrite.Config{}); err == nil || len(u.effects) != before { |
| 230 | t.Fatalf("unsafe recovery: %v %v", err, u.effects) |
| 231 | } |
| 232 | h.Store.(*rollbackMemory).failWrite = false |
| 233 | if _, err := h.Refusals(a.HistoryBinding); err == nil { |
| 234 | t.Fatal("failed recovery cleared pending history") |
| 235 | } |
| 236 | }) |
| 237 | } |
| 238 | } |
| 239 | |