package host import ( "context" "encoding/json" "errors" "os" "testing" "time" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/rootstate" ) type rollbackMemory struct { rootstate.Store files map[string][]byte failWrite bool } func (m *rollbackMemory) Read(k string) ([]byte, error) { b, ok := m.files[k] if !ok { return nil, os.ErrNotExist } return append([]byte(nil), b...), nil } func (m *rollbackMemory) Write(k string, b []byte) error { if m.failWrite { return os.ErrPermission } m.files[k] = append([]byte(nil), b...) return nil } func (m *rollbackMemory) Remove(k string) error { delete(m.files, k); return nil } func (m *rollbackMemory) Lock(context.Context, string) (func(), error) { return func() {}, nil } func (m *rollbackMemory) Close() error { return nil } func TestPlan14RollbackCapability(t *testing.T) { t.Parallel() for _, attack := range []string{"crash", "unit identity", "etag", "missing", "stale", "later process", "settled", "operator stopped", "wrong link", "bad executable", "failed restart result", "missing journal", "different attempt", "capability attempt", "spent", "capture write"} { t.Run(attack, func(t *testing.T) { e, rt, u, c, pub := plan14AdoptionFixture(t) u.crashNew = true mem := &rollbackMemory{files: map[string][]byte{}} c.attempts = func() (rootstate.Store, error) { return mem, nil } if attack == "capture write" { mem.failWrite = true } forward := rootRestartRouteWith(e.root.reg, u, u.opt, func(string) string { return "5s" }, e.root.obs.recordSettled(e.bake), c) restart := func(host string) error { err := forward(host) key := rollbackJournalKey(host) var a rollbackAttempt b, _ := mem.Read(key) _ = json.Unmarshal(b, &a) switch attack { case "unit identity": b, err := os.ReadFile(u.props.FragmentPath) if err != nil { t.Fatal(err) } if err = os.Rename(u.props.FragmentPath, u.props.FragmentPath+".old"); err != nil { t.Fatal(err) } if err = os.WriteFile(u.props.FragmentPath, b, 0644); err != nil { t.Fatal(err) } case "etag": rec, _, err := provision.FindRecordByHost(context.Background(), e.store, host) if err != nil { t.Fatal(err) } rec.RecordSeq++ plan14PutRecord(t, e.store, *rec) case "missing": delete(mem.files, rollbackCapabilityKey(host, a.ID)) case "stale": c.now = func() time.Time { return a.Deadline.Add(rollbackMargin + time.Second) } case "settled": a.Settled = true case "operator stopped": u.props.ActiveState, u.props.SubState = "inactive", "dead" case "bad executable": u.badRollback = true case "failed restart result": u.props.ActiveState, u.props.SubState = "active", "running" u.failNew = true case "missing journal": delete(mem.files, key) case "different attempt": a.ID = "different" case "capability attempt": var capability rollbackCapability raw, _ := mem.Read(rollbackCapabilityKey(host, a.ID)) if e := json.Unmarshal(raw, &capability); e != nil { t.Fatal(e) } capability.Attempt.ID = "another-attempt" raw, _ = json.Marshal(capability) if e := mem.Write(rollbackCapabilityKey(host, a.ID), raw); e != nil { t.Fatal(e) } case "spent": a.Recovering = true } if attack == "settled" || attack == "different attempt" || attack == "spent" { b, _ := json.Marshal(a) _ = mem.Write(key, b) } return err } if attack == "failed restart result" { u.failNew = true u.crashNew = false } var rollbackErr error err := release.Pass(context.Background(), e.root.reg, e.store, pub, u.opt, restart, release.BakeDir(e.bake), release.WithRestartFailurePropagation(), release.WithRollbackRestart(func(host string) error { if attack == "wrong link" { if err := release.PointLive(u.opt, host, "bab", "v2", "console"); err != nil { t.Fatal(err) } } // Reconstruct the callback after the forward operation, as another root process does. rollbackErr = rollbackUnitRestart(e.root.reg, u, u.opt, func(string) string { return "5s" }, c)(host) return rollbackErr })) positive := attack == "missing" || attack == "stale" || attack == "settled" || attack == "missing journal" || attack == "crash" || attack == "later process" || attack == "failed restart result" || attack == "spent" if err == nil { t.Fatal("forward failure disappeared") } if positive { if rollbackErr != nil || len(u.effects) != 3 || u.props.MainPID <= 0 { t.Fatalf("recovery: %v, %+v", rollbackErr, u) } } else if rollbackErr == nil && attack != "capture write" { t.Fatalf("%s accepted", attack) } if !positive && attack != "bad executable" && len(u.effects) > 1 { t.Fatalf("unauthorized restart: %v", u.effects) } if attack == "capture write" && len(u.effects) != 0 { t.Fatal("restart without durable capture") } if len(release.ReadHealthy(e.bake, rt.Account, rt.App)) != 1 { t.Fatal("recorded failed build") } if errors.Is(rollbackErr, os.ErrNotExist) { t.Log("absent capability refused") } for k := range mem.files { if k != rollbackJournalKey(rt.Host) && k != rollbackHostKey(rt.Host)+".previous" { t.Fatalf("capability survived settlement: %s", k) } } }) } } func (m *rollbackMemory) Names() ([]string, error) { var names []string for k := range m.files { names = append(names, k) } return names, nil } func TestPlan14RollbackAttemptSettles(t *testing.T) { t.Parallel() for _, failure := range []bool{false, true} { t.Run(map[bool]string{false: "success", true: "terminal"}[failure], func(t *testing.T) { e, rt, u, c, pub := plan14AdoptionFixture(t) mem := &rollbackMemory{files: map[string][]byte{}} c.attempts = func() (rootstate.Store, error) { return mem, nil } forward := rootRestartRouteWith(e.root.reg, u, u.opt, func(string) string { return "5s" }, e.root.obs.recordSettled(e.bake), c) if failure { u.crashNew = true } err := release.Pass(context.Background(), e.root.reg, e.store, pub, u.opt, forward, release.BakeDir(e.bake), release.WithRestartFailurePropagation(), release.WithRestartAttemptSettled(c.finish)) if (err != nil) != failure { t.Fatalf("pass: %v", err) } a, err := readRollbackAttempt(mem, rt.Host) if err != nil || !a.Settled { t.Fatalf("unsettled: %+v %v", a, err) } if _, err := mem.Read(rollbackCapabilityKey(rt.Host, a.ID)); !errors.Is(err, os.ErrNotExist) { t.Fatal("capability retained") } }) } }