carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package layout
2
3import (
4 "encoding/json"
5 "errors"
6 "os"
7 "path/filepath"
8 "testing"
9
10 "github.com/carlosframework/platform/internal/registry"
11)
12
13// presence9 is a presence vector in artifactNames order: db, db-wal, db-shm,
14// env, sidecar-env, config-env, socket, litestream-shadow, corrupt. The db
15// is marked present, since Validate now requires that of any journal it
16// accepts.
17var presence9 = []bool{true, false, false, true, false, true, false, false, false}
18
19func journalFor(t *testing.T, dd string, mode Mode) *Journal {
20 t.Helper()
21 p, err := PlanFor(dd, "/run/carlos", "h.example.com", mode)
22 if err != nil {
23 t.Fatal(err)
24 }
25 return p.Journal(presence9)
26}
27
28func TestJournalWriteIsExclusiveAndPhasesAdvance(t *testing.T) {
29 dd := t.TempDir()
30 j := journalFor(t, dd, Mode("nested"))
31 if err := WriteJournal(dd, j); err != nil {
32 t.Fatal(err)
33 }
34 if err := WriteJournal(dd, j); !errors.Is(err, ErrJournalExists) {
35 t.Fatalf("second write = %v, want ErrJournalExists", err)
36 }
37 got, found, err := ReadJournal(dd, "h.example.com")
38 if err != nil || !found || got.Phase != PhaseFiles || got.Mode != Mode("nested") || len(got.Artifacts) != 9 {
39 t.Fatalf("read back = %+v %v %v", got, found, err)
40 }
41 if err := UpdatePhase(dd, got, PhaseRow); err != nil {
42 t.Fatal(err)
43 }
44 got, _, _ = ReadJournal(dd, "h.example.com")
45 if got.Phase != PhaseRow {
46 t.Errorf("phase = %s", got.Phase)
47 }
48 if err := RemoveJournal(dd, "h.example.com"); err != nil {
49 t.Fatal(err)
50 }
51 if _, found, _ := ReadJournal(dd, "h.example.com"); found {
52 t.Error("journal survives removal")
53 }
54}
55
56// tempFilesIn lists the WriteJournal/UpdatePhase temp names (".<host>.tmp-*")
57// left in dir, so a test can assert none survive a call.
58func tempFilesIn(t *testing.T, dir string) []string {
59 t.Helper()
60 entries, err := os.ReadDir(dir)
61 if err != nil {
62 t.Fatal(err)
63 }
64 var names []string
65 for _, e := range entries {
66 if filepath.Ext(e.Name()) != ".json" {
67 names = append(names, e.Name())
68 }
69 }
70 return names
71}
72
73// TestWriteJournalRefusesAPreexistingFinalPath pins that the link-based
74// create refuses exactly like the O_EXCL it replaces, even when the file
75// at the final path was never written by WriteJournal itself (a hand
76// dropped file, or debris from something else): os.Link cannot overwrite
77// an existing name any more than O_EXCL can, so the caller can trust
78// ErrJournalExists means "something is already there" regardless of how it
79// got there.
80func TestWriteJournalRefusesAPreexistingFinalPath(t *testing.T) {
81 dd := t.TempDir()
82 dir := filepath.Join(dd, "layout")
83 if err := os.MkdirAll(dir, 0o700); err != nil {
84 t.Fatal(err)
85 }
86 final := filepath.Join(dir, "h.example.com.json")
87 if err := os.WriteFile(final, []byte("not a journal"), 0o600); err != nil {
88 t.Fatal(err)
89 }
90 j := journalFor(t, dd, Mode("nested"))
91 if err := WriteJournal(dd, j); !errors.Is(err, ErrJournalExists) {
92 t.Fatalf("WriteJournal over a pre-existing file = %v, want ErrJournalExists", err)
93 }
94 if got := tempFilesIn(t, dir); len(got) != 0 {
95 t.Errorf("temp files left behind after a refused write: %v", got)
96 }
97}
98
99// TestWriteJournalLeavesNoTempOnSuccess pins the other half of the same
100// contract: a successful write's temp name must not survive either, since
101// the whole point of linking rather than renaming in place was to prove
102// the final name's content durable before anything reads it, not to leave
103// a second, forgotten copy sitting beside it.
104func TestWriteJournalLeavesNoTempOnSuccess(t *testing.T) {
105 dd := t.TempDir()
106 j := journalFor(t, dd, Mode("nested"))
107 if err := WriteJournal(dd, j); err != nil {
108 t.Fatal(err)
109 }
110 if got := tempFilesIn(t, journalDir(dd)); len(got) != 0 {
111 t.Errorf("temp files left behind after a successful write: %v", got)
112 }
113}
114
115func TestReadJournalRefusesASymlinkAndAFIFO(t *testing.T) {
116 dd := t.TempDir()
117 dir := filepath.Join(dd, "layout")
118 os.MkdirAll(dir, 0o700)
119 target := filepath.Join(dd, "elsewhere.json")
120 os.WriteFile(target, []byte(`{"host":"h.example.com"}`), 0o600)
121 if err := os.Symlink(target, filepath.Join(dir, "h.example.com.json")); err != nil {
122 t.Skip(err)
123 }
124 if _, _, err := ReadJournal(dd, "h.example.com"); err == nil {
125 t.Error("a symlinked journal was read")
126 }
127}
128
129// TestReadJournalRefusesAHostMismatch pins that ReadJournal checks the
130// journal's own content against the path it was read from: a file whose
131// content names a different host than the file name itself is a sign
132// something replaced it (a copy, a stale hard link, a bug in whatever
133// wrote it) and must not be trusted for that host's recovery.
134func TestReadJournalRefusesAHostMismatch(t *testing.T) {
135 dd := t.TempDir()
136 dir := filepath.Join(dd, "layout")
137 if err := os.MkdirAll(dir, 0o700); err != nil {
138 t.Fatal(err)
139 }
140 j := journalFor(t, dd, Mode("nested"))
141 j.Host = "other.example.com"
142 raw, err := json.Marshal(j)
143 if err != nil {
144 t.Fatal(err)
145 }
146 if err := os.WriteFile(filepath.Join(dir, "h.example.com.json"), raw, 0o600); err != nil {
147 t.Fatal(err)
148 }
149 if _, _, err := ReadJournal(dd, "h.example.com"); err == nil {
150 t.Error("a journal whose content names a different host than its path was read")
151 }
152}
153
154func TestValidateChecksBothEndpointsFromTheJournalsOwnDirection(t *testing.T) {
155 dd := t.TempDir()
156 j := journalFor(t, dd, Mode("nested"))
157 flat := registry.Route{Host: "h.example.com", DBPath: j.From.DB, Socket: j.From.Socket}
158 nested := registry.Route{Host: "h.example.com", DBPath: j.To.DB, Socket: j.To.Socket}
159 if atFrom, err := Validate(j, dd, "/run/carlos", flat); err != nil || !atFrom {
160 t.Errorf("row at from: %v %v", atFrom, err)
161 }
162 if atFrom, err := Validate(j, dd, "/run/carlos", nested); err != nil || atFrom {
163 t.Errorf("row at to: %v %v", atFrom, err)
164 }
165 other := registry.Route{Host: "h.example.com", DBPath: "/data/other.db", Socket: j.From.Socket}
166 if _, err := Validate(j, dd, "/run/carlos", other); err == nil {
167 t.Error("a row at neither endpoint was accepted")
168 }
169 // A forged journal whose `to` points somewhere else is refused whatever the row says.
170 forged := *j
171 forged.To.DB = "/data/console/console.db"
172 if _, err := Validate(&forged, dd, "/run/carlos", flat); err == nil {
173 t.Error("a journal with a foreign destination was accepted")
174 }
175 forged = *j
176 forged.From.DB = "/data/console/console.db"
177 if _, err := Validate(&forged, dd, "/run/carlos", flat); err == nil {
178 t.Error("a journal with a foreign source was accepted")
179 }
180 // The flat direction swaps the two, and Validate derives from the mode.
181 r := journalFor(t, dd, Mode("flat"))
182 if atFrom, err := Validate(r, dd, "/run/carlos", nested); err != nil || !atFrom {
183 t.Errorf("revert journal, row at from (nested): %v %v", atFrom, err)
184 }
185}
186
187// TestValidateRefusesAWrongArtifactCount: a journal that predates the two
188// newest artifacts (or one that is simply malformed) must not be treated
189// as valid just because its endpoints happen to match.
190func TestValidateRefusesAWrongArtifactCount(t *testing.T) {
191 dd := t.TempDir()
192 j := journalFor(t, dd, Mode("nested"))
193 short := *j
194 short.Artifacts = j.Artifacts[:7]
195 row := registry.Route{Host: "h.example.com", DBPath: j.From.DB, Socket: j.From.Socket}
196 if _, err := Validate(&short, dd, "/run/carlos", row); err == nil {
197 t.Error("a journal with only 7 of the 9 artifacts was accepted")
198 }
199}
200
201// TestValidateRequiresTheDBArtifactPresent pins that a forged journal
202// cannot claim its db was never there and still be accepted: everything
203// else in an optional artifact list can be absent and the pass simply
204// skips it, but a journal with no db describes a move that cannot have
205// happened, whatever its endpoints say.
206func TestValidateRequiresTheDBArtifactPresent(t *testing.T) {
207 dd := t.TempDir()
208 j := journalFor(t, dd, Mode("nested"))
209 row := registry.Route{Host: "h.example.com", DBPath: j.From.DB, Socket: j.From.Socket}
210 forged := *j
211 forged.Artifacts = append([]JournalArtifact(nil), j.Artifacts...)
212 forged.Artifacts[0].Present = false
213 if _, err := Validate(&forged, dd, "/run/carlos", row); err == nil {
214 t.Error("a journal whose db artifact is not present was accepted")
215 }
216}
217
218// TestValidateRefusesAnUndefinedPhase pins that Validate only accepts the
219// three phases the pass itself ever writes: an empty or unrecognized phase
220// is a sign of a forged or corrupted journal, not one from an earlier
221// build that Validate should try to interpret.
222func TestValidateRefusesAnUndefinedPhase(t *testing.T) {
223 dd := t.TempDir()
224 j := journalFor(t, dd, Mode("nested"))
225 row := registry.Route{Host: "h.example.com", DBPath: j.From.DB, Socket: j.From.Socket}
226 for _, phase := range []Phase{"", "bogus"} {
227 forged := *j
228 forged.Phase = phase
229 if _, err := Validate(&forged, dd, "/run/carlos", row); err == nil {
230 t.Errorf("phase %q was accepted", phase)
231 }
232 }
233}
234
235func TestJournalPresent(t *testing.T) {
236 dd := t.TempDir()
237 if JournalPresent(dd, "h.example.com") {
238 t.Error("no journal written yet, JournalPresent = true")
239 }
240 j := journalFor(t, dd, Mode("nested"))
241 if err := WriteJournal(dd, j); err != nil {
242 t.Fatal(err)
243 }
244 if !JournalPresent(dd, "h.example.com") {
245 t.Error("journal written, JournalPresent = false")
246 }
247 if err := RemoveJournal(dd, "h.example.com"); err != nil {
248 t.Fatal(err)
249 }
250 if JournalPresent(dd, "h.example.com") {
251 t.Error("journal removed, JournalPresent = true")
252 }
253}
254
255// ReplaceJournal hands a host over from one journal to the next in one
256// rename: at every instant the final name holds one whole journal, so the
257// host is never without one.
258func TestReplaceJournalSwapsContentsInOneStep(t *testing.T) {
259 dd := t.TempDir()
260 old := journalFor(t, dd, ModeFlat)
261 if err := WriteJournal(dd, old); err != nil {
262 t.Fatal(err)
263 }
264 if err := UpdatePhase(dd, old, PhaseDone); err != nil {
265 t.Fatal(err)
266 }
267 next := journalFor(t, dd, ModeNested)
268 if err := ReplaceJournal(dd, old, next); err != nil {
269 t.Fatalf("ReplaceJournal: %v", err)
270 }
271 got, found, err := ReadJournal(dd, "h.example.com")
272 if err != nil || !found || got.Mode != ModeNested || got.Phase != PhaseFiles {
273 t.Fatalf("after replace: %+v %v %v", got, found, err)
274 }
275 if left := tempFilesIn(t, journalDir(dd)); len(left) != 0 {
276 t.Errorf("temp files left: %v", left)
277 }
278 if entries, _ := os.ReadDir(journalDir(dd)); len(entries) != 1 {
279 t.Errorf("journal dir holds %d entries, want exactly one", len(entries))
280 }
281}
282
283// The file replaced must be the journal the caller holds: anything else
284// there (a newer phase, another direction, nothing at all) is refused and
285// left exactly as it is.
286func TestReplaceJournalRefusesAnythingButTheHeldJournal(t *testing.T) {
287 dd := t.TempDir()
288 held := journalFor(t, dd, ModeFlat)
289 next := journalFor(t, dd, ModeNested)
290 if err := ReplaceJournal(dd, held, next); err == nil {
291 t.Fatal("replaced a journal that is not there")
292 }
293 if JournalPresent(dd, "h.example.com") {
294 t.Fatal("a journal was created where none was held")
295 }
296 if err := WriteJournal(dd, held); err != nil {
297 t.Fatal(err)
298 }
299 stale := *held
300 stale.Phase = PhaseDone
301 if err := ReplaceJournal(dd, &stale, next); err == nil {
302 t.Fatal("replaced a journal whose contents differ from the held one")
303 }
304 other := journalFor(t, dd, ModeNested)
305 other.Host = "o.example.com"
306 if err := ReplaceJournal(dd, held, other); err == nil {
307 t.Fatal("replaced one host's journal with another host's")
308 }
309 got, _, _ := ReadJournal(dd, "h.example.com")
310 if got.Mode != ModeFlat || got.Phase != PhaseFiles {
311 t.Fatalf("the held journal changed: %+v", got)
312 }
313 if left := tempFilesIn(t, journalDir(dd)); len(left) != 0 {
314 t.Errorf("temp files left: %v", left)
315 }
316}
317
318// JournalHosts names every host with a journal, and nothing for the temp
319// files WriteJournal and UpdatePhase leave while they work, or for a
320// directory that is not there.
321func TestJournalHostsIgnoresTempFiles(t *testing.T) {
322 dd := t.TempDir()
323 if hosts, err := JournalHosts(dd); err != nil || len(hosts) != 0 {
324 t.Fatalf("no directory: %v %v", hosts, err)
325 }
326 dir := journalDir(dd)
327 if err := os.MkdirAll(dir, 0o700); err != nil {
328 t.Fatal(err)
329 }
330 for _, n := range []string{".h.example.com.tmp-1", ".x.json", "notes.txt"} {
331 if err := os.WriteFile(filepath.Join(dir, n), nil, 0o600); err != nil {
332 t.Fatal(err)
333 }
334 }
335 if hosts, err := JournalHosts(dd); err != nil || len(hosts) != 0 {
336 t.Fatalf("temp files only: %v %v", hosts, err)
337 }
338 if err := os.WriteFile(filepath.Join(dir, "h.example.com.json"), []byte("{}"), 0o600); err != nil {
339 t.Fatal(err)
340 }
341 if hosts, err := JournalHosts(dd); err != nil || len(hosts) != 1 || hosts[0] != "h.example.com" {
342 t.Fatalf("one journal: %v %v", hosts, err)
343 }
344}
345