| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "github.com/carlosframework/platform/internal/provision" |
| 6 | "os" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | "time" |
| 10 | |
| 11 | "github.com/carlosframework/platform/internal/hostwrite" |
| 12 | "github.com/carlosframework/platform/internal/registry" |
| 13 | "github.com/carlosframework/platform/internal/release" |
| 14 | ) |
| 15 | |
| 16 | func TestPlan14SignedActiveFixIgnoresHistory(t *testing.T) { |
| 17 | for _, fault := range []string{"unavailable", "missing", "malformed", "unwritable", "refused", "pending", "unknown bake", "claims on"} { |
| 18 | t.Run(fault, func(t *testing.T) { |
| 19 | e, u, c, source, pub := signedRecoveryFixture(t) |
| 20 | rt, err := e.root.reg.Get(observedHost) |
| 21 | if err != nil { |
| 22 | t.Fatal(err) |
| 23 | } |
| 24 | h := seedHostRootHistory(t, *rt) |
| 25 | b := release.HistoryBinding{Host: rt.Host, Account: rt.Account, App: rt.App} |
| 26 | switch fault { |
| 27 | case "missing": |
| 28 | h.Store.(*rollbackMemory).files = map[string][]byte{} |
| 29 | case "malformed": |
| 30 | for k := range h.Store.(*rollbackMemory).files { |
| 31 | h.Store.(*rollbackMemory).files[k] = []byte("{") |
| 32 | } |
| 33 | case "unwritable": |
| 34 | h.Store.(*rollbackMemory).failWrite = true |
| 35 | case "refused": |
| 36 | if err := h.AddRefusal(b, release.Refusal{Host: rt.Host, App: rt.App, Version: "v2", Kind: release.RefusalWrongProgram, RefusedAt: time.Now()}); err != nil { |
| 37 | t.Fatal(err) |
| 38 | } |
| 39 | case "pending": |
| 40 | if err := h.Begin(b, release.HistoryAttempt{ID: "unsettled", Version: "v0", SHA256: "abcd", Source: "old-source", Platform: release.AdoptPlatform(), Started: time.Now(), PromotedAt: time.Now()}); err != nil { |
| 41 | t.Fatal(err) |
| 42 | } |
| 43 | } |
| 44 | deps := boundRootHistory(h, source.resolve, e.root.obs) |
| 45 | if fault == "unavailable" { |
| 46 | deps = nil |
| 47 | } |
| 48 | if fault == "unknown bake" { |
| 49 | if err := h.Store.Remove("bake.json"); err != nil { |
| 50 | t.Fatal(err) |
| 51 | } |
| 52 | if _, err := h.LoadBake([]release.HistoryBinding{b}, time.Now()); err == nil { |
| 53 | t.Fatal("fixture unexpectedly has bake completeness") |
| 54 | } |
| 55 | } |
| 56 | c.history = func() (*release.RootHistoryPass, error) { |
| 57 | if deps == nil { |
| 58 | return nil, os.ErrPermission |
| 59 | } |
| 60 | return deps, nil |
| 61 | } |
| 62 | n := namedUnitRecovery{source: source, pub: pub, readLive: os.Readlink} |
| 63 | if fault == "claims on" { |
| 64 | n.writes = hostwrite.Config{Enabled: true, Selector: namedRecoveryWork{}} |
| 65 | } |
| 66 | err = release.Pass(t.Context(), e.root.reg, e.store, pub, u.opt, nil, |
| 67 | release.WithRootHistory(deps), release.WithRecovery(func(ctx context.Context, rt registry.Route) (bool, error) { |
| 68 | return n.recover(ctx, rt.Host, c, func(string) string { return "5s" }, e.root.reg) |
| 69 | })) |
| 70 | if fault == "refused" { |
| 71 | live, liveErr := release.LiveVersion(u.opt, rt.Host) |
| 72 | if err == nil || liveErr != nil || live != "v1" || len(u.effects) != 0 { |
| 73 | t.Fatalf("known refusal ignored: live=%s effects=%v error=%v", live, u.effects, err) |
| 74 | } |
| 75 | return |
| 76 | } |
| 77 | if err != nil || len(u.effects) != 1 || u.exe != release.VersionPath(u.opt, rt.Account, "v2", rt.App) { |
| 78 | t.Fatalf("history prevented active repair: effects=%v error=%v", u.effects, err) |
| 79 | } |
| 80 | got, err := e.root.reg.Get(rt.Host) |
| 81 | if err != nil || got.Version != "v2" { |
| 82 | t.Fatalf("missing verified version: %+v %v", got, err) |
| 83 | } |
| 84 | if err := release.Pass(t.Context(), e.root.reg, e.store, pub, u.opt, nil, |
| 85 | release.WithRootHistory(deps), release.WithRecovery(func(ctx context.Context, rt registry.Route) (bool, error) { |
| 86 | return n.recover(ctx, rt.Host, c, func(string) string { return "5s" }, e.root.reg) |
| 87 | })); err != nil || len(u.effects) != 1 { |
| 88 | t.Fatalf("repair replay: %v %v", err, u.effects) |
| 89 | } |
| 90 | }) |
| 91 | } |
| 92 | } |
| 93 | |
| 94 | func TestPlan14SignedActiveFixAuthority(t *testing.T) { |
| 95 | for _, attack := range []string{"unsigned target", "unsigned predecessor", "changed artifact", "changed record", "changed unit", "changed pointer", "changed invocation", "operator stop", "held claim", "untrusted live", "capture failure", "rollback", "rollback fails"} { |
| 96 | t.Run(attack, func(t *testing.T) { |
| 97 | e, u, c, source, pub := signedRecoveryFixture(t) |
| 98 | rt, err := e.root.reg.Get(observedHost) |
| 99 | if err != nil { |
| 100 | t.Fatal(err) |
| 101 | } |
| 102 | n := namedUnitRecovery{source: source, pub: pub, readLive: os.Readlink} |
| 103 | mutate := func() {} |
| 104 | switch attack { |
| 105 | case "unsigned target": |
| 106 | n.pub = make([]byte, 32) |
| 107 | case "unsigned predecessor": |
| 108 | if err := e.store.Delete(t.Context(), release.PromotionRecordKey(rt.Account, rt.App, "v1")); err != nil { |
| 109 | t.Fatal(err) |
| 110 | } |
| 111 | case "changed artifact": |
| 112 | mutate = func() { |
| 113 | if err := os.WriteFile(release.VersionPath(u.opt, rt.Account, "v2", rt.App), []byte("wrong"), 0755); err != nil { |
| 114 | t.Fatal(err) |
| 115 | } |
| 116 | } |
| 117 | case "changed record": |
| 118 | mutate = func() { |
| 119 | rec, _, err := provision.FindRecordByHost(t.Context(), e.store, rt.Host) |
| 120 | if err != nil { |
| 121 | t.Fatal(err) |
| 122 | } |
| 123 | rec.RecordSeq++ |
| 124 | plan14PutRecord(t, e.store, *rec) |
| 125 | } |
| 126 | case "changed unit": |
| 127 | mutate = func() { |
| 128 | if err := os.WriteFile(u.props.FragmentPath, []byte("wrong"), 0644); err != nil { |
| 129 | t.Fatal(err) |
| 130 | } |
| 131 | } |
| 132 | case "changed pointer": |
| 133 | mutate = func() { |
| 134 | if err := e.store.Delete(t.Context(), release.ChannelKey(rt.Account, rt.App, rt.Channel)); err != nil { |
| 135 | t.Fatal(err) |
| 136 | } |
| 137 | } |
| 138 | case "changed invocation": |
| 139 | mutate = func() { u.props.InvocationID += "other" } |
| 140 | case "operator stop": |
| 141 | mutate = func() { u.props.ActiveState, u.props.SubState, u.props.MainPID = "inactive", "dead", 0 } |
| 142 | case "held claim": |
| 143 | n.writes = hostwrite.Config{Enabled: true, Selector: namedRecoveryWork{deny: true}} |
| 144 | case "untrusted live": |
| 145 | n.readLive = func(string) (string, error) { return "", os.ErrPermission } |
| 146 | case "capture failure": |
| 147 | disk, _ := c.attempts() |
| 148 | disk.(*rollbackMemory).failWrite = true |
| 149 | case "rollback", "rollback fails": |
| 150 | u.crashNew = true |
| 151 | u.badRollback = attack == "rollback fails" |
| 152 | } |
| 153 | if strings.HasPrefix(attack, "changed ") || attack == "operator stop" { |
| 154 | n.writes = hostwrite.Config{Enabled: true, Selector: namedRecoveryWork{before: mutate}} |
| 155 | } |
| 156 | handled, err := n.recover(t.Context(), rt.Host, c, func(string) string { return "5s" }, e.root.reg) |
| 157 | if !handled || err == nil { |
| 158 | t.Fatalf("attack accepted: %v %v", handled, err) |
| 159 | } |
| 160 | if attack == "rollback" || attack == "rollback fails" { |
| 161 | if len(u.effects) != 3 { |
| 162 | t.Fatalf("rollback absent: %v %v", u.effects, err) |
| 163 | } |
| 164 | if attack == "rollback" && u.exe != release.VersionPath(u.opt, rt.Account, "v1", rt.App) { |
| 165 | t.Fatal("wrong recovered executable") |
| 166 | } |
| 167 | count := len(u.effects) |
| 168 | _, _ = n.recover(t.Context(), rt.Host, c, func(string) string { return "5s" }, e.root.reg) |
| 169 | if len(u.effects) != count { |
| 170 | t.Fatalf("repeated failed attempt: %v", u.effects) |
| 171 | } |
| 172 | } else { |
| 173 | live, liveErr := release.LiveVersion(u.opt, rt.Host) |
| 174 | if len(u.effects) != 0 || liveErr != nil || live != "v1" { |
| 175 | t.Fatalf("unauthorized effect: %v live=%s error=%v", u.effects, live, liveErr) |
| 176 | } |
| 177 | } |
| 178 | }) |
| 179 | } |
| 180 | } |
| 181 | |
| 182 | func TestPlan14SignedFixSupersedesOlderAttempt(t *testing.T) { |
| 183 | e, u, c, source, pub := signedRecoveryFixture(t) |
| 184 | b, err := c.host(t.Context(), observedHost) |
| 185 | if err != nil { |
| 186 | t.Fatal(err) |
| 187 | } |
| 188 | h := seedHostRootHistory(t, b.Route) |
| 189 | c.history = func() (*release.RootHistoryPass, error) { return &release.RootHistoryPass{History: h}, nil } |
| 190 | disk, err := c.attempts() |
| 191 | if err != nil { |
| 192 | t.Fatal(err) |
| 193 | } |
| 194 | _, err = captureRollback(disk, b, c.now(), unitSettleTimeout(func(string) string { return "5s" }), release.VersionPath(u.opt, b.Route.Account, "obsolete", b.Route.App), c.identity, historyRollbackLink{Binding: release.HistoryBinding{Host: b.Route.Host, Account: b.Route.Account, App: b.Route.App}, Attempt: release.HistoryAttempt{ID: "older-attempt", Version: "obsolete", Source: b.Source, SHA256: "old-digest", Platform: release.AdoptPlatform(), Started: c.now(), PromotedAt: c.now()}, Independent: true}) |
| 195 | if err != nil { |
| 196 | t.Fatal(err) |
| 197 | } |
| 198 | n := namedUnitRecovery{source: source, pub: pub, readLive: os.Readlink} |
| 199 | handled, err := n.recover(t.Context(), observedHost, c, func(string) string { return "5s" }, e.root.reg) |
| 200 | if !handled || err != nil || len(u.effects) != 1 || u.exe != release.VersionPath(u.opt, b.Route.Account, "v2", b.Route.App) { |
| 201 | t.Fatalf("old attempt blocked new signed fix: %v %v %v", handled, err, u.effects) |
| 202 | } |
| 203 | if _, err := h.Refusals(release.HistoryBinding{Host: b.Route.Host, Account: b.Route.Account, App: b.Route.App}); err == nil { |
| 204 | t.Fatal("superseded ambiguous outcome erased by success") |
| 205 | } |
| 206 | } |
| 207 | |
| 208 | func TestPlan14ERecoveryBindingRefusalKeepsPromotedFix(t *testing.T) { |
| 209 | e, u, c, source, pub := signedRecoveryFixture(t) |
| 210 | c.legacy = func() (bool, error) { return true, nil } |
| 211 | c.legacyReady = c.ready |
| 212 | // Refuse the final readiness binding after the process effect. |
| 213 | original := c.host |
| 214 | c.host = func(ctx context.Context, host string) (proofBinding, error) { |
| 215 | if len(u.effects) > 0 { |
| 216 | return proofBinding{}, os.ErrPermission |
| 217 | } |
| 218 | return original(ctx, host) |
| 219 | } |
| 220 | n := namedUnitRecovery{source: source, pub: pub, readLive: os.Readlink} |
| 221 | handled, err := n.recover(t.Context(), observedHost, c, func(string) string { return "5s" }, e.root.reg) |
| 222 | live, liveErr := release.LiveVersion(u.opt, observedHost) |
| 223 | if handled || err != nil || liveErr != nil || live != "v1" || len(u.effects) != 0 { |
| 224 | t.Fatalf("no-census recovery selected effects: handled=%v err=%v live=%s/%v effects=%v", handled, err, live, liveErr, u.effects) |
| 225 | } |
| 226 | rt, err := e.root.reg.Get(observedHost) |
| 227 | if err != nil { |
| 228 | t.Fatal(err) |
| 229 | } |
| 230 | if _, _, err := release.StageSignedHead(t.Context(), e.store, pub, nil, u.opt, *rt); err != nil { |
| 231 | t.Fatal(err) |
| 232 | } |
| 233 | if err := release.PointLive(u.opt, observedHost, rt.Account, "v2", rt.App); err != nil { |
| 234 | t.Fatal(err) |
| 235 | } |
| 236 | if err := legacyCheckedUnitRestart(u, u.opt, func(string) string { return "5s" }, *rt, nil, c); err != nil { |
| 237 | t.Fatal(err) |
| 238 | } |
| 239 | live, liveErr = release.LiveVersion(u.opt, observedHost) |
| 240 | if liveErr != nil || live != "v2" || len(u.effects) != 1 { |
| 241 | t.Fatalf("main outcome changed: %s/%v %v", live, liveErr, u.effects) |
| 242 | } |
| 243 | } |
| 244 | |