package moves import ( "bytes" "encoding/json" "errors" "fmt" "io/fs" "os" "path/filepath" "strings" "sync" "time" ) // journalVersion is the only journal format this code reads or writes. A // file with any other version is refused, never reinterpreted. const journalVersion = 1 const journalFile = "journal.json" // Journal is the box-local record of how far one move operation has got on // this box (§9.1, §9.2). A step that is Done is never redone after a // restart; the value a step was marked with is whatever that step needs to // resume (a digest, an id, a timestamp). Values are internal state, not // operator copy. type Journal struct { V int `json:"v"` Host string `json:"host"` Op string `json:"op"` Phase string `json:"phase,omitempty"` Steps map[string]string `json:"steps,omitempty"` Attempt int `json:"attempt,omitempty"` UpdatedAt time.Time `json:"updated_at"` // changes are what this value changed since it was read or last // committed (Mark, Unmark), in order, and loadedPhase and loadedAttempt // the Phase and Attempt it was read with: what Commit applies. changes []journalChange loadedPhase string loadedAttempt int } // journalChange is one Mark (del false) or Unmark of a step. type journalChange struct { step, value string del bool } // A move's journal has more than one writer on a box: the pass that runs the // phase, the probe owners and attempts (Plan 6 fix round 1, ruling 3). Every // read-modify-write of one journal goes through UpdateJournal, which holds // that journal's lock, the one lock every writer in this process shares // (journalLock), across the read, the change and the write (fix round 2, // ruling 2). There is no plain write: a writer that read the journal earlier // commits its own changes (Commit), which UpdateJournal applies to the // journal as it is then, so nothing the writer read and did not change is // ever written back over another writer's. var journalLocks sync.Map // the journal's directory -> *sync.Mutex // journalLock is the lock of the journal of (host, op) under dataDir. func journalLock(dataDir, host, op string) *sync.Mutex { l, _ := journalLocks.LoadOrStore(filepath.Clean(JournalDir(dataDir, host, op)), &sync.Mutex{}) return l.(*sync.Mutex) } // UpdateJournal reads the journal of (host, op), calls fn with it and writes // it, all under the journal's lock. It makes the journal when there is none. // An error from fn writes nothing and is returned. fn must not call Commit. func UpdateJournal(dataDir, host, op string, fn func(*Journal) error) error { if err := journalNames(host, op); err != nil { return err } l := journalLock(dataDir, host, op) l.Lock() defer l.Unlock() j, err := ReadJournal(dataDir, host, op) if err != nil { return err } if err := fn(j); err != nil { return err } return j.write(dataDir) } // Commit makes j's own changes durable: through UpdateJournal, on the // journal as it is on disk now, it applies what j changed since it was read // or last committed (its Marks and Unmarks in order, and Phase and Attempt // if j changed them) and nothing else, and j becomes the journal as written. // A step another writer advanced or deleted after j was read stays as that // writer left it unless j itself marks it. func (j *Journal) Commit(dataDir string) error { var now *Journal err := UpdateJournal(dataDir, j.Host, j.Op, func(d *Journal) error { for _, c := range j.changes { if c.del { delete(d.Steps, c.step) } else { d.Steps[c.step] = c.value } } if j.Phase != j.loadedPhase { d.Phase = j.Phase } if j.Attempt != j.loadedAttempt { d.Attempt = j.Attempt } now = d return nil }) if err != nil { return err } j.V, j.Phase, j.Attempt, j.UpdatedAt = now.V, now.Phase, now.Attempt, now.UpdatedAt j.Steps = make(map[string]string, len(now.Steps)) for k, v := range now.Steps { j.Steps[k] = v } j.changes, j.loadedPhase, j.loadedAttempt = nil, j.Phase, j.Attempt return nil } // JournalDir is where one operation's journal lives on the box: // /.moves///. func JournalDir(dataDir, host, op string) string { return filepath.Join(dataDir, ".moves", host, op) } // journalName refuses a host or operation id that is empty or could leave // its directory. Both come from console-issued records, but the journal // path is built from them, so they are checked here as well. func journalName(kind, s string) error { if s == "" || s == "." || s == ".." || strings.ContainsAny(s, "/\\\x00") { return fmt.Errorf("moves journal: unusable %s %q", kind, s) } return nil } func journalNames(host, op string) error { if err := journalName("host", host); err != nil { return err } return journalName("op", op) } // JournalFile is the journal's file for (host, op): // /journal.json. Root reads it only through an anchored, // no-symlink open of a regular file (internal/host's held release), never // by name with ReadJournal. func JournalFile(dataDir, host, op string) string { return filepath.Join(JournalDir(dataDir, host, op), journalFile) } // ReadJournal loads the journal for (host, op). A journal that does not // exist yet is an empty one. A file that exists but cannot be trusted // (unreadable, not valid JSON, an unknown version, or naming a different // host or op) is an error and never an empty journal: starting over could // redo a step that already ran. func ReadJournal(dataDir, host, op string) (*Journal, error) { if err := journalNames(host, op); err != nil { return nil, err } b, err := os.ReadFile(JournalFile(dataDir, host, op)) if errors.Is(err, fs.ErrNotExist) { return &Journal{V: journalVersion, Host: host, Op: op, Steps: map[string]string{}}, nil } if err != nil { return nil, fmt.Errorf("moves journal %s/%s: %w", host, op, err) } return DecodeJournal(b, host, op) } // DecodeJournal is ReadJournal's judgement of a journal's bytes b, read by // the caller: one JSON object of this version naming exactly (host, op). func DecodeJournal(b []byte, host, op string) (*Journal, error) { if err := journalNames(host, op); err != nil { return nil, err } var j Journal dec := json.NewDecoder(bytes.NewReader(b)) if err := dec.Decode(&j); err != nil { return nil, fmt.Errorf("moves journal %s/%s is corrupt: %w", host, op, err) } if dec.More() { return nil, fmt.Errorf("moves journal %s/%s is corrupt: trailing data", host, op) } if j.V != journalVersion { return nil, fmt.Errorf("moves journal %s/%s: unknown version %d", host, op, j.V) } if j.Host != host || j.Op != op { return nil, fmt.Errorf("moves journal %s/%s names %s/%s", host, op, j.Host, j.Op) } if j.Steps == nil { j.Steps = map[string]string{} } j.loadedPhase, j.loadedAttempt = j.Phase, j.Attempt return &j, nil } // write stamps UpdatedAt and saves the journal whole: a temp file in the // same directory, fsync, rename over the old file, then fsync of the // directory, so a crash leaves either the old journal or the new one. Only // UpdateJournal calls it, with the journal's lock held since its read // (TestJournalHasNoPlainWrite). func (j *Journal) write(dataDir string) error { if err := journalNames(j.Host, j.Op); err != nil { return err } if j.V == 0 { j.V = journalVersion } if j.V != journalVersion { return fmt.Errorf("moves journal %s/%s: unknown version %d", j.Host, j.Op, j.V) } j.UpdatedAt = time.Now().UTC() b, err := json.Marshal(j) if err != nil { return err } dir := JournalDir(dataDir, j.Host, j.Op) created, err := missingDirs(dir) if err != nil { return err } if err := os.MkdirAll(dir, 0o700); err != nil { return err } // A directory this call created is only durable once its parent's // entry is, so sync the parent of each one, or a crash could lose the // whole chain with the journal inside it. for _, c := range created { if err := syncDir(filepath.Dir(c)); err != nil { return err } } tmp, err := os.CreateTemp(dir, journalFile+".tmp-*") if err != nil { return err } tmpName := tmp.Name() fail := func(err error) error { tmp.Close() os.Remove(tmpName) return err } if _, err := tmp.Write(b); err != nil { return fail(err) } if err := tmp.Sync(); err != nil { return fail(err) } if err := tmp.Close(); err != nil { os.Remove(tmpName) return err } if err := os.Rename(tmpName, filepath.Join(dir, journalFile)); err != nil { os.Remove(tmpName) return err } return syncDir(dir) } // missingDirs lists the directories at and above dir that do not exist yet, // deepest first: the ones MkdirAll is about to create. func missingDirs(dir string) ([]string, error) { var out []string for p := filepath.Clean(dir); ; p = filepath.Dir(p) { _, err := os.Lstat(p) if err == nil { return out, nil } if !errors.Is(err, fs.ErrNotExist) { return nil, err } out = append(out, p) if filepath.Dir(p) == p { return out, nil } } } func syncDir(dir string) error { d, err := os.Open(dir) if err != nil { return err } defer d.Close() return d.Sync() } // Done reports whether step has been marked. func (j *Journal) Done(step string) bool { _, ok := j.Steps[step] return ok } // Mark records step as done with value. It changes the in-memory journal // only; Commit makes it durable. func (j *Journal) Mark(step, value string) { if j.Steps == nil { j.Steps = map[string]string{} } j.Steps[step] = value j.changes = append(j.changes, journalChange{step: step, value: value}) } // Unmark removes step. It changes the in-memory journal only; Commit makes // it durable. func (j *Journal) Unmark(step string) { delete(j.Steps, step) j.changes = append(j.changes, journalChange{step: step, del: true}) }