package host import ( "context" "encoding/json" "errors" "fmt" "io/fs" "log" "os" "path/filepath" "strings" "github.com/carlosframework/platform/internal/moves" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/release" ) // The move log's outbox (Plan 6 Task 3, fix round 2, ruling 1). An entry // whose write fails is kept on the box's disk, under // /.moves/.log-outbox///.json, with the entry's // exact bytes and the app it belongs to. Every pass retries every kept entry // (flushLogOutbox), whatever its move has done since, and deletes the file // only once the entry is created or found there. The directory's leading dot // keeps it apart from the moves journal's host directories. // moveLogOutboxDir is the outbox's root. func moveLogOutboxDir(dataDir string) string { return filepath.Join(dataDir, ".moves", ".log-outbox") } // moveLogPending is one kept entry: Entry is the bytes the log object holds // (json.Marshal of the moves.LogEvent, as moves.WriteLog writes it). type moveLogPending struct { V int `json:"v"` Account string `json:"account"` App string `json:"app"` Host string `json:"host"` Entry json.RawMessage `json:"entry"` } // outboxSegment refuses a path segment that is empty or could leave its // directory. func outboxSegment(s string) bool { return s != "" && s != "." && s != ".." && !strings.ContainsAny(s, "/\\\x00") } // queueLog keeps ev, for rec's app and host, in the outbox: written to a // temporary file, synced, and linked into place create-exclusive. The first // entry kept for an identity stays: a transition seen again after a restart // (built again, later) never replaces its bytes or its time (fix round 3, // ruling 4). func (w *movesWorker) queueLog(rec provision.Record, ev moves.LogEvent) error { if w.cfg.DataDir == "" { return errors.New("no data directory for the move log's outbox") } if !outboxSegment(rec.Host) || !outboxSegment(ev.Op) || !outboxSegment(ev.Identity) { return fmt.Errorf("an unusable move log entry name: %s/%s/%s", rec.Host, ev.Op, ev.Identity) } entry, err := json.Marshal(ev) if err != nil { return err } body, err := json.Marshal(moveLogPending{V: 1, Account: rec.Account, App: rec.App, Host: rec.Host, Entry: entry}) if err != nil { return err } _, err = outboxCreate(w.cfg.DataDir, filepath.Join(moveLogOutboxDir(w.cfg.DataDir), rec.Host, ev.Op), ev.Identity+".json", body) return err } // outboxCreate keeps body as dir/name, create-exclusive: written to a // temporary file, synced, and linked into place, so a crash leaves the whole // file or none. created is false, with no error, when dir/name was already // there: the first one kept stays, and an existing file is found before // anything is written, so storage that has turned full or read-only still // answers for it (Plan 6 Tasks 4+5 fix round 3). The move log's outbox and // the page outbox (movespaging.go) share it. // // base is the data directory the outbox lives under. Nothing is // acknowledged until it is durable (final review I2): every directory the // call creates is synced into its parent, and the entry's directory and // each one above it up to base are synced after the link, any failure // returned. An entry found already there may be an earlier try's whose sync // failed after the link, so it counts only once it, its directory and those // above it are synced now. func outboxCreate(base, dir, name string, body []byte) (created bool, err error) { if err := outboxBase(base); err != nil { return false, err } path := filepath.Join(dir, name) if _, err := os.Lstat(path); err == nil { return false, outboxSettle(base, dir, path) } fresh, err := outboxMissingDirs(dir) if err != nil { return false, err } if err := os.MkdirAll(dir, 0o700); err != nil { return false, err } for _, c := range fresh { if err := outboxSyncPath(filepath.Dir(c)); err != nil { return false, err } } tmp, err := os.CreateTemp(dir, ".pending-*") if err != nil { return false, err } defer os.Remove(tmp.Name()) if _, err := tmp.Write(body); err != nil { tmp.Close() return false, err } if err := tmp.Sync(); err != nil { tmp.Close() return false, err } if err := tmp.Close(); err != nil { return false, err } if err := os.Link(tmp.Name(), path); err != nil { if errors.Is(err, fs.ErrExist) { return false, outboxSettle(base, dir, path) } return false, err } if err := outboxSyncChain(base, dir); err != nil { return true, err } return true, nil } // outboxBase refuses a data directory that is not absolute and clean: the // ancestor walk (outboxSyncChain) ends only at such a root. The worker // resolves its own once, at construction (movesDataDir). func outboxBase(base string) error { if !filepath.IsAbs(base) || filepath.Clean(base) != base { return fmt.Errorf("the outbox's data directory %q is not an absolute, clean path", base) } return nil } // outboxSync syncs one open file or directory of an outbox. It is a // variable only so the host's tests can fail it; nothing in production // configuration reaches it. var outboxSync = func(f *os.File) error { return f.Sync() } // outboxSyncPath syncs the file or directory at p. func outboxSyncPath(p string) error { f, err := os.Open(p) if err != nil { return err } serr := outboxSync(f) if cerr := f.Close(); serr == nil { serr = cerr } return serr } // outboxSyncChain syncs dir and each directory above it up to and // including base, so the entries naming them are all on disk. A dir outside // base is synced alone. func outboxSyncChain(base, dir string) error { base, dir = filepath.Clean(base), filepath.Clean(dir) for p := dir; ; { if err := outboxSyncPath(p); err != nil { return err } q := filepath.Dir(p) if p == base || q == p || !outboxWithin(base, q) { return nil } p = q } } // outboxWithin reports whether p is base or under it. func outboxWithin(base, p string) bool { if base == "" || base == "." { return false } rel, err := filepath.Rel(base, p) return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) } // outboxSettle makes an entry found on disk durable before it counts: the // file, its directory and those above it up to base. func outboxSettle(base, dir, path string) error { if err := outboxSyncPath(path); err != nil { return err } return outboxSyncChain(base, dir) } // outboxMissingDirs lists the directories at and above dir that do not // exist yet, deepest first: the ones MkdirAll is about to create. func outboxMissingDirs(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 } } } // outboxReplace replaces dir/name with body: a temporary file, synced and // renamed over it, so a crash leaves the old file or the new one. Like // outboxCreate, it succeeds only once the rename is durable: dir and each // directory above it up to base synced (final review I2). func outboxReplace(base, dir, name string, body []byte) error { if err := outboxBase(base); err != nil { return err } tmp, err := os.CreateTemp(dir, ".pending-*") if err != nil { return err } defer os.Remove(tmp.Name()) if _, err := tmp.Write(body); err != nil { tmp.Close() return err } if err := tmp.Sync(); err != nil { tmp.Close() return err } if err := tmp.Close(); err != nil { return err } if err := os.Rename(tmp.Name(), filepath.Join(dir, name)); err != nil { return err } return outboxSyncChain(base, dir) } // flushLogOutbox retries every kept entry through the LogEvent hook, into s. // A written entry's file is deleted; one that fails again stays. A file that // cannot be read as an entry is logged and deleted: it can never be written. func (w *movesWorker) flushLogOutbox(ctx context.Context, s release.Store) { f := w.cfg.LogEvent if w.cfg.DataDir == "" || f == nil || s == nil { return } root := moveLogOutboxDir(w.cfg.DataDir) err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { if errors.Is(err, fs.ErrNotExist) && p == root { return fs.SkipAll } if err != nil { return err } if d.IsDir() || !strings.HasSuffix(d.Name(), ".json") || strings.HasPrefix(d.Name(), ".") { return nil } if err := ctx.Err(); err != nil { return err } w.flushLogFile(ctx, s, f, p) return nil }) if err != nil { log.Printf("moves: the move log's outbox: %v", err) } pruneEmptyDirs(root) } // flushLogFile retries one kept entry. func (w *movesWorker) flushLogFile(ctx context.Context, s release.Store, f func(context.Context, release.Store, provision.Record, moves.LogEvent) error, p string) { body, err := os.ReadFile(p) if err != nil { log.Printf("moves: the move log's outbox: %v", err) return } var kept moveLogPending var ev moves.LogEvent if err := json.Unmarshal(body, &kept); err != nil || kept.V != 1 || json.Unmarshal(kept.Entry, &ev) != nil || ev.Identity+".json" != filepath.Base(p) || ev.Op != filepath.Base(filepath.Dir(p)) || kept.Host != filepath.Base(filepath.Dir(filepath.Dir(p))) { log.Printf("moves: the move log's outbox: %s is not a kept entry; deleted", p) _ = os.Remove(p) return } if err := f(ctx, s, provision.Record{Account: kept.Account, App: kept.App, Host: kept.Host}, ev); err != nil { log.Printf("moves: %s: the log of %s at %s is kept for the next pass: %v", kept.Host, ev.Op, ev.Identity, err) return } if err := os.Remove(p); err != nil { log.Printf("moves: the move log's outbox: %v", err) } } // pruneEmptyDirs removes the outbox's empty host and move directories. func pruneEmptyDirs(root string) { hosts, err := os.ReadDir(root) if err != nil { return } for _, h := range hosts { if !h.IsDir() { continue } hd := filepath.Join(root, h.Name()) ops, _ := os.ReadDir(hd) for _, o := range ops { if o.IsDir() { _ = os.Remove(filepath.Join(hd, o.Name())) // only if empty } } _ = os.Remove(hd) } }