| 1 | // Package layout is the pass that moves an activator-managed host between |
| 2 | // the flat layout (<data>/<host>.db, /run/carlos/<host>.sock) and the nested |
| 3 | // one (<data>/instances/<iid>/, /run/carlos/instances/<iid>/), and back |
| 4 | // (isolated-exec design §8.2). It builds its plan from the row and the host |
| 5 | // name only, journals every step in /data/layout, and recovers from the |
| 6 | // journal exactly. Nothing in an instance directory is read to decide |
| 7 | // anything (§3.3). |
| 8 | package layout |
| 9 | |
| 10 | import ( |
| 11 | "fmt" |
| 12 | "path/filepath" |
| 13 | "strconv" |
| 14 | "strings" |
| 15 | |
| 16 | "github.com/carlosframework/platform/internal/activator" |
| 17 | "github.com/carlosframework/platform/internal/controlname" |
| 18 | "github.com/carlosframework/platform/internal/instancectl" |
| 19 | "github.com/carlosframework/platform/internal/instancekey" |
| 20 | "github.com/carlosframework/platform/internal/provision" |
| 21 | "github.com/carlosframework/platform/internal/release" |
| 22 | ) |
| 23 | |
| 24 | // Mode is the direction of a move: the layout the host ends in. |
| 25 | type Mode string |
| 26 | |
| 27 | const ( |
| 28 | ModeNested Mode = release.LayoutModeNested |
| 29 | ModeFlat Mode = release.LayoutModeFlat |
| 30 | ) |
| 31 | |
| 32 | // Endpoint is the pair of row columns a layout is defined by. |
| 33 | type Endpoint struct { |
| 34 | DB string `json:"db"` |
| 35 | Socket string `json:"socket"` |
| 36 | } |
| 37 | |
| 38 | // Artifact is one file (or, for litestream-shadow, directory) the pass |
| 39 | // moves. Src and Dst are RAW paths for anything inside the instance tree |
| 40 | // (design §3.2): the bind mount at /data/instances is a different mount |
| 41 | // from /data/.instances and rename(2) between them answers EXDEV, so the |
| 42 | // pass renames through the raw directory and the mount shows the result. |
| 43 | // The socket artifact is the one exception: nothing is ever renamed for |
| 44 | // it, so its Src/Dst are the plan's own endpoint sockets (design §8.2 |
| 45 | // step 3; see PlanFor). |
| 46 | type Artifact struct { |
| 47 | Name string |
| 48 | Src, Dst string |
| 49 | // Required marks the database: a missing required source is an error, |
| 50 | // a missing optional one is recorded absent and skipped (R2-7). |
| 51 | Required bool |
| 52 | // Dir marks an artifact that is a directory rather than a regular |
| 53 | // file (only litestream-shadow, design §8.2 step 3): the preflight |
| 54 | // and the rename step need to know this before they touch it, since |
| 55 | // a directory cannot be opened, stat'd or renamed the way a file is. |
| 56 | Dir bool |
| 57 | // Present records whether this artifact's source exists. PlanFor |
| 58 | // never stats anything (it builds the plan from the row and the host |
| 59 | // name alone, §3.3), so Present is always false here; the pass fills |
| 60 | // it in during preflight. |
| 61 | Present bool |
| 62 | // Extra marks an entry the app made for itself under the host's name |
| 63 | // (extras.go), found by ScanExtras or recorded in the journal rather |
| 64 | // than derived by PlanFor. Always Present: it is in the plan because |
| 65 | // it was seen. Name is the entry's own base name. |
| 66 | Extra bool |
| 67 | } |
| 68 | |
| 69 | // Plan is everything the pass will do for one host, computed before it |
| 70 | // touches anything. |
| 71 | type Plan struct { |
| 72 | Host string |
| 73 | IID string |
| 74 | Mode Mode |
| 75 | From, To Endpoint |
| 76 | Artifacts []Artifact |
| 77 | // DataDir is the box's data directory, kept so the checks that run on |
| 78 | // the plan can find the pass's own directory under it (the aside |
| 79 | // directory, outside any instance tree) without being handed it again. |
| 80 | DataDir string |
| 81 | // Logf is the pass's logger, set by the pass after PlanFor so the |
| 82 | // checks that run on the plan (the extras scan, the link walk) can |
| 83 | // say what they are doing and what they left. Nil logs nothing. |
| 84 | Logf func(string, ...any) |
| 85 | } |
| 86 | |
| 87 | func (p *Plan) logf(format string, a ...any) { |
| 88 | if p.Logf != nil { |
| 89 | p.Logf(format, a...) |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | // artifactNames is the fixed order of the nine artifacts the pass tracks. |
| 94 | // litestream-shadow and corrupt sit after socket so that an index into an |
| 95 | // existing Artifacts slice (Artifacts[0] for the db, and so on) keeps its |
| 96 | // meaning. The journal records presence by this order, so it is part of |
| 97 | // the on-disk contract. |
| 98 | var artifactNames = []string{ |
| 99 | "db", "db-wal", "db-shm", "env", "sidecar-env", "config-env", "socket", |
| 100 | "litestream-shadow", "corrupt", |
| 101 | } |
| 102 | |
| 103 | // rawInstanceDir and rawRunDir are the ONLY places the platform names the |
| 104 | // raw directories behind the two bind mounts, so a grep test can pin that |
| 105 | // and catch any new hand-built path that starts reading or writing through |
| 106 | // the bind mount instead. |
| 107 | func rawInstanceDir(dataDir, host string) string { |
| 108 | return filepath.Join(dataDir, ".instances", provision.InstanceID(host)) |
| 109 | } |
| 110 | |
| 111 | func rawRunDir(socketDir, host string) string { |
| 112 | return filepath.Join(socketDir, ".instances", provision.InstanceID(host)) |
| 113 | } |
| 114 | |
| 115 | // shadowName is the litestream shadow directory's file name for host, |
| 116 | // derived exactly as internal/lease/lease.go's newStore does for a |
| 117 | // database path whose base name is "<host>.db". That derivation is on an |
| 118 | // unexported helper in internal/lease, so it is repeated here rather than |
| 119 | // imported: this package has no other reason to depend on litestream's |
| 120 | // setup, and a two-line string format is cheaper to keep in step by hand |
| 121 | // than a new cross-package dependency is to justify. |
| 122 | func shadowName(host string) string { |
| 123 | return ".activator-" + host + ".db-litestream" |
| 124 | } |
| 125 | |
| 126 | // flatFiles and nestedFiles are the nine artifact paths under each layout, |
| 127 | // in artifactNames order. The nested set is expressed through the raw |
| 128 | // directories, except for the socket, which the pass never renames and so |
| 129 | // is expressed through the mount-visible ("instances", not ".instances") |
| 130 | // path the row itself will carry. |
| 131 | func flatFiles(dataDir, socketDir, host string) []string { |
| 132 | db := provision.DefaultDBPath(dataDir, host) |
| 133 | return []string{ |
| 134 | db, |
| 135 | db + "-wal", |
| 136 | db + "-shm", |
| 137 | filepath.Join(dataDir, host+".env"), |
| 138 | filepath.Join(dataDir, host+"-sidecar.env"), |
| 139 | filepath.Join(dataDir, host+".config.env"), |
| 140 | filepath.Join(socketDir, host+".sock"), |
| 141 | filepath.Join(dataDir, shadowName(host)), |
| 142 | db + ".corrupt", |
| 143 | } |
| 144 | } |
| 145 | |
| 146 | func nestedFiles(dataDir, socketDir, host string) []string { |
| 147 | d := rawInstanceDir(dataDir, host) |
| 148 | db := filepath.Join(d, host+".db") |
| 149 | return []string{ |
| 150 | db, |
| 151 | filepath.Join(d, host+".db-wal"), |
| 152 | filepath.Join(d, host+".db-shm"), |
| 153 | filepath.Join(d, host+".env"), |
| 154 | filepath.Join(d, host+"-sidecar.env"), |
| 155 | filepath.Join(d, host+".config.env"), |
| 156 | filepath.Join(socketDir, "instances", provision.InstanceID(host), "app.sock"), |
| 157 | filepath.Join(d, shadowName(host)), |
| 158 | db + ".corrupt", |
| 159 | } |
| 160 | } |
| 161 | |
| 162 | // filesMarkerName is the files directory's generation marker |
| 163 | // (archive.MarkerName, hostfiles' marker), named here rather than imported: |
| 164 | // the archive package depends on hostfiles and appconfig, which this |
| 165 | // package has no other reason to. |
| 166 | const filesMarkerName = ".files-gen" |
| 167 | |
| 168 | // NestedPlatformNames is every name in a nested host's instance directory |
| 169 | // that is the platform's, not the app's: the names nestedFiles puts in the |
| 170 | // raw instance directory (the database, its WAL, SHM and quarantine, the |
| 171 | // own and sidecar env files, the rendered configuration, the activator's |
| 172 | // litestream shadow; the socket is in the run directory and is not here), |
| 173 | // the database's generation marker (<db>.gen), litestream's stock meta |
| 174 | // directory (<db>-litestream), the files generation marker (.files-gen) and |
| 175 | // the instance-key adoption record, the seed marker a seeded database |
| 176 | // keeps beside it for its life (<db>.seed, activator.SeedMarkerSuffix, |
| 177 | // written by ExecBackend.seededRestore in activator/seedplace.go), and the |
| 178 | // unit launch descriptors written into the instance directory: |
| 179 | // launch.<role>.json for every instancectl role |
| 180 | // (instancectl.LaunchFileName; written by ExecBackend.writeLaunchFor in |
| 181 | // activator/prepare.go, and launch.run.json also by root's role launch in |
| 182 | // activator/rolelaunch_root_linux.go). A |
| 183 | // generation's descriptor, launch.gen.<n>.json (instancectl. |
| 184 | // GenLaunchFileName, written by writeLaunchFor), has no fixed name: |
| 185 | // IsNestedPlatformName answers for it. Everything else in the directory is |
| 186 | // the app's: its files directory <host> and anything it keeps beside it. |
| 187 | // |
| 188 | // The offline move's sealer uses these to find what the app keeps beside |
| 189 | // its files directory, which the move cannot carry. It archives the files |
| 190 | // directory alone (archive.Build over <instance dir>/<host>), so none of |
| 191 | // these is ever carried; the source's retire parks every entry of the |
| 192 | // instance directory, these included. |
| 193 | func NestedPlatformNames(host string) map[string]bool { |
| 194 | const dataDir, socketDir = "/data", "/run/carlos" |
| 195 | raw := rawInstanceDir(dataDir, host) |
| 196 | out := map[string]bool{} |
| 197 | for _, p := range nestedFiles(dataDir, socketDir, host) { |
| 198 | if filepath.Dir(p) == raw { |
| 199 | out[filepath.Base(p)] = true |
| 200 | } |
| 201 | } |
| 202 | db := host + ".db" |
| 203 | out[db+".gen"] = true |
| 204 | out[db+"-litestream"] = true |
| 205 | out[db+activator.SeedMarkerSuffix] = true |
| 206 | out[filesMarkerName] = true |
| 207 | out[host+instancekey.AdoptionSuffix] = true |
| 208 | for _, role := range []instancectl.Role{instancectl.RoleRun, instancectl.RoleSeed, instancectl.RoleSidecar, instancectl.RoleCmd} { |
| 209 | out[instancectl.LaunchFileName(role)] = true |
| 210 | } |
| 211 | return out |
| 212 | } |
| 213 | |
| 214 | // IsNestedPlatformName reports whether name, an entry of host's instance |
| 215 | // directory, is the platform's: one of NestedPlatformNames, or a |
| 216 | // generation's launch descriptor, exactly launch.gen.<n>.json with n a |
| 217 | // decimal number as instancectl.GenLaunchFileName writes it. |
| 218 | func IsNestedPlatformName(host, name string) bool { |
| 219 | if NestedPlatformNames(host)[name] { |
| 220 | return true |
| 221 | } |
| 222 | n, ok := strings.CutPrefix(name, "launch.gen.") |
| 223 | if !ok { |
| 224 | return false |
| 225 | } |
| 226 | n, ok = strings.CutSuffix(n, ".json") |
| 227 | if !ok || n == "" { |
| 228 | return false |
| 229 | } |
| 230 | gen, err := strconv.ParseUint(n, 10, 64) |
| 231 | return err == nil && instancectl.GenLaunchFileName(gen) == name |
| 232 | } |
| 233 | |
| 234 | // PlanFor derives the plan for host in the given direction from the host |
| 235 | // name alone (design §8.2 step 3). The endpoints are the row values the |
| 236 | // pass will compare against and swap; the artifacts are the files (and one |
| 237 | // directory) it will rename. |
| 238 | // |
| 239 | // Both socket endpoints are derived from socketDir, never from the |
| 240 | // compiled-in provision.SocketDir/DefaultSocket/NestedSocket: those ignore |
| 241 | // any directory a caller passes, which is invisible in production |
| 242 | // (socketDir is always provision.SocketDir there) but would silently break |
| 243 | // every test that wants an isolated socket tree. |
| 244 | func PlanFor(dataDir, socketDir, host string, mode Mode) (*Plan, error) { |
| 245 | if mode != ModeNested && mode != ModeFlat { |
| 246 | return nil, fmt.Errorf("layout: mode %q is not nested or flat", mode) |
| 247 | } |
| 248 | if !controlname.ValidHostShape(host) { |
| 249 | return nil, fmt.Errorf("layout: %q is not a hostname", host) |
| 250 | } |
| 251 | iid := provision.InstanceID(host) |
| 252 | flat := Endpoint{ |
| 253 | DB: provision.DefaultDBPath(dataDir, host), |
| 254 | Socket: filepath.Join(socketDir, host+".sock"), |
| 255 | } |
| 256 | nested := Endpoint{ |
| 257 | DB: provision.NestedDBPath(dataDir, host), |
| 258 | Socket: filepath.Join(socketDir, "instances", iid, "app.sock"), |
| 259 | } |
| 260 | src, dst := flatFiles(dataDir, socketDir, host), nestedFiles(dataDir, socketDir, host) |
| 261 | p := &Plan{Host: host, IID: iid, Mode: mode, From: flat, To: nested, DataDir: dataDir} |
| 262 | if mode == ModeFlat { |
| 263 | p.From, p.To = nested, flat |
| 264 | src, dst = dst, src |
| 265 | } |
| 266 | for i, name := range artifactNames { |
| 267 | p.Artifacts = append(p.Artifacts, Artifact{ |
| 268 | Name: name, |
| 269 | Src: src[i], |
| 270 | Dst: dst[i], |
| 271 | Required: name == "db", |
| 272 | Dir: name == "litestream-shadow", |
| 273 | }) |
| 274 | } |
| 275 | return p, nil |
| 276 | } |
| 277 | |
| 278 | // Journal builds the journal for this plan with the given presence |
| 279 | // vector (one bool per fixed artifact, artifactNames order), plus the |
| 280 | // plan's extra entries by name. |
| 281 | func (p *Plan) Journal(present []bool) *Journal { |
| 282 | j := &Journal{Host: p.Host, Mode: p.Mode, From: p.From, To: p.To, Phase: PhaseFiles} |
| 283 | for i, a := range p.Artifacts { |
| 284 | if a.Extra { |
| 285 | j.Extra = append(j.Extra, JournalArtifact{Name: a.Name, Present: true}) |
| 286 | continue |
| 287 | } |
| 288 | j.Artifacts = append(j.Artifacts, JournalArtifact{Name: a.Name, Present: i < len(present) && present[i]}) |
| 289 | } |
| 290 | return j |
| 291 | } |
| 292 | |