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 "fmt"
7 "io"
8 "os"
9 "path/filepath"
10 "reflect"
11 "strings"
12 "time"
13
14 "github.com/carlosframework/platform/internal/provision"
15 "github.com/carlosframework/platform/internal/registry"
16)
17
18// Phase is how far a move got. files: sources are being renamed; row: the
19// files are at their destinations and the row is being swapped; done: the
20// row is swapped and only the journal remains.
21type Phase string
22
23const (
24 PhaseFiles Phase = "files"
25 PhaseRow Phase = "row"
26 PhaseDone Phase = "done"
27)
28
29// Journal is /data/layout/<host>.json (design §8.2 step 6). It records
30// presence so recovery can tell an optional artifact that was absent from
31// one that was lost (R2-7). It is NOT an instruction (§3.3): Validate
32// re-derives both endpoints from the host and the mode and refuses a
33// journal that names anything else.
34type Journal struct {
35 Host string `json:"host"`
36 Mode Mode `json:"mode"`
37 From Endpoint `json:"from"`
38 To Endpoint `json:"to"`
39 Artifacts []JournalArtifact `json:"artifacts"`
40 // Extra is the app-made entries moved with the host (extras.go), by
41 // base name. Absent in journals written before #256, which moved
42 // none. Each is re-derived from the host and the name on recovery
43 // and must pass extraNameOK, so the journal cannot direct a move
44 // PlanFor and ScanExtras would not have chosen.
45 Extra []JournalArtifact `json:"extra,omitempty"`
46 Phase Phase `json:"phase"`
47 WrittenAt string `json:"written_at"`
48}
49
50// extraNames is the recorded extra entries' names, in journal order.
51func (j *Journal) extraNames() []string {
52 var names []string
53 for _, e := range j.Extra {
54 names = append(names, e.Name)
55 }
56 return names
57}
58
59// JournalArtifact records one artifact's presence at the time the journal
60// was written. The path itself is not stored: it is re-derived by
61// PlanFor from the host and mode on every read, so a journal can never be
62// used to redirect a rename anywhere PlanFor itself would not have chosen.
63type JournalArtifact struct {
64 Name string `json:"name"`
65 Present bool `json:"present"`
66}
67
68// ErrJournalExists is WriteJournal's answer when a journal is already on
69// disk for the host.
70var ErrJournalExists = errors.New("layout: a journal already exists for this host")
71
72// journalMax bounds how much of a journal file ReadJournal will read. A
73// journal is a handful of paths and booleans; anything past this is either
74// corrupt or something else entirely.
75const journalMax = 64 << 10
76
77func journalDir(dataDir string) string { return filepath.Join(dataDir, "layout") }
78
79// syncDir fsyncs a directory. A file's own Sync makes its bytes durable,
80// but the directory entry that gives those bytes a name (a hard link, a
81// rename) is separate metadata: on ext4 with delayed allocation, a crash
82// between an otherwise-successful Link or Rename and this call can still
83// lose the name while the bytes it would have pointed to survive under
84// their old one.
85func syncDir(dir string) error {
86 d, err := os.Open(dir)
87 if err != nil {
88 return err
89 }
90 defer d.Close()
91 return d.Sync()
92}
93
94// WriteJournal creates the journal exactly once. An existing file, whoever
95// wrote it, is a refusal: the pass never overwrites a record of a move it
96// did not make.
97//
98// It writes the content to a temp file, syncs that file, then links it to
99// the final name: os.Link fails with EEXIST exactly as O_EXCL would,
100// without ever making a partly written journal visible at the final path.
101// Without the temp+sync+link, an O_EXCL create-and-write can leave an
102// empty file at the final name durable while its content is not (a power
103// loss, or a kill between OpenFile and Write). Recovery then finds a
104// journal with no content, refuses it, and the host stays fenced with
105// nothing to read to explain why.
106func WriteJournal(dataDir string, j *Journal) error {
107 dir := journalDir(dataDir)
108 if err := os.MkdirAll(dir, 0o700); err != nil {
109 return err
110 }
111 if j.WrittenAt == "" {
112 j.WrittenAt = time.Now().UTC().Format(time.RFC3339)
113 }
114 tmpName, err := writeTemp(dir, j)
115 if err != nil {
116 return err
117 }
118 linkErr := os.Link(tmpName, provision.LayoutJournalPath(dataDir, j.Host))
119 // The temp file's only job was to become the final name; whether that
120 // succeeded or not, its own name must not linger (nothing reads a
121 // ".tmp-" name, but a stray one left after a failed Link would look
122 // like leftover corruption to anyone auditing the directory by hand).
123 os.Remove(tmpName)
124 if linkErr != nil {
125 if errors.Is(linkErr, os.ErrExist) {
126 return ErrJournalExists
127 }
128 return linkErr
129 }
130 return syncDir(dir)
131}
132
133// ReadJournal reads the host's journal without following a symlink and
134// without blocking on a special file: /data/layout is agent-owned, but a
135// flat tenant shares the agent's uid during the transition and can plant
136// things there.
137func ReadJournal(dataDir, host string) (*Journal, bool, error) {
138 path := provision.LayoutJournalPath(dataDir, host)
139 f, err := os.OpenFile(path, os.O_RDONLY|syscallNofollow|syscallNonblock, 0)
140 if errors.Is(err, os.ErrNotExist) {
141 return nil, false, nil
142 }
143 if err != nil {
144 return nil, false, err
145 }
146 defer f.Close()
147 fi, err := f.Stat()
148 if err != nil {
149 return nil, false, err
150 }
151 if !fi.Mode().IsRegular() {
152 return nil, false, fmt.Errorf("layout: %s is not a regular file", path)
153 }
154 raw, err := io.ReadAll(io.LimitReader(f, journalMax+1))
155 if err != nil {
156 return nil, false, err
157 }
158 if len(raw) > journalMax {
159 return nil, false, fmt.Errorf("layout: %s is larger than %d bytes", path, journalMax)
160 }
161 var j Journal
162 if err := json.Unmarshal(raw, &j); err != nil {
163 return nil, false, fmt.Errorf("layout: %s: %w", path, err)
164 }
165 if j.Host != host {
166 return nil, false, fmt.Errorf("layout: %s: content names host %q, path names %q", path, j.Host, host)
167 }
168 return &j, true, nil
169}
170
171// UpdatePhase rewrites the journal with a new phase through a temp file and
172// rename in the same directory, so a crash leaves either the old phase or
173// the new one, never a torn file. It marshals a copy carrying the new
174// phase, not j itself, so a caller that inspects j after an error sees the
175// phase the journal on disk actually has, not one that was never made
176// durable.
177func UpdatePhase(dataDir string, j *Journal, p Phase) error {
178 next := *j
179 next.Phase = p
180 dir := journalDir(dataDir)
181 name, err := writeTemp(dir, &next)
182 if err != nil {
183 return err
184 }
185 if err := os.Rename(name, provision.LayoutJournalPath(dataDir, j.Host)); err != nil {
186 os.Remove(name)
187 return err
188 }
189 // Only now, with the rename itself having succeeded, does the phase
190 // this call promised become true of both the in-memory journal and
191 // the one on disk.
192 j.Phase = p
193 return syncDir(dir)
194}
195
196// writeTemp writes j to a fresh temp file in dir and syncs it, returning
197// its name. The sync comes before the caller links or renames the file to
198// the journal's canonical name: that makes these bytes visible there, so
199// they must already be durable, or a crash just after could leave the name
200// pointing at content that never landed.
201func writeTemp(dir string, j *Journal) (string, error) {
202 raw, err := json.MarshalIndent(j, "", " ")
203 if err != nil {
204 return "", err
205 }
206 tmp, err := os.CreateTemp(dir, "."+j.Host+".tmp-*")
207 if err != nil {
208 return "", err
209 }
210 name := tmp.Name()
211 if _, err := tmp.Write(append(raw, '\n')); err != nil {
212 tmp.Close()
213 os.Remove(name)
214 return "", err
215 }
216 if err := tmp.Sync(); err != nil {
217 tmp.Close()
218 os.Remove(name)
219 return "", err
220 }
221 if err := tmp.Close(); err != nil {
222 os.Remove(name)
223 return "", err
224 }
225 return name, nil
226}
227
228// ReplaceJournal hands a host over from the journal the caller holds to a
229// new one in a single rename, so the host is never without a journal: a
230// crash, or a failure anywhere here, leaves either the whole old journal
231// or the whole new one at the canonical name. Removing the old one and
232// then creating the new one would leave a gap in which nothing fences the
233// host, and a same-uid tenant that can make <data>/layout unwritable at
234// the right moment could hold that gap open.
235//
236// The file replaced must be exactly held, as ReadJournal reads it back;
237// anything else there is refused and left alone. No exclusive create is
238// needed on the final name: the caller holds both the host's fence and
239// the section config sync is locked out of, and it has just checked what
240// it is replacing.
241func ReplaceJournal(dataDir string, held, next *Journal) error {
242 if next.Host != held.Host {
243 return fmt.Errorf("layout: replacing %s's journal with one for %s", held.Host, next.Host)
244 }
245 cur, found, err := ReadJournal(dataDir, held.Host)
246 if err != nil {
247 return err
248 }
249 if !found {
250 return fmt.Errorf("layout: %s: no journal to replace", held.Host)
251 }
252 if !reflect.DeepEqual(cur, held) {
253 return fmt.Errorf("layout: %s: the journal on disk is not the one this pass holds; not replacing it", held.Host)
254 }
255 if next.WrittenAt == "" {
256 next.WrittenAt = time.Now().UTC().Format(time.RFC3339)
257 }
258 dir := journalDir(dataDir)
259 name, err := writeTemp(dir, next)
260 if err != nil {
261 return err
262 }
263 if err := os.Rename(name, provision.LayoutJournalPath(dataDir, next.Host)); err != nil {
264 os.Remove(name)
265 return err
266 }
267 return syncDir(dir)
268}
269
270// RemoveJournal deletes the host's journal. Removing an absent journal is
271// not an error: callers reach this after a move is already complete, and a
272// second call (a retried recovery, say) must be idempotent.
273func RemoveJournal(dataDir, host string) error {
274 err := os.Remove(provision.LayoutJournalPath(dataDir, host))
275 if errors.Is(err, os.ErrNotExist) {
276 return nil
277 }
278 return err
279}
280
281// JournalHosts names every host with a journal in <data>/layout, in the
282// directory's order. Dot names are WriteJournal's and UpdatePhase's temp
283// files, and are not journals. A directory that is not there holds none.
284func JournalHosts(dataDir string) ([]string, error) {
285 entries, err := os.ReadDir(journalDir(dataDir))
286 if errors.Is(err, os.ErrNotExist) {
287 return nil, nil
288 }
289 if err != nil {
290 return nil, err
291 }
292 var hosts []string
293 for _, e := range entries {
294 if n := e.Name(); !strings.HasPrefix(n, ".") && strings.HasSuffix(n, ".json") {
295 hosts = append(hosts, strings.TrimSuffix(n, ".json"))
296 }
297 }
298 return hosts, nil
299}
300
301// JournalPresent reports whether a journal exists for host, without
302// reading it. Later tasks use this everywhere a caller needs to know a
303// host is "fenced" by an in-flight move but has no reason to read the
304// journal's contents (design §8.1's LayoutFenced, SyncExec's skip, the
305// litestream fenced hook). An Lstat error other than "not exist" (a
306// permission problem, say) is treated as present: silently proceeding
307// past a journal this process cannot even stat is worse than refusing.
308func JournalPresent(dataDir, host string) bool {
309 return provision.LayoutJournalExists(dataDir, host)
310}
311
312// Validate is the recovery check of design §8.2: the journal's endpoints
313// must be exactly what PlanFor derives for its host and mode, and the row
314// must sit at one of them. It returns whether the row is at `from` (the
315// CAS has not run) or at `to` (it has).
316func Validate(j *Journal, dataDir, socketDir string, row registry.Route) (atFrom bool, err error) {
317 if j.Host != row.Host {
318 return false, fmt.Errorf("layout: journal is for %s, row is %s", j.Host, row.Host)
319 }
320 switch j.Phase {
321 case PhaseFiles, PhaseRow, PhaseDone:
322 default:
323 return false, fmt.Errorf("layout: %s: journal phase %q is not files, row or done; not acting", j.Host, j.Phase)
324 }
325 p, err := PlanFor(dataDir, socketDir, j.Host, j.Mode)
326 if err != nil {
327 return false, err
328 }
329 if j.From != p.From || j.To != p.To {
330 return false, fmt.Errorf("layout: %s: journal endpoints %+v -> %+v are not the derived %+v -> %+v; not acting", j.Host, j.From, j.To, p.From, p.To)
331 }
332 if len(j.Artifacts) != len(artifactNames) {
333 return false, fmt.Errorf("layout: %s: journal lists %d artifacts, expected %d", j.Host, len(j.Artifacts), len(artifactNames))
334 }
335 for i, a := range j.Artifacts {
336 if a.Name != artifactNames[i] {
337 return false, fmt.Errorf("layout: %s: journal artifact %d is %q, expected %q", j.Host, i, a.Name, artifactNames[i])
338 }
339 }
340 // The db is the one artifact recovery cannot proceed without: every
341 // other artifact missing is a smaller, optional file the pass would
342 // simply skip, but a journal claiming its db was never even there
343 // describes a move that cannot have happened.
344 if !j.Artifacts[0].Present {
345 return false, fmt.Errorf("layout: %s: journal's db artifact is not marked present; not acting", j.Host)
346 }
347 // The extra names pass the same rule a scan applies, and an extra is
348 // never absent: the plan carries one because it was seen.
349 if err := p.AddExtras(j.extraNames()); err != nil {
350 return false, fmt.Errorf("%w; not acting", err)
351 }
352 for _, e := range j.Extra {
353 if !e.Present {
354 return false, fmt.Errorf("layout: %s: journal records extra %q absent; not acting", j.Host, e.Name)
355 }
356 }
357 switch (Endpoint{DB: row.DBPath, Socket: row.Socket}) {
358 case j.From:
359 return true, nil
360 case j.To:
361 return false, nil
362 }
363 return false, fmt.Errorf("layout: %s: row holds db %q socket %q, at neither journal endpoint; not acting", j.Host, row.DBPath, row.Socket)
364}
365