carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package host
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "sync"
11 "testing"
12 "time"
13
14 "github.com/carlosframework/platform/internal/hostwrite"
15 "github.com/carlosframework/platform/internal/provision"
16 "github.com/carlosframework/platform/internal/registry"
17 "github.com/carlosframework/platform/internal/release"
18 "github.com/carlosframework/platform/internal/rootstate"
19)
20
21type rollbackDisk struct {
22 rootstate.Store
23 dir string
24 afterWrite func(string, []byte)
25 beforeWrite func(string, []byte)
26}
27
28func (s *rollbackDisk) Read(k string) ([]byte, error) { return os.ReadFile(filepath.Join(s.dir, k)) }
29func (s *rollbackDisk) Write(k string, b []byte) error {
30 if s.beforeWrite != nil {
31 s.beforeWrite(k, b)
32 }
33 f, err := os.CreateTemp(s.dir, "pending-")
34 if err != nil {
35 return err
36 }
37 n := f.Name()
38 defer os.Remove(n)
39 if _, err = f.Write(b); err == nil {
40 err = f.Sync()
41 }
42 err = errors.Join(err, f.Close())
43 if err != nil {
44 return err
45 }
46 if err = os.Rename(n, filepath.Join(s.dir, k)); err != nil {
47 return err
48 }
49 d, err := os.Open(s.dir)
50 if err != nil {
51 return err
52 }
53 err = errors.Join(d.Sync(), d.Close())
54 if err != nil {
55 return err
56 }
57 if s.afterWrite != nil {
58 s.afterWrite(k, b)
59 }
60 return nil
61}
62func (s *rollbackDisk) Remove(k string) error {
63 err := os.Remove(filepath.Join(s.dir, k))
64 if errors.Is(err, os.ErrNotExist) {
65 return nil
66 }
67 return err
68}
69func (s *rollbackDisk) Names() ([]string, error) {
70 es, err := os.ReadDir(s.dir)
71 var out []string
72 for _, e := range es {
73 out = append(out, e.Name())
74 }
75 return out, err
76}
77func (s *rollbackDisk) Lock(context.Context, string) (func(), error) { return func() {}, nil }
78func (s *rollbackDisk) Close() error { return nil }
79
80type rollbackProcessFixture struct {
81 Props unitProps
82 Exe, Opt, Bake, Store string
83 Effects []string
84}
85
86func TestPlan14RollbackProcessCrash(t *testing.T) {
87 t.Parallel()
88 if boundary := os.Getenv("PLAN14_CRASH_BOUNDARY"); boundary != "" {
89 dir := os.Getenv("PLAN14_CRASH_DIR")
90 e, rt, u, c, pub := plan14AdoptionFixture(t)
91 storeDir := filepath.Join(dir, "deployment")
92 store := release.NewDirStore(storeDir)
93 rec, _, err := provision.FindRecordByHost(t.Context(), e.store, rt.Host)
94 if err != nil {
95 t.Fatal(err)
96 }
97 plan14PutRecord(t, store, *rec)
98 disk := &rollbackDisk{dir: filepath.Join(dir, "attempts")}
99 if err := os.Mkdir(disk.dir, 0700); err != nil {
100 t.Fatal(err)
101 }
102 c.attempts = func() (rootstate.Store, error) { return disk, nil }
103 crash := func() {
104 b, err := json.Marshal(rollbackProcessFixture{Props: u.props, Exe: u.exe, Opt: u.opt, Bake: e.bake, Store: storeDir, Effects: u.effects})
105 if err != nil {
106 t.Fatal(err)
107 }
108 if err := os.WriteFile(filepath.Join(dir, "fixture.json"), b, 0600); err != nil {
109 t.Fatal(err)
110 }
111 os.Exit(73)
112 }
113 disk.afterWrite = func(k string, b []byte) {
114 if k != rollbackJournalKey(rt.Host) {
115 return
116 }
117 var a rollbackAttempt
118 if json.Unmarshal(b, &a) != nil {
119 return
120 }
121 if boundary == "captured" && !a.Failed && !a.Recovering && !a.Settled {
122 crash()
123 }
124 if boundary == "failed journal" && a.Failed && !a.Recovering && !a.Settled {
125 crash()
126 }
127 if boundary == "rollback pending" && a.Recovering && !a.Settled {
128 crash()
129 }
130 if boundary == "settled" && a.Settled {
131 crash()
132 }
133 }
134 u.crashNew = boundary != "forward running"
135 u.afterEffect = func() {
136 if (boundary == "forward restart" || boundary == "forward running") && len(u.effects) == 1 {
137 crash()
138 }
139 if boundary == "rollback running" && len(u.effects) == 3 {
140 crash()
141 }
142 }
143 forward := rootRestartRouteWith(e.root.reg, u, u.opt, func(string) string { return "5s" }, nil, c)
144 _ = release.Pass(t.Context(), e.root.reg, e.store, pub, u.opt, forward, release.WithRestartFailurePropagation(), release.WithRollbackRestart(rollbackUnitRestart(e.root.reg, u, u.opt, func(string) string { return "5s" }, c)))
145 t.Fatal("crash boundary not reached")
146 }
147 for _, boundary := range []string{"captured", "forward running", "forward restart", "failed journal", "rollback pending", "rollback running", "settled"} {
148 t.Run(boundary, func(t *testing.T) {
149 dir := t.TempDir()
150 ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
151 defer cancel()
152 cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestPlan14RollbackProcessCrash$", "-test.count=1")
153 cmd.Dir = dir
154 cmd.Env = append(os.Environ(), "TMPDIR="+dir, "PLAN14_CRASH_BOUNDARY="+boundary, "PLAN14_CRASH_DIR="+dir, "CARLOS_HOST_CLAIM=")
155 out, err := cmd.CombinedOutput()
156 var exit *exec.ExitError
157 if !errors.As(err, &exit) || exit.ExitCode() != 73 {
158 t.Fatalf("subprocess did not crash at %s: %v\n%s", boundary, err, out)
159 }
160 body, err := os.ReadFile(filepath.Join(dir, "fixture.json"))
161 if err != nil {
162 t.Fatal(err)
163 }
164 var f rollbackProcessFixture
165 if err := json.Unmarshal(body, &f); err != nil {
166 t.Fatal(err)
167 }
168 u := &plan14RestartUnits{props: f.Props, exe: f.Exe, opt: f.Opt, effects: f.Effects}
169 disk := &rollbackDisk{dir: filepath.Join(dir, "attempts")}
170 source := &proofBindingSource{store: release.NewDirStore(f.Store), sys: u, optDir: u.opt, openFile: os.Open, unitDirs: []string{filepath.Dir(u.props.FragmentPath)}, healthy: func(acct, app, ver, digest string) *release.HealthyBuild {
171 for _, b := range release.ReadHealthy(f.Bake, acct, app) {
172 if b.Version == ver && b.SHA256 == digest {
173 return &b
174 }
175 }
176 return nil
177 }}
178 a, err := readRollbackAttempt(disk, observedHost)
179 if err != nil {
180 t.Fatal(err)
181 }
182 now := a.Created.Add(time.Second)
183 c := rootRestartChecks{targetFile: os.Open, started: &sync.Map{}, attempts: func() (rootstate.Store, error) { return disk, nil }, prepareRollback: source.prepareRollback, rollback: source.rollback, host: source.forHost, ready: func(registry.Route) func() error { return func() error { return nil } }, now: func() time.Time { return now }, sleep: func(d time.Duration) { now = now.Add(d) }}
184 before := len(u.effects)
185 err = recoverRootRestarts(t.Context(), u, u.opt, func(string) string { return "5s" }, c, hostwrite.Config{})
186 if boundary == "captured" {
187 if err == nil || len(u.effects) != before {
188 t.Fatalf("unstarted update restarted: %v %v", err, u.effects)
189 }
190 } else {
191 if err != nil {
192 t.Fatal(err)
193 }
194 want := "v1"
195 if boundary == "forward running" {
196 want = "v2"
197 }
198 if u.exe != release.VersionPath(u.opt, "bab", want, "console") || u.props.MainPID <= 0 {
199 t.Fatalf("no recovered process: %+v", u)
200 }
201 if (boundary == "forward running" || boundary == "rollback running" || boundary == "settled") && len(u.effects) != before {
202 t.Fatalf("restarted twice: %v", u.effects)
203 }
204 }
205 a, err = readRollbackAttempt(disk, observedHost)
206 if err != nil || !a.Settled {
207 t.Fatalf("unsettled recovery: %+v %v", a, err)
208 }
209 if _, err := disk.Read(rollbackCapabilityKey(a.Host, a.ID)); !errors.Is(err, os.ErrNotExist) {
210 t.Fatal("capability survived recovery")
211 }
212 before = len(u.effects)
213 if err := recoverRootRestarts(t.Context(), u, u.opt, func(string) string { return "5s" }, c, hostwrite.Config{}); err != nil || len(u.effects) != before {
214 t.Fatalf("settled replay: %v %v", err, u.effects)
215 }
216 })
217 }
218}
219