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 "errors"
5 "fmt"
6 "os"
7 "path/filepath"
8 "sort"
9 "strings"
10
11 "github.com/carlosframework/platform/internal/controlname"
12)
13
14// The entries an app makes for itself (discussion #256).
15//
16// The plan's nine artifacts are the files the PLATFORM names for a host.
17// A flat instance was started with cwd / and `--db /data/<host>.db`, and
18// an app that derives a directory from its database path makes more:
19// bdn/ips kept /data/<host>/instance.key and /data/<host>/report.token
20// and a /data/<host>-blobs directory beside its database. The first
21// migration moved the nine names and left those behind, the app started
22// nested, looked for them under its new database's directory, found
23// nothing, and minted a fresh key. Three apps did, on the flagship, on
24// the first day.
25//
26// So a move to nested takes every entry in the data directory that the
27// flat layout's naming rules make the host's, into the instance
28// directory at the same relative name, where `filepath.Dir(dbPath)` now
29// points (nested the database is <instance dir>/<host>.db, and the
30// instance sees that directory as /data/instances/<iid>); a revert brings
31// the same names back. Nothing the host could own is left behind
32// silently: an entry that cannot be moved safely refuses the move with
33// its name in the reason. These entries are scanned, not derived, so they
34// are recorded in the journal by name and re-derived from the host and
35// the name on recovery; a name a journal records must still pass the
36// same rules (extraNameOK), so a journal cannot name a move the pass
37// would not have chosen itself.
38//
39// The naming rules are the flat layout's own. A file is <host>'s when it
40// is <host> itself, <host> plus a dot or a dash and more (<host>.db,
41// <host>-sidecar.env, <host>-blobs), or the activator's own prefix form
42// of those (.activator-<host>.db-litestream). A name can match more than
43// one host on the box (`a.b.oncarlos.com-x` is `a.b.oncarlos.com` plus
44// `-x`, and also the host `a.b.oncarlos.com-x` itself), so an entry
45// belongs to the LONGEST host on the box whose rule it matches, and a
46// pass moves only what belongs to its own host. That is exact for every
47// host the box has a row for. A name that matches no row is not moved by
48// anyone and is reported (UnattributedEntries), because a shared
49// directory such as /data/blobs, written by every flat app whose cwd was
50// /, cannot be given to one host.
51
52// belongsTo reports whether name is one the flat layout's rules make
53// host's: the host itself, or the host with a platform or app suffix.
54func belongsTo(name, host string) bool {
55 if name == host {
56 return true
57 }
58 for _, prefix := range []string{host + ".", host + "-", ".activator-" + host + ".", ".activator-" + host + "-"} {
59 if strings.HasPrefix(name, prefix) {
60 return true
61 }
62 }
63 return false
64}
65
66// ownerOf is the longest host among hosts whose rules name matches, or
67// "" when none does.
68func ownerOf(name string, hosts []string) string {
69 owner := ""
70 for _, h := range hosts {
71 if len(h) > len(owner) && belongsTo(name, h) {
72 owner = h
73 }
74 }
75 return owner
76}
77
78// fixedNames is the base names of the plan's platform-named artifacts
79// (the same on both sides of a move; the socket is never a file the pass
80// moves and has a different name nested, so it is left out).
81func fixedNames(p *Plan) map[string]bool {
82 names := map[string]bool{}
83 for _, a := range p.Artifacts {
84 if a.Extra || a.Name == "socket" {
85 continue
86 }
87 names[filepath.Base(a.Src)] = true
88 names[filepath.Base(a.Dst)] = true
89 }
90 return names
91}
92
93// fixedSuffixes are the platform's per-host file shapes under the flat
94// layout, X plus suffix (flatFiles), longest first so ".db.corrupt" is
95// read before ".db" and ".config.env" before ".env".
96var fixedSuffixes = []string{".db.corrupt", ".config.env", "-sidecar.env", ".db-wal", ".db-shm", ".db", ".env"}
97
98// fixedShapeHost reads the host X out of a name that has one of the
99// platform's fixed per-host shapes (X.db, X.env, X.config.env,
100// X-sidecar.env, X.db-wal, X.db-shm, X.db.corrupt, .activator-X.*), and
101// reports whether it has one. X must be a valid host shape with a dot in
102// it (every host the platform serves has one; registry.db and
103// litestream.env are the box's, not a host's); a name that is not
104// host-shaped under any suffix is nobody's platform file.
105func fixedShapeHost(name string) (string, bool) {
106 hostLike := func(x string) bool { return strings.Contains(x, ".") && controlname.ValidHostShape(x) }
107 if rest, ok := strings.CutPrefix(name, ".activator-"); ok {
108 if x, ok := fixedShapeHost(rest); ok {
109 return x, true
110 }
111 if i := strings.Index(rest, ".db-"); i > 0 && hostLike(rest[:i]) {
112 return rest[:i], true
113 }
114 return "", false
115 }
116 for _, suffix := range fixedSuffixes {
117 if x, ok := strings.CutSuffix(name, suffix); ok && x != "" {
118 if hostLike(x) {
119 return x, true
120 }
121 return "", false
122 }
123 }
124 return "", false
125}
126
127// knownHosts is every host an entry can belong to: the box's rows, and
128// every host the directory's own platform-named files are evidence of. A
129// host deleted from the box, or moved away, leaves X.env (its admin
130// token), X.config.env, X-blobs and the rest behind under its name, and
131// when X extends this host's name (app.example.com.au beside
132// app.example.com; <host>-staging beside <host>) a scan that knew only
133// the rows would give them to the shorter host. Its files say X existed,
134// so X owns what its rules match, exactly as a row would.
135func knownHosts(entries []os.DirEntry, rows []string) []string {
136 seen := map[string]bool{}
137 var hosts []string
138 for _, h := range rows {
139 if !seen[h] {
140 seen[h] = true
141 hosts = append(hosts, h)
142 }
143 }
144 for _, e := range entries {
145 if x, ok := fixedShapeHost(e.Name()); ok && !seen[x] {
146 seen[x] = true
147 hosts = append(hosts, x)
148 }
149 }
150 return hosts
151}
152
153// hostEntries sorts a directory's entries that match host's rules into
154// the ones host owns (fixed platform names of host itself left out when
155// skipFixed) and the ones a longer known host owns, by owner.
156func hostEntries(entries []os.DirEntry, host string, known []string, skipFixed map[string]bool) (ours []string, others map[string]string) {
157 others = map[string]string{}
158 for _, e := range entries {
159 name := e.Name()
160 if skipFixed[name] || !belongsTo(name, host) {
161 continue
162 }
163 if owner := ownerOf(name, known); owner != host {
164 others[name] = owner
165 continue
166 }
167 ours = append(ours, name)
168 }
169 sort.Strings(ours)
170 return ours, others
171}
172
173// reportOthers says, once per scan, what matched the host's rules but
174// belongs to a longer host, and whether that host still has a row: the
175// operator reads which files a departed host left in the shared
176// directory. Sorted by name so a log reads the same way twice.
177func reportOthers(p *Plan, dir string, others map[string]string, rows []string) {
178 if len(others) == 0 {
179 return
180 }
181 hasRow := map[string]bool{}
182 for _, h := range rows {
183 hasRow[h] = true
184 }
185 names := make([]string, 0, len(others))
186 for name := range others {
187 names = append(names, name)
188 }
189 sort.Strings(names)
190 var parts []string
191 for _, name := range names {
192 owner := others[name]
193 if hasRow[owner] {
194 parts = append(parts, fmt.Sprintf("%s (%s's)", name, owner))
195 } else {
196 parts = append(parts, fmt.Sprintf("%s (%s's, which has no row on this box)", name, owner))
197 }
198 }
199 p.logf("layout: %s: left in %s, named for another host: %s", p.Host, dir, strings.Join(parts, ", "))
200}
201
202// extraNameOK is the rule a recorded or scanned extra name must pass: one
203// path element, not one of the plan's own names, and host's by belongsTo.
204// It is what keeps a journal from being an instruction (design §3.3): a
205// name that fails it is refused however it got into the journal.
206func extraNameOK(p *Plan, name string) error {
207 if name == "" || name == "." || name == ".." || strings.ContainsAny(name, "/\x00") {
208 return fmt.Errorf("layout: %s: %q is not a file name", p.Host, name)
209 }
210 if fixedNames(p)[name] {
211 return fmt.Errorf("layout: %s: %q is one of the move's own names, not an extra", p.Host, name)
212 }
213 if !belongsTo(name, p.Host) {
214 return fmt.Errorf("layout: %s: %q is not named for the host", p.Host, name)
215 }
216 return nil
217}
218
219// extraDirs is where a host's extra entries are before and after this
220// plan's move: the data directory and the raw instance directory, in the
221// plan's direction.
222func (p *Plan) extraDirs() (src, dst string) {
223 flat, nested := p.DataDir, rawInstanceDir(p.DataDir, p.Host)
224 if p.Mode == ModeFlat {
225 return nested, flat
226 }
227 return flat, nested
228}
229
230// AddExtras appends the named entries to the plan as extra artifacts,
231// present, at their source and destination for the plan's direction.
232// Each name must pass extraNameOK, and no name twice.
233func (p *Plan) AddExtras(names []string) error {
234 src, dst := p.extraDirs()
235 seen := map[string]bool{}
236 for _, a := range p.Artifacts {
237 if a.Extra {
238 seen[a.Name] = true
239 }
240 }
241 for _, name := range names {
242 if err := extraNameOK(p, name); err != nil {
243 return err
244 }
245 if seen[name] {
246 return fmt.Errorf("layout: %s: extra %q listed twice", p.Host, name)
247 }
248 seen[name] = true
249 p.Artifacts = append(p.Artifacts, Artifact{
250 Name: name, Src: filepath.Join(src, name), Dst: filepath.Join(dst, name),
251 Extra: true, Present: true,
252 })
253 }
254 return nil
255}
256
257// ExtraNames is the names of the plan's extra artifacts, in plan order.
258func (p *Plan) ExtraNames() []string {
259 var names []string
260 for _, a := range p.Artifacts {
261 if a.Extra {
262 names = append(names, a.Name)
263 }
264 }
265 return names
266}
267
268// ScanExtras lists the entries in the plan's source directory that belong
269// to the host and that the plan does not already name, sorted. hosts is
270// every host with a row on the box; with the hosts the directory's own
271// platform files are evidence of (knownHosts), an entry a longer host's
272// rule also matches is that host's, is left alone, and is reported. An
273// entry whose destination is already taken is a refusal naming both,
274// never a choice: on a migration the destination check has just cleared
275// the instance directory of everything the plan did not name, so a taken
276// destination was planted since (a hold, like any planted destination);
277// on a revert it is a flat file the pass will not overwrite.
278//
279// The scan runs under the fence, with the host stopped, so the listing is
280// the whole of what the app made; and inside the serialized section with
281// the journal write, so what it records is what the journal records.
282func ScanExtras(p *Plan, hosts []string) ([]string, error) {
283 src, dst := p.extraDirs()
284 entries, err := os.ReadDir(src)
285 if errors.Is(err, os.ErrNotExist) {
286 return nil, nil
287 }
288 if err != nil {
289 return nil, fmt.Errorf("layout: %s: list %s: %w", p.Host, src, err)
290 }
291 rows := append([]string{p.Host}, hosts...)
292 names, others := hostEntries(entries, p.Host, knownHosts(entries, rows), fixedNames(p))
293 reportOthers(p, src, others, rows)
294 for _, name := range names {
295 taken, err := lstatPresent(filepath.Join(dst, name))
296 if err != nil {
297 return nil, fmt.Errorf("layout: %s: %w", p.Host, err)
298 }
299 if taken {
300 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))
301 }
302 }
303 return names, nil
304}
305
306// FlatLeftovers lists what a host that is already nested still has in
307// the data directory under its own name: the entries a move made before
308// #256 left behind, platform-named ones included (a <host>.db there
309// beside the nested one is the thing an operator most wants to know
310// about). Ownership is judged as the scan judges it. Nothing is moved;
311// the pass reports these on every pass for a nested host (convergeHost).
312func FlatLeftovers(dataDir, host string, hosts []string) []string {
313 entries, err := os.ReadDir(dataDir)
314 if err != nil {
315 return nil
316 }
317 ours, _ := hostEntries(entries, host, knownHosts(entries, append([]string{host}, hosts...)), nil)
318 for i, name := range ours {
319 ours[i] = filepath.Join(dataDir, name)
320 }
321 return ours
322}
323
324// RawInstanceDir is a nested host's instance directory through the raw
325// directory behind the /data/instances bind mount, for a caller outside this
326// package that renames entries out of it. The offline operator move parks a
327// nested source's contents this way (docs/superpowers/plans/2026-10-20-
328// offline-operator-move.md, Task 3): a rename through the mount answers
329// EXDEV, as the pass's own renames would (Artifact). rename(2) and lstat(2)
330// act on names, never on what a planted link points at; a caller uses only
331// those and a listing of the directory itself, and never follows a link
332// beneath it.
333func RawInstanceDir(dataDir, host string) string { return rawInstanceDir(dataDir, host) }
334
335// platformNames are the entries the platform itself keeps directly under
336// the data directory. UnattributedEntries leaves them out of its report.
337var platformNames = map[string]bool{
338 "registry.db": true, "registry.db-wal": true, "registry.db-shm": true,
339 "instances": true, ".instances": true, "layout": true, "system": true,
340 "moves": true, ".moves": true, ".hostexec": true, "litestream.env": true,
341 "lost+found": true,
342 // The offline operator move's park directory: local state it renames
343 // aside, never deletes (docs/superpowers/plans/2026-10-20-offline-
344 // operator-move.md).
345 ".offline-moves": true,
346}
347
348// UnattributedEntries lists the entries directly under dataDir that no
349// host on the box owns and that are not the platform's own: what a flat
350// app wrote relative to its working directory, which was /, or under a
351// name of its own choosing. The pass cannot move these, because it
352// cannot tell whose they are (a /data/blobs that several apps wrote into
353// is the live example), so it reports them for an operator to place by
354// hand. Sorted; nil when there are none.
355func UnattributedEntries(dataDir string, hosts []string) []string {
356 entries, err := os.ReadDir(dataDir)
357 if err != nil {
358 return nil
359 }
360 // Rows only, not knownHosts: a departed host's files are exactly what
361 // this report is for, and "no host on this box" is true of them.
362 var out []string
363 for _, e := range entries {
364 name := e.Name()
365 if platformNames[name] || ownerOf(name, hosts) != "" {
366 continue
367 }
368 out = append(out, filepath.Join(dataDir, name))
369 }
370 sort.Strings(out)
371 return out
372}
373