// Package layout is the pass that moves an activator-managed host between // the flat layout (/.db, /run/carlos/.sock) and the nested // one (/instances//, /run/carlos/instances//), and back // (isolated-exec design §8.2). It builds its plan from the row and the host // name only, journals every step in /data/layout, and recovers from the // journal exactly. Nothing in an instance directory is read to decide // anything (§3.3). package layout import ( "fmt" "path/filepath" "strconv" "strings" "github.com/carlosframework/platform/internal/activator" "github.com/carlosframework/platform/internal/controlname" "github.com/carlosframework/platform/internal/instancectl" "github.com/carlosframework/platform/internal/instancekey" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/release" ) // Mode is the direction of a move: the layout the host ends in. type Mode string const ( ModeNested Mode = release.LayoutModeNested ModeFlat Mode = release.LayoutModeFlat ) // Endpoint is the pair of row columns a layout is defined by. type Endpoint struct { DB string `json:"db"` Socket string `json:"socket"` } // Artifact is one file (or, for litestream-shadow, directory) the pass // moves. Src and Dst are RAW paths for anything inside the instance tree // (design §3.2): the bind mount at /data/instances is a different mount // from /data/.instances and rename(2) between them answers EXDEV, so the // pass renames through the raw directory and the mount shows the result. // The socket artifact is the one exception: nothing is ever renamed for // it, so its Src/Dst are the plan's own endpoint sockets (design §8.2 // step 3; see PlanFor). type Artifact struct { Name string Src, Dst string // Required marks the database: a missing required source is an error, // a missing optional one is recorded absent and skipped (R2-7). Required bool // Dir marks an artifact that is a directory rather than a regular // file (only litestream-shadow, design §8.2 step 3): the preflight // and the rename step need to know this before they touch it, since // a directory cannot be opened, stat'd or renamed the way a file is. Dir bool // Present records whether this artifact's source exists. PlanFor // never stats anything (it builds the plan from the row and the host // name alone, §3.3), so Present is always false here; the pass fills // it in during preflight. Present bool // Extra marks an entry the app made for itself under the host's name // (extras.go), found by ScanExtras or recorded in the journal rather // than derived by PlanFor. Always Present: it is in the plan because // it was seen. Name is the entry's own base name. Extra bool } // Plan is everything the pass will do for one host, computed before it // touches anything. type Plan struct { Host string IID string Mode Mode From, To Endpoint Artifacts []Artifact // DataDir is the box's data directory, kept so the checks that run on // the plan can find the pass's own directory under it (the aside // directory, outside any instance tree) without being handed it again. DataDir string // Logf is the pass's logger, set by the pass after PlanFor so the // checks that run on the plan (the extras scan, the link walk) can // say what they are doing and what they left. Nil logs nothing. Logf func(string, ...any) } func (p *Plan) logf(format string, a ...any) { if p.Logf != nil { p.Logf(format, a...) } } // artifactNames is the fixed order of the nine artifacts the pass tracks. // litestream-shadow and corrupt sit after socket so that an index into an // existing Artifacts slice (Artifacts[0] for the db, and so on) keeps its // meaning. The journal records presence by this order, so it is part of // the on-disk contract. var artifactNames = []string{ "db", "db-wal", "db-shm", "env", "sidecar-env", "config-env", "socket", "litestream-shadow", "corrupt", } // rawInstanceDir and rawRunDir are the ONLY places the platform names the // raw directories behind the two bind mounts, so a grep test can pin that // and catch any new hand-built path that starts reading or writing through // the bind mount instead. func rawInstanceDir(dataDir, host string) string { return filepath.Join(dataDir, ".instances", provision.InstanceID(host)) } func rawRunDir(socketDir, host string) string { return filepath.Join(socketDir, ".instances", provision.InstanceID(host)) } // shadowName is the litestream shadow directory's file name for host, // derived exactly as internal/lease/lease.go's newStore does for a // database path whose base name is ".db". That derivation is on an // unexported helper in internal/lease, so it is repeated here rather than // imported: this package has no other reason to depend on litestream's // setup, and a two-line string format is cheaper to keep in step by hand // than a new cross-package dependency is to justify. func shadowName(host string) string { return ".activator-" + host + ".db-litestream" } // flatFiles and nestedFiles are the nine artifact paths under each layout, // in artifactNames order. The nested set is expressed through the raw // directories, except for the socket, which the pass never renames and so // is expressed through the mount-visible ("instances", not ".instances") // path the row itself will carry. func flatFiles(dataDir, socketDir, host string) []string { db := provision.DefaultDBPath(dataDir, host) return []string{ db, db + "-wal", db + "-shm", filepath.Join(dataDir, host+".env"), filepath.Join(dataDir, host+"-sidecar.env"), filepath.Join(dataDir, host+".config.env"), filepath.Join(socketDir, host+".sock"), filepath.Join(dataDir, shadowName(host)), db + ".corrupt", } } func nestedFiles(dataDir, socketDir, host string) []string { d := rawInstanceDir(dataDir, host) db := filepath.Join(d, host+".db") return []string{ db, filepath.Join(d, host+".db-wal"), filepath.Join(d, host+".db-shm"), filepath.Join(d, host+".env"), filepath.Join(d, host+"-sidecar.env"), filepath.Join(d, host+".config.env"), filepath.Join(socketDir, "instances", provision.InstanceID(host), "app.sock"), filepath.Join(d, shadowName(host)), db + ".corrupt", } } // filesMarkerName is the files directory's generation marker // (archive.MarkerName, hostfiles' marker), named here rather than imported: // the archive package depends on hostfiles and appconfig, which this // package has no other reason to. const filesMarkerName = ".files-gen" // NestedPlatformNames is every name in a nested host's instance directory // that is the platform's, not the app's: the names nestedFiles puts in the // raw instance directory (the database, its WAL, SHM and quarantine, the // own and sidecar env files, the rendered configuration, the activator's // litestream shadow; the socket is in the run directory and is not here), // the database's generation marker (.gen), litestream's stock meta // directory (-litestream), the files generation marker (.files-gen) and // the instance-key adoption record, the seed marker a seeded database // keeps beside it for its life (.seed, activator.SeedMarkerSuffix, // written by ExecBackend.seededRestore in activator/seedplace.go), and the // unit launch descriptors written into the instance directory: // launch..json for every instancectl role // (instancectl.LaunchFileName; written by ExecBackend.writeLaunchFor in // activator/prepare.go, and launch.run.json also by root's role launch in // activator/rolelaunch_root_linux.go). A // generation's descriptor, launch.gen..json (instancectl. // GenLaunchFileName, written by writeLaunchFor), has no fixed name: // IsNestedPlatformName answers for it. Everything else in the directory is // the app's: its files directory and anything it keeps beside it. // // The offline move's sealer uses these to find what the app keeps beside // its files directory, which the move cannot carry. It archives the files // directory alone (archive.Build over /), so none of // these is ever carried; the source's retire parks every entry of the // instance directory, these included. func NestedPlatformNames(host string) map[string]bool { const dataDir, socketDir = "/data", "/run/carlos" raw := rawInstanceDir(dataDir, host) out := map[string]bool{} for _, p := range nestedFiles(dataDir, socketDir, host) { if filepath.Dir(p) == raw { out[filepath.Base(p)] = true } } db := host + ".db" out[db+".gen"] = true out[db+"-litestream"] = true out[db+activator.SeedMarkerSuffix] = true out[filesMarkerName] = true out[host+instancekey.AdoptionSuffix] = true for _, role := range []instancectl.Role{instancectl.RoleRun, instancectl.RoleSeed, instancectl.RoleSidecar, instancectl.RoleCmd} { out[instancectl.LaunchFileName(role)] = true } return out } // IsNestedPlatformName reports whether name, an entry of host's instance // directory, is the platform's: one of NestedPlatformNames, or a // generation's launch descriptor, exactly launch.gen..json with n a // decimal number as instancectl.GenLaunchFileName writes it. func IsNestedPlatformName(host, name string) bool { if NestedPlatformNames(host)[name] { return true } n, ok := strings.CutPrefix(name, "launch.gen.") if !ok { return false } n, ok = strings.CutSuffix(n, ".json") if !ok || n == "" { return false } gen, err := strconv.ParseUint(n, 10, 64) return err == nil && instancectl.GenLaunchFileName(gen) == name } // PlanFor derives the plan for host in the given direction from the host // name alone (design §8.2 step 3). The endpoints are the row values the // pass will compare against and swap; the artifacts are the files (and one // directory) it will rename. // // Both socket endpoints are derived from socketDir, never from the // compiled-in provision.SocketDir/DefaultSocket/NestedSocket: those ignore // any directory a caller passes, which is invisible in production // (socketDir is always provision.SocketDir there) but would silently break // every test that wants an isolated socket tree. func PlanFor(dataDir, socketDir, host string, mode Mode) (*Plan, error) { if mode != ModeNested && mode != ModeFlat { return nil, fmt.Errorf("layout: mode %q is not nested or flat", mode) } if !controlname.ValidHostShape(host) { return nil, fmt.Errorf("layout: %q is not a hostname", host) } iid := provision.InstanceID(host) flat := Endpoint{ DB: provision.DefaultDBPath(dataDir, host), Socket: filepath.Join(socketDir, host+".sock"), } nested := Endpoint{ DB: provision.NestedDBPath(dataDir, host), Socket: filepath.Join(socketDir, "instances", iid, "app.sock"), } src, dst := flatFiles(dataDir, socketDir, host), nestedFiles(dataDir, socketDir, host) p := &Plan{Host: host, IID: iid, Mode: mode, From: flat, To: nested, DataDir: dataDir} if mode == ModeFlat { p.From, p.To = nested, flat src, dst = dst, src } for i, name := range artifactNames { p.Artifacts = append(p.Artifacts, Artifact{ Name: name, Src: src[i], Dst: dst[i], Required: name == "db", Dir: name == "litestream-shadow", }) } return p, nil } // Journal builds the journal for this plan with the given presence // vector (one bool per fixed artifact, artifactNames order), plus the // plan's extra entries by name. func (p *Plan) Journal(present []bool) *Journal { j := &Journal{Host: p.Host, Mode: p.Mode, From: p.From, To: p.To, Phase: PhaseFiles} for i, a := range p.Artifacts { if a.Extra { j.Extra = append(j.Extra, JournalArtifact{Name: a.Name, Present: true}) continue } j.Artifacts = append(j.Artifacts, JournalArtifact{Name: a.Name, Present: i < len(present) && present[i]}) } return j }