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 "crypto/rand"
5 "encoding/hex"
6 "errors"
7 "fmt"
8 "os"
9 "path/filepath"
10 "strings"
11 "time"
12
13 "github.com/carlosframework/platform/internal/instancefs"
14 "github.com/carlosframework/platform/internal/provision"
15 "github.com/carlosframework/platform/internal/registry"
16)
17
18// Preflight is the ownership check of design §8.2 step 4, plus the socket
19// length check of §3.1. Under the flat rules <host>.config.env is host's
20// rendered bundle UNLESS a route named <host>.config exists, in which case
21// it is that route's instance env (SyncExec's own collision rule). Moving
22// it with host would take another route's file; reverting onto it would
23// overwrite one. Either is a refusal naming both owners. <host>-sidecar.env
24// is shared the same way with a route named <host>-sidecar.
25func Preflight(p *Plan, routes []registry.Route) error {
26 hosts := map[string]bool{}
27 for _, rt := range routes {
28 hosts[rt.Host] = true
29 }
30 if other := p.Host + ".config"; hosts[other] {
31 return fmt.Errorf("layout: %s: %s.config.env is owned by both %s (its bundle) and %s (its instance env); refusing to move it", p.Host, p.Host, p.Host, other)
32 }
33 if strings.HasSuffix(p.Host, ".config") {
34 if owner := strings.TrimSuffix(p.Host, ".config"); hosts[owner] {
35 return fmt.Errorf("layout: %s: its env file %s.env is owned by both %s (its instance env) and %s (its bundle); refusing to move it", p.Host, p.Host, p.Host, owner)
36 }
37 }
38 // The sidecar env collides the same way: <host>-sidecar.env is host's
39 // sidecar env, and also the instance env of a route named
40 // <host>-sidecar.
41 if other := p.Host + "-sidecar"; hosts[other] {
42 return fmt.Errorf("layout: %s: %s-sidecar.env is owned by both %s (its sidecar env) and %s (its instance env); refusing to move it", p.Host, p.Host, p.Host, other)
43 }
44 if strings.HasSuffix(p.Host, "-sidecar") {
45 if owner := strings.TrimSuffix(p.Host, "-sidecar"); hosts[owner] {
46 return fmt.Errorf("layout: %s: its env file %s.env is owned by both %s (its instance env) and %s (its sidecar env); refusing to move it", p.Host, p.Host, p.Host, owner)
47 }
48 }
49 // The activator binds a control socket beside every exec listener at
50 // the listener's path plus ".control". A nested address that leaves no
51 // room for it would make the host unwakeable after the move, so it is
52 // refused before anything moves.
53 if p.Mode == ModeNested {
54 if n := len(p.To.Socket) + len(".control"); n > provision.MaxUnixPath {
55 return fmt.Errorf("layout: %s: control socket %s.control is %d bytes, over the %d a unix socket address holds; refusing to move it", p.Host, p.To.Socket, n, provision.MaxUnixPath)
56 }
57 }
58 return nil
59}
60
61// DestinationCheck is step 5, run before the journal so that at the moment
62// the journal is written nothing the pass will move has anything at its
63// destination, and, on a migration, so that the instance directory holds
64// nothing the pass did not put there. A database, or any file of one,
65// already at the destination is a refusal (two databases; refusing to
66// choose), and so is a litestream shadow (two replication states for one
67// database). An env file already there, which an older binary running
68// between a migration and a revert can have minted fresh, is moved aside
69// to the pass's own aside directory so the source, the file the instance
70// ran with for longer, takes its place; a quarantined restore is kept the
71// same way. The rendered bundle is simply cleared; sync re-renders it
72// anyway.
73//
74// The aside copy lives OUTSIDE the instance tree, in <data>/layout/aside/<host>,
75// where no instance's namespace reaches (design §4.1 binds the instance
76// its own directory and nothing else of /data). Inside the tree it would
77// be the tenant's to read, and a tenant that reclaims a hostname, or that
78// planted the file itself before its own migration, must not read what a
79// previous owner or a sibling left at that name.
80//
81// Every change goes through a directory fd that follows no symlink, so a
82// planted link at a destination directory cannot redirect it.
83func DestinationCheck(p *Plan, now func() time.Time) (aside []string, err error) {
84 if p.Mode == ModeNested {
85 moved, err := quarantineForeignEntries(p)
86 aside = append(aside, moved...)
87 if err != nil {
88 return aside, err
89 }
90 }
91 for _, a := range p.Artifacts {
92 if a.Name == "socket" {
93 continue
94 }
95 present, err := lstatPresent(a.Dst)
96 if err != nil {
97 return aside, err
98 }
99 if !present {
100 continue
101 }
102 dir, name := filepath.Dir(a.Dst), filepath.Base(a.Dst)
103 switch a.Name {
104 case "db", "db-wal", "db-shm":
105 return aside, fmt.Errorf("layout: %s: two databases (%s exists at the destination); refusing to choose", p.Host, a.Dst)
106 case "litestream-shadow":
107 return aside, fmt.Errorf("layout: %s: two replication states (%s exists at the destination); refusing to choose", p.Host, a.Dst)
108 case "env", "sidecar-env", "corrupt":
109 asideDir, err := ensureAsideDir(p.DataDir, p.Host)
110 if err != nil {
111 return aside, fmt.Errorf("layout: %s: %w", p.Host, err)
112 }
113 to := fmt.Sprintf("%s.aside-%d", name, now().Unix())
114 taken, err := lstatPresent(filepath.Join(asideDir, to))
115 if err != nil {
116 return aside, err
117 }
118 if taken {
119 return aside, fmt.Errorf("layout: %s: %s and %s both exist; refusing to replace a kept copy", p.Host, a.Dst, filepath.Join(asideDir, to))
120 }
121 if err := instancefs.RenameBetween(dir, name, asideDir, to); err != nil {
122 return aside, fmt.Errorf("layout: %s: move %s aside: %w", p.Host, a.Dst, err)
123 }
124 aside = append(aside, filepath.Join(asideDir, to))
125 case "config-env":
126 if err := instancefs.RemoveBeneath(dir, name); err != nil {
127 return aside, fmt.Errorf("layout: %s: clear %s: %w", p.Host, a.Dst, err)
128 }
129 default:
130 return aside, fmt.Errorf("layout: %s: no destination rule for artifact %q", p.Host, a.Name)
131 }
132 }
133 return aside, nil
134}
135
136// asideDir is where the pass keeps what it moved out of a host's way:
137// under the pass's own layout directory, per host, which no instance
138// namespace contains. It is created on first use, carlos 0700 like the
139// journal directory above it, through fds that follow no symlink.
140func asideDir(dataDir, host string) string {
141 return filepath.Join(journalDir(dataDir), "aside", host)
142}
143
144func ensureAsideDir(dataDir, host string) (string, error) {
145 if _, err := instancefs.EnsureDirBeneath(dataDir, filepath.Base(journalDir(dataDir)), 0o700); err != nil {
146 return "", err
147 }
148 if err := ensureDirDisplacing(dataDir, journalDir(dataDir), "aside"); err != nil {
149 return "", err
150 }
151 if err := ensureDirDisplacing(dataDir, filepath.Join(journalDir(dataDir), "aside"), host); err != nil {
152 return "", err
153 }
154 return asideDir(dataDir, host), nil
155}
156
157// ensureDirDisplacing makes name a real directory under parent. The layout
158// directory and everything under it are carlos 0700, which every flat
159// tenant can write, so a tenant can plant a symlink or a file at the aside
160// directory, or at a sibling's directory under it, ahead of the pass; left
161// as a refusal that would be a hold on the sibling (or on every host) with
162// one command. A blocker that is not a real directory is renamed out of
163// the way to a random, no-replace name under the layout directory itself,
164// where the operator can see it, and the directory is made once more.
165// Only a second failure is returned.
166func ensureDirDisplacing(dataDir, parent, name string) error {
167 _, err := instancefs.EnsureDirBeneath(parent, name, 0o700)
168 if err == nil {
169 return nil
170 }
171 to := fmt.Sprintf(".%s.blocker-%d-%s", name, time.Now().Unix(), randomSuffix())
172 if rerr := instancefs.RenameBetweenNoReplace(parent, name, journalDir(dataDir), to); rerr != nil {
173 return fmt.Errorf("%w (and moving the blocker aside: %v)", err, rerr)
174 }
175 if _, err := instancefs.EnsureDirBeneath(parent, name, 0o700); err != nil {
176 return err
177 }
178 return nil
179}
180
181// quarantineForeignEntries moves everything out of a migration's instance
182// data directory that the plan does not name, into the host's aside
183// directory, and reports where each went. Until every flat tenant on the
184// box has migrated, the raw directory is writable by every one of them
185// (same uid, §1.3), and a tenant can make a sibling's future directory
186// ahead of the pass and hard-link the sibling's database or the box's
187// bucket credentials into it, which protected_hardlinks allows because
188// the files are its own uid's; the sibling's namespace would then bind
189// that directory read-write. So the directory the pass is about to fill
190// must hold nothing but what the pass itself moves in: an entry at a
191// planned name is handled by the destination rules (refused, cleared, or
192// kept aside), and any other entry is renamed out here.
193//
194// Renamed, not refused: a refusal would let one tenant hold a sibling
195// down with one touch(1) until an operator acted. rename(2) moves the
196// directory entry itself, whatever it names, a hard link or a symlink
197// included, so the quarantined entry is as harmless in the aside
198// directory as it was dangerous in the tenant's, and the original file
199// (if the entry was a second name for one) keeps its own name wherever
200// that is. The destination name carries a random suffix and is written
201// with RENAME_NOREPLACE, so nothing pre-planted in the aside directory
202// can make the quarantine collide. A tenant's own leftovers from an
203// earlier nested life (a revert leaves what the app wrote beside its
204// database) are quarantined the same way; the aside copy is kept for
205// the operator. The run directory is not checked: it is on tmpfs, where
206// a hard link to anything under /data is impossible, and its mount is
207// nosymfollow.
208func quarantineForeignEntries(p *Plan) ([]string, error) {
209 dir := rawInstanceDir(p.DataDir, p.Host)
210 entries, err := os.ReadDir(dir)
211 if errors.Is(err, os.ErrNotExist) {
212 return nil, nil
213 }
214 if err != nil {
215 return nil, fmt.Errorf("layout: %s: list %s: %w", p.Host, dir, err)
216 }
217 planned := map[string]bool{}
218 for _, a := range p.Artifacts {
219 if a.Name != "socket" {
220 planned[filepath.Base(a.Dst)] = true
221 }
222 }
223 var moved []string
224 for _, e := range entries {
225 if planned[e.Name()] {
226 continue
227 }
228 aside, err := ensureAsideDir(p.DataDir, p.Host)
229 if err != nil {
230 return moved, fmt.Errorf("layout: %s: %w", p.Host, err)
231 }
232 var last error
233 for attempt := 0; attempt < 3; attempt++ {
234 to := fmt.Sprintf("%s.q-%d-%s", e.Name(), time.Now().Unix(), randomSuffix())
235 last = instancefs.RenameBetweenNoReplace(dir, e.Name(), aside, to)
236 if last == nil {
237 moved = append(moved, filepath.Join(aside, to))
238 break
239 }
240 if !errors.Is(last, os.ErrExist) {
241 break
242 }
243 }
244 if last != nil {
245 return moved, fmt.Errorf("layout: %s: quarantine %s out of %s: %w", p.Host, e.Name(), dir, last)
246 }
247 }
248 return moved, nil
249}
250
251func randomSuffix() string {
252 var b [4]byte
253 if _, err := rand.Read(b[:]); err != nil {
254 return fmt.Sprintf("%d", time.Now().UnixNano())
255 }
256 return hex.EncodeToString(b[:])
257}
258
259// LinkCheck refuses a plan whose files have more than one name. It runs
260// on the sources right before the journal is written, and again on the
261// destinations after the renames and before the row flips (VerifyMoved):
262// a regular file with a second hard link somewhere is a second reader and
263// writer of it, and a tenant that linked a sibling's database into its own
264// shadow directory before migrating would otherwise carry that link into
265// its namespace. The shadow directory is walked, and a symlink or anything
266// that is neither a regular file nor a directory inside it is refused too:
267// litestream writes only files and directories there.
268//
269// This is a check against what was planted before the pass arrived. A
270// flat tenant that is still running while the pass moves another host can
271// still plant between the check and the rename, which is why a box is not
272// isolated until no flat tenant remains on it (Converge's awake bound, and
273// the mixed-layout warning in carlos status --layout).
274func LinkCheck(p *Plan, at func(Artifact) string) error {
275 for _, a := range p.Artifacts {
276 if a.Name == "socket" || !a.Present {
277 continue
278 }
279 path := at(a)
280 fi, err := os.Lstat(path)
281 if errors.Is(err, os.ErrNotExist) {
282 continue
283 }
284 if err != nil {
285 return fmt.Errorf("layout: %s: %w", p.Host, err)
286 }
287 if a.Dir {
288 if err := walkLinks(p.Host, p.DataDir, path); err != nil {
289 return err
290 }
291 continue
292 }
293 if a.Extra && fi.IsDir() {
294 // Said before the walk, which stats every file: a blob store
295 // can be large, the host is stopped meanwhile, and the walk
296 // runs twice (sources, then destinations).
297 p.logf("layout: %s: checking every file under %s for hard links and symlinks before the move goes on", p.Host, path)
298 if err := walkExtraLinks(p.Host, p.DataDir, path); err != nil {
299 return err
300 }
301 continue
302 }
303 if err := oneName(p.Host, p.DataDir, path, fi); err != nil {
304 return err
305 }
306 }
307 return nil
308}
309
310// walkExtraLinks is walkLinks for a directory an app made for itself
311// (extras.go), with one allowance the shadow directory does not need: a
312// file with several names may be moved when EVERY one of those names is
313// inside the directory, since the move carries them all and nothing
314// outside can reach the file afterwards. Apps deduplicate content by
315// hard-linking inside their own blob store, and refusing that would hold
316// a host down for its own ordinary files. A name outside the directory
317// is still a second reader and writer the move does not know about, and
318// is refused with the inode and the find that lists it. Symlinks and
319// special files are refused as in walkLinks.
320func walkExtraLinks(host, dataDir, root string) error {
321 type linked struct {
322 path string
323 nlink uint64
324 seen uint64
325 }
326 byInode := map[uint64]*linked{}
327 err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
328 if err != nil {
329 return fmt.Errorf("layout: %s: %w", host, err)
330 }
331 if d.IsDir() {
332 return nil
333 }
334 fi, err := d.Info()
335 if err != nil {
336 return fmt.Errorf("layout: %s: %w", host, err)
337 }
338 if !fi.Mode().IsRegular() {
339 return fmt.Errorf("layout: %s: %s is not a regular file (%v); refusing to move it", host, path, fi.Mode().Type())
340 }
341 if n, ino := linkCount(fi); n > 1 {
342 l := byInode[ino]
343 if l == nil {
344 l = &linked{path: path, nlink: n}
345 byInode[ino] = l
346 }
347 l.seen++
348 }
349 return nil
350 })
351 if err != nil {
352 return err
353 }
354 for ino, l := range byInode {
355 if l.seen < l.nlink {
356 return fmt.Errorf("layout: %s: %s has %d names (hard links, inode %d) and only %d of them are inside %s; refusing to move a file something else can still reach. find the others with: find %s -xdev -samefile %s", host, l.path, l.nlink, ino, l.seen, root, dataDir, l.path)
357 }
358 }
359 return nil
360}
361
362func walkLinks(host, dataDir, root string) error {
363 return filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
364 if err != nil {
365 return fmt.Errorf("layout: %s: %w", host, err)
366 }
367 if d.IsDir() {
368 return nil
369 }
370 fi, err := d.Info()
371 if err != nil {
372 return fmt.Errorf("layout: %s: %w", host, err)
373 }
374 return oneName(host, dataDir, path, fi)
375 })
376}
377
378// oneName refuses a file that is not a regular file with exactly one
379// name. The refusal tells the operator how to find the other names: the
380// inode, and the find invocation that lists every path to it on the data
381// volume (-xdev, because a hard link cannot cross a filesystem).
382func oneName(host, dataDir, path string, fi os.FileInfo) error {
383 if !fi.Mode().IsRegular() {
384 return fmt.Errorf("layout: %s: %s is not a regular file (%v); refusing to move it", host, path, fi.Mode().Type())
385 }
386 if n, ino := linkCount(fi); n > 1 {
387 return fmt.Errorf("layout: %s: %s has %d names (hard links, inode %d); refusing to move a file something else can still reach. find the others with: find %s -xdev -samefile %s", host, path, n, ino, dataDir, path)
388 }
389 return nil
390}
391
392// VerifyMoved is the check after the renames and before the row flips:
393// every moved file still has one name, and on a migration the instance
394// directory holds nothing but the plan's files, anything else planted
395// between the destination check and the renames being quarantined now.
396// It reports what it quarantined. A link failure keeps the journal at its
397// files phase, so the host stays fenced and the next recovery meets the
398// same check with the same answer until an operator has looked.
399func VerifyMoved(p *Plan) ([]string, error) {
400 if err := LinkCheck(p, func(a Artifact) string { return a.Dst }); err != nil {
401 return nil, err
402 }
403 if p.Mode == ModeNested {
404 return quarantineForeignEntries(p)
405 }
406 return nil, nil
407}
408
409// EnsureDirs makes the raw directories the bind mounts show: the data
410// directory 0700 (the tenant's own ground) and the run directory 2750 in
411// the carlos-sockets group, so an enrolled consumer can dial app.sock
412// (design §3.1). The directories must exist before the row flips, because
413// Restore and prepare both assume them.
414//
415// The pass runs as carlos, which is not a member of carlos-sockets. It
416// therefore cannot chown anything into that group, and any chmod it makes
417// on a directory of that group has the kernel clear setgid, which on the
418// run parent would hand every later instance directory the wrong group.
419// So nothing here changes a mode or an owner. The run directory gets its
420// group and setgid by inheritance from <run>/.instances, which tmpfiles
421// owns, exactly as prepare relies on; both parents are checked and never
422// repaired, and a new or existing instance directory that is not what
423// inheritance should have made is refused as it stands.
424//
425// Each directory is made and checked through an fd on its parent that
426// followed no symlink: a same-uid tenant can plant a symlink at
427// <data>/.instances/<iid> before the pass arrives, and a move into it
428// would land the database wherever the link points.
429func EnsureDirs(dataDir, socketDir, host string, socketsGID int) error {
430 data := rawInstanceDir(dataDir, host)
431 run := rawRunDir(socketDir, host)
432 // The run parent is checked before anything is created, so a refusal
433 // leaves nothing behind.
434 runParent, err := checkRunParent(filepath.Dir(run), socketsGID)
435 if err != nil {
436 return fmt.Errorf("layout: %s: %w", host, err)
437 }
438 // The data parent is tmpfiles' too, but carlos:carlos 0700 is exactly
439 // what mkdir as carlos makes, so a missing one is created, never
440 // changed.
441 if _, err := instancefs.EnsureDirBeneath(dataDir, filepath.Base(filepath.Dir(data)), 0o700); err != nil {
442 return fmt.Errorf("layout: %s: %w", host, err)
443 }
444 st, err := instancefs.EnsureDirBeneath(filepath.Dir(data), filepath.Base(data), 0o700)
445 if err != nil {
446 return fmt.Errorf("layout: %s: %w", host, err)
447 }
448 if err := checkInstanceDir(data, st, 0o700, false, -1); err != nil {
449 return fmt.Errorf("layout: %s: %w", host, err)
450 }
451 st, err = instancefs.EnsureDirBeneath(filepath.Dir(run), filepath.Base(run), 0o750)
452 if err != nil {
453 return fmt.Errorf("layout: %s: %w", host, err)
454 }
455 if err := checkInstanceDir(run, st, 0o750, runParent.Mode&os.ModeSetgid != 0, socketsGID); err != nil {
456 return fmt.Errorf("layout: %s: %w", host, err)
457 }
458 return nil
459}
460
461// checkRunParent reads <run>/.instances. With the sockets group known it
462// must be there already, in that group and setgid, because only tmpfiles,
463// running as root, can make it so. Without the group (a box that has none,
464// or a test) a missing parent is simply created.
465func checkRunParent(parent string, socketsGID int) (instancefs.DirStat, error) {
466 if socketsGID < 0 {
467 return instancefs.EnsureDirBeneath(filepath.Dir(parent), filepath.Base(parent), 0o750)
468 }
469 st, err := instancefs.StatDir(parent)
470 if errors.Is(err, os.ErrNotExist) {
471 return st, fmt.Errorf("%s is missing; tmpfiles should create it, and the pass does not", parent)
472 }
473 if err != nil {
474 return st, err
475 }
476 if st.GID != socketsGID {
477 return st, fmt.Errorf("%s has group %d, not carlos-sockets (%d); tmpfiles should restore it, and the pass does not repair it", parent, st.GID, socketsGID)
478 }
479 if st.Mode&os.ModeSetgid == 0 {
480 return st, fmt.Errorf("%s has lost its setgid bit (mode %v); tmpfiles should restore it, and the pass does not repair it", parent, st.Mode)
481 }
482 return st, nil
483}
484
485// checkInstanceDir refuses an instance directory the pass cannot vouch
486// for: not its own, the wrong permission bits, no setgid under a setgid
487// parent, or not the sockets group. Each would need a chmod or chown to
488// fix, which is exactly what the pass must not do.
489func checkInstanceDir(path string, st instancefs.DirStat, perm os.FileMode, wantSetgid bool, gid int) error {
490 if st.UID != os.Geteuid() {
491 return fmt.Errorf("%s is owned by uid %d, not the agent's %d; refusing to use it", path, st.UID, os.Geteuid())
492 }
493 if st.Mode.Perm() != perm {
494 return fmt.Errorf("%s has mode %v, not %v; refusing to use it", path, st.Mode.Perm(), perm)
495 }
496 if wantSetgid && st.Mode&os.ModeSetgid == 0 {
497 return fmt.Errorf("%s has no setgid bit though its parent has; refusing to use it", path)
498 }
499 if gid >= 0 && st.GID != gid {
500 return fmt.Errorf("%s has group %d, not carlos-sockets (%d); refusing to use it", path, st.GID, gid)
501 }
502 return nil
503}
504
505func lstatPresent(path string) (bool, error) {
506 _, err := os.Lstat(path)
507 if errors.Is(err, os.ErrNotExist) {
508 return false, nil
509 }
510 return err == nil, err
511}
512