carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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).
8package layout
9
10import (
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.
25type Mode string
26
27const (
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.
33type 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).
46type 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.
71type 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
87func (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.
98var 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.
107func rawInstanceDir(dataDir, host string) string {
108 return filepath.Join(dataDir, ".instances", provision.InstanceID(host))
109}
110
111func 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.
122func 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.
131func 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
146func 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.
166const 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.
193func 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.
218func 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.
244func 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.
281func (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