package layout import ( "errors" "fmt" "os" "path/filepath" "sort" "strings" "github.com/carlosframework/platform/internal/controlname" ) // The entries an app makes for itself (discussion #256). // // The plan's nine artifacts are the files the PLATFORM names for a host. // A flat instance was started with cwd / and `--db /data/.db`, and // an app that derives a directory from its database path makes more: // bdn/ips kept /data//instance.key and /data//report.token // and a /data/-blobs directory beside its database. The first // migration moved the nine names and left those behind, the app started // nested, looked for them under its new database's directory, found // nothing, and minted a fresh key. Three apps did, on the flagship, on // the first day. // // So a move to nested takes every entry in the data directory that the // flat layout's naming rules make the host's, into the instance // directory at the same relative name, where `filepath.Dir(dbPath)` now // points (nested the database is /.db, and the // instance sees that directory as /data/instances/); a revert brings // the same names back. Nothing the host could own is left behind // silently: an entry that cannot be moved safely refuses the move with // its name in the reason. These entries are scanned, not derived, so they // are recorded in the journal by name and re-derived from the host and // the name on recovery; a name a journal records must still pass the // same rules (extraNameOK), so a journal cannot name a move the pass // would not have chosen itself. // // The naming rules are the flat layout's own. A file is 's when it // is itself, plus a dot or a dash and more (.db, // -sidecar.env, -blobs), or the activator's own prefix form // of those (.activator-.db-litestream). A name can match more than // one host on the box (`a.b.oncarlos.com-x` is `a.b.oncarlos.com` plus // `-x`, and also the host `a.b.oncarlos.com-x` itself), so an entry // belongs to the LONGEST host on the box whose rule it matches, and a // pass moves only what belongs to its own host. That is exact for every // host the box has a row for. A name that matches no row is not moved by // anyone and is reported (UnattributedEntries), because a shared // directory such as /data/blobs, written by every flat app whose cwd was // /, cannot be given to one host. // belongsTo reports whether name is one the flat layout's rules make // host's: the host itself, or the host with a platform or app suffix. func belongsTo(name, host string) bool { if name == host { return true } for _, prefix := range []string{host + ".", host + "-", ".activator-" + host + ".", ".activator-" + host + "-"} { if strings.HasPrefix(name, prefix) { return true } } return false } // ownerOf is the longest host among hosts whose rules name matches, or // "" when none does. func ownerOf(name string, hosts []string) string { owner := "" for _, h := range hosts { if len(h) > len(owner) && belongsTo(name, h) { owner = h } } return owner } // fixedNames is the base names of the plan's platform-named artifacts // (the same on both sides of a move; the socket is never a file the pass // moves and has a different name nested, so it is left out). func fixedNames(p *Plan) map[string]bool { names := map[string]bool{} for _, a := range p.Artifacts { if a.Extra || a.Name == "socket" { continue } names[filepath.Base(a.Src)] = true names[filepath.Base(a.Dst)] = true } return names } // fixedSuffixes are the platform's per-host file shapes under the flat // layout, X plus suffix (flatFiles), longest first so ".db.corrupt" is // read before ".db" and ".config.env" before ".env". var fixedSuffixes = []string{".db.corrupt", ".config.env", "-sidecar.env", ".db-wal", ".db-shm", ".db", ".env"} // fixedShapeHost reads the host X out of a name that has one of the // platform's fixed per-host shapes (X.db, X.env, X.config.env, // X-sidecar.env, X.db-wal, X.db-shm, X.db.corrupt, .activator-X.*), and // reports whether it has one. X must be a valid host shape with a dot in // it (every host the platform serves has one; registry.db and // litestream.env are the box's, not a host's); a name that is not // host-shaped under any suffix is nobody's platform file. func fixedShapeHost(name string) (string, bool) { hostLike := func(x string) bool { return strings.Contains(x, ".") && controlname.ValidHostShape(x) } if rest, ok := strings.CutPrefix(name, ".activator-"); ok { if x, ok := fixedShapeHost(rest); ok { return x, true } if i := strings.Index(rest, ".db-"); i > 0 && hostLike(rest[:i]) { return rest[:i], true } return "", false } for _, suffix := range fixedSuffixes { if x, ok := strings.CutSuffix(name, suffix); ok && x != "" { if hostLike(x) { return x, true } return "", false } } return "", false } // knownHosts is every host an entry can belong to: the box's rows, and // every host the directory's own platform-named files are evidence of. A // host deleted from the box, or moved away, leaves X.env (its admin // token), X.config.env, X-blobs and the rest behind under its name, and // when X extends this host's name (app.example.com.au beside // app.example.com; -staging beside ) a scan that knew only // the rows would give them to the shorter host. Its files say X existed, // so X owns what its rules match, exactly as a row would. func knownHosts(entries []os.DirEntry, rows []string) []string { seen := map[string]bool{} var hosts []string for _, h := range rows { if !seen[h] { seen[h] = true hosts = append(hosts, h) } } for _, e := range entries { if x, ok := fixedShapeHost(e.Name()); ok && !seen[x] { seen[x] = true hosts = append(hosts, x) } } return hosts } // hostEntries sorts a directory's entries that match host's rules into // the ones host owns (fixed platform names of host itself left out when // skipFixed) and the ones a longer known host owns, by owner. func hostEntries(entries []os.DirEntry, host string, known []string, skipFixed map[string]bool) (ours []string, others map[string]string) { others = map[string]string{} for _, e := range entries { name := e.Name() if skipFixed[name] || !belongsTo(name, host) { continue } if owner := ownerOf(name, known); owner != host { others[name] = owner continue } ours = append(ours, name) } sort.Strings(ours) return ours, others } // reportOthers says, once per scan, what matched the host's rules but // belongs to a longer host, and whether that host still has a row: the // operator reads which files a departed host left in the shared // directory. Sorted by name so a log reads the same way twice. func reportOthers(p *Plan, dir string, others map[string]string, rows []string) { if len(others) == 0 { return } hasRow := map[string]bool{} for _, h := range rows { hasRow[h] = true } names := make([]string, 0, len(others)) for name := range others { names = append(names, name) } sort.Strings(names) var parts []string for _, name := range names { owner := others[name] if hasRow[owner] { parts = append(parts, fmt.Sprintf("%s (%s's)", name, owner)) } else { parts = append(parts, fmt.Sprintf("%s (%s's, which has no row on this box)", name, owner)) } } p.logf("layout: %s: left in %s, named for another host: %s", p.Host, dir, strings.Join(parts, ", ")) } // extraNameOK is the rule a recorded or scanned extra name must pass: one // path element, not one of the plan's own names, and host's by belongsTo. // It is what keeps a journal from being an instruction (design ยง3.3): a // name that fails it is refused however it got into the journal. func extraNameOK(p *Plan, name string) error { if name == "" || name == "." || name == ".." || strings.ContainsAny(name, "/\x00") { return fmt.Errorf("layout: %s: %q is not a file name", p.Host, name) } if fixedNames(p)[name] { return fmt.Errorf("layout: %s: %q is one of the move's own names, not an extra", p.Host, name) } if !belongsTo(name, p.Host) { return fmt.Errorf("layout: %s: %q is not named for the host", p.Host, name) } return nil } // extraDirs is where a host's extra entries are before and after this // plan's move: the data directory and the raw instance directory, in the // plan's direction. func (p *Plan) extraDirs() (src, dst string) { flat, nested := p.DataDir, rawInstanceDir(p.DataDir, p.Host) if p.Mode == ModeFlat { return nested, flat } return flat, nested } // AddExtras appends the named entries to the plan as extra artifacts, // present, at their source and destination for the plan's direction. // Each name must pass extraNameOK, and no name twice. func (p *Plan) AddExtras(names []string) error { src, dst := p.extraDirs() seen := map[string]bool{} for _, a := range p.Artifacts { if a.Extra { seen[a.Name] = true } } for _, name := range names { if err := extraNameOK(p, name); err != nil { return err } if seen[name] { return fmt.Errorf("layout: %s: extra %q listed twice", p.Host, name) } seen[name] = true p.Artifacts = append(p.Artifacts, Artifact{ Name: name, Src: filepath.Join(src, name), Dst: filepath.Join(dst, name), Extra: true, Present: true, }) } return nil } // ExtraNames is the names of the plan's extra artifacts, in plan order. func (p *Plan) ExtraNames() []string { var names []string for _, a := range p.Artifacts { if a.Extra { names = append(names, a.Name) } } return names } // ScanExtras lists the entries in the plan's source directory that belong // to the host and that the plan does not already name, sorted. hosts is // every host with a row on the box; with the hosts the directory's own // platform files are evidence of (knownHosts), an entry a longer host's // rule also matches is that host's, is left alone, and is reported. An // entry whose destination is already taken is a refusal naming both, // never a choice: on a migration the destination check has just cleared // the instance directory of everything the plan did not name, so a taken // destination was planted since (a hold, like any planted destination); // on a revert it is a flat file the pass will not overwrite. // // The scan runs under the fence, with the host stopped, so the listing is // the whole of what the app made; and inside the serialized section with // the journal write, so what it records is what the journal records. func ScanExtras(p *Plan, hosts []string) ([]string, error) { src, dst := p.extraDirs() entries, err := os.ReadDir(src) if errors.Is(err, os.ErrNotExist) { return nil, nil } if err != nil { return nil, fmt.Errorf("layout: %s: list %s: %w", p.Host, src, err) } rows := append([]string{p.Host}, hosts...) names, others := hostEntries(entries, p.Host, knownHosts(entries, rows), fixedNames(p)) reportOthers(p, src, others, rows) for _, name := range names { taken, err := lstatPresent(filepath.Join(dst, name)) if err != nil { return nil, fmt.Errorf("layout: %s: %w", p.Host, err) } if taken { return nil, fmt.Errorf("layout: %s: %s exists at both %s and %s; refusing to choose", p.Host, name, filepath.Join(src, name), filepath.Join(dst, name)) } } return names, nil } // FlatLeftovers lists what a host that is already nested still has in // the data directory under its own name: the entries a move made before // #256 left behind, platform-named ones included (a .db there // beside the nested one is the thing an operator most wants to know // about). Ownership is judged as the scan judges it. Nothing is moved; // the pass reports these on every pass for a nested host (convergeHost). func FlatLeftovers(dataDir, host string, hosts []string) []string { entries, err := os.ReadDir(dataDir) if err != nil { return nil } ours, _ := hostEntries(entries, host, knownHosts(entries, append([]string{host}, hosts...)), nil) for i, name := range ours { ours[i] = filepath.Join(dataDir, name) } return ours } // RawInstanceDir is a nested host's instance directory through the raw // directory behind the /data/instances bind mount, for a caller outside this // package that renames entries out of it. The offline operator move parks a // nested source's contents this way (docs/superpowers/plans/2026-10-20- // offline-operator-move.md, Task 3): a rename through the mount answers // EXDEV, as the pass's own renames would (Artifact). rename(2) and lstat(2) // act on names, never on what a planted link points at; a caller uses only // those and a listing of the directory itself, and never follows a link // beneath it. func RawInstanceDir(dataDir, host string) string { return rawInstanceDir(dataDir, host) } // platformNames are the entries the platform itself keeps directly under // the data directory. UnattributedEntries leaves them out of its report. var platformNames = map[string]bool{ "registry.db": true, "registry.db-wal": true, "registry.db-shm": true, "instances": true, ".instances": true, "layout": true, "system": true, "moves": true, ".moves": true, ".hostexec": true, "litestream.env": true, "lost+found": true, // The offline operator move's park directory: local state it renames // aside, never deletes (docs/superpowers/plans/2026-10-20-offline- // operator-move.md). ".offline-moves": true, } // UnattributedEntries lists the entries directly under dataDir that no // host on the box owns and that are not the platform's own: what a flat // app wrote relative to its working directory, which was /, or under a // name of its own choosing. The pass cannot move these, because it // cannot tell whose they are (a /data/blobs that several apps wrote into // is the live example), so it reports them for an operator to place by // hand. Sorted; nil when there are none. func UnattributedEntries(dataDir string, hosts []string) []string { entries, err := os.ReadDir(dataDir) if err != nil { return nil } // Rows only, not knownHosts: a departed host's files are exactly what // this report is for, and "no host on this box" is true of them. var out []string for _, e := range entries { name := e.Name() if platformNames[name] || ownerOf(name, hosts) != "" { continue } out = append(out, filepath.Join(dataDir, name)) } sort.Strings(out) return out }