carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// The pin set (zero-downtime deploys spec §4.2, §8.5): every staged copy
2// something on this box still refers to. A staged copy in it is never
3// overwritten, because staged paths are keyed by version name with no digest
4// and another instance's good build or running generation would change
5// underneath it.
6package release
7
8import (
9 "context"
10 "encoding/json"
11 "errors"
12 "fmt"
13 "os"
14 "path/filepath"
15 "sort"
16 "strconv"
17 "strings"
18
19 "github.com/carlosframework/platform/internal/registry"
20 "github.com/carlosframework/platform/internal/safefile"
21)
22
23// StagedPaths is a set of staged artifact paths (VersionPath values, held
24// cleaned) that something on this box refers to.
25type StagedPaths map[string]bool
26
27func (sp StagedPaths) add(p string) { sp[filepath.Clean(p)] = true }
28
29// Has reports whether p is referred to.
30func (sp StagedPaths) Has(p string) bool { return sp[filepath.Clean(p)] }
31
32// addReal adds p and, when p exists, the fully resolved real path it names
33// (filepath.EvalSymlinks), so a copy reached through a symlinked directory is
34// pinned under the name the OS gives it too (Astra r3 N4, ruling R19 item 5).
35// A p that does not exist adds p alone; any other resolution error is
36// returned, and the caller's pin set is incomplete.
37func (sp StagedPaths) addReal(p string) error {
38 sp.add(p)
39 real, err := filepath.EvalSymlinks(p)
40 if errors.Is(err, os.ErrNotExist) {
41 return nil
42 }
43 if err != nil {
44 return fmt.Errorf("resolve %s: %w", p, err)
45 }
46 sp.add(real)
47 return nil
48}
49
50// Refers is the membership check a restage asks: whether p, or the fully
51// resolved real path of p when p exists, is referred to. Comparing the real
52// path as well means a symlinked opt dir cannot make a pinned file look
53// unpinned under its other name (Astra r3 N4). A resolution error other than
54// not existing is returned, and never permits a restage.
55func (sp StagedPaths) Refers(p string) (bool, error) {
56 if sp.Has(p) {
57 return true, nil
58 }
59 real, err := filepath.EvalSymlinks(p)
60 if errors.Is(err, os.ErrNotExist) {
61 return false, nil
62 }
63 if err != nil {
64 return false, fmt.Errorf("resolve %s: %w", p, err)
65 }
66 return sp.Has(real), nil
67}
68
69// PinSource supplies references beyond the live links and the registry rows.
70// GenerationPins is the one this package ships. A source that cannot answer
71// returns an error, and an error never permits a restage.
72type PinSource interface {
73 StagedPins(ctx context.Context, optDir string) (StagedPaths, error)
74}
75
76// PinSourceFunc adapts a function to PinSource.
77type PinSourceFunc func(ctx context.Context, optDir string) (StagedPaths, error)
78
79func (f PinSourceFunc) StagedPins(ctx context.Context, optDir string) (StagedPaths, error) {
80 return f(ctx, optDir)
81}
82
83// BoxPins is the part of the pin set this package reads itself: every hop of
84// the symlink chain of every entry under <opt>/live (linkChain; a crashed
85// swap's <host>.tmp included, as LivePins includes it), so a live link that
86// reaches a staged copy through another link still pins that copy (a
87// relative hop pinned at the real path the OS reaches, and at its lexical
88// join too when the two differ), plus the fully resolved real path of the
89// chain's end when it exists (StagedPaths.addReal), and the staged path of
90// every row's Version, PrevVersion and GoodVersion (the good build a failed
91// first wake falls back to, spec §8.5). routes is every registry row, unfiltered: a hibernating route,
92// an expired canary and a mid-provision row all still refer to their builds.
93//
94// It also holds every build a row's update settings and build-state copy
95// name (SettingsRefs: a pin, last_executed, a resolved request's T), under
96// the row's own account and app (settings spec §4.1 retained identity;
97// Astra round 1 finding 2): a sleeper whose build state alone names a build
98// keeps that copy, executable and sibling, when another instance adopts a
99// reuse of its name.
100//
101// An entry that cannot be read is an error, never a skipped entry: a pin set
102// that quietly narrowed is how a running build's bytes get replaced. A row
103// whose settings references cannot be read completely is reported apart, as
104// an *IncompleteRefsError returned WITH the pins: it refuses a restage of its
105// own app only, so one app's unread build state does not stop another's.
106func BoxPins(optDir string, routes []registry.Route) (StagedPaths, error) {
107 pins := StagedPaths{}
108 dir := liveDir(optDir)
109 entries, err := os.ReadDir(dir)
110 if err != nil && !os.IsNotExist(err) {
111 return nil, fmt.Errorf("read %s: %w", dir, err)
112 }
113 for _, e := range entries {
114 _, hops, aliases, err := linkChain(filepath.Join(dir, e.Name()))
115 if err != nil {
116 return nil, err
117 }
118 for _, h := range hops {
119 pins.add(h)
120 }
121 for _, h := range aliases {
122 pins.add(h)
123 }
124 // And the fully resolved real path of where the chain ends (Astra r3
125 // N4, ruling R19 item 5): a hop written through a symlinked directory
126 // names the copy under another spelling than VersionPath's.
127 if err := pins.addReal(hops[len(hops)-1]); err != nil {
128 return nil, fmt.Errorf("live link %s: %w", filepath.Join(dir, e.Name()), err)
129 }
130 }
131 var incomplete *IncompleteRefsError
132 for _, rt := range routes {
133 if rt.App == "" {
134 continue
135 }
136 acct := RouteAccount(rt)
137 refs, rerr := SettingsRefs(rt)
138 for _, v := range append([]string{rt.Version, rt.PrevVersion, rt.GoodVersion}, refs...) {
139 if ValidVersion(v) {
140 pins.add(VersionPath(optDir, acct, v, rt.App))
141 }
142 }
143 if rerr != nil {
144 if incomplete == nil {
145 incomplete = &IncompleteRefsError{apps: map[string]string{}}
146 }
147 incomplete.apps[acct+"/"+rt.App] = rerr.Error()
148 }
149 }
150 if incomplete != nil {
151 return pins, incomplete
152 }
153 return pins, nil
154}
155
156// IncompleteRefsError is BoxPins' report of apps whose settings references
157// (SettingsRefs) could not be read completely. BoxPins returns it together
158// with the pins it did read: a restage of a copy of a named app is refused,
159// because a held instance's last_executed may be the very name being reused;
160// any other app restages on the pins as usual.
161type IncompleteRefsError struct {
162 apps map[string]string // "<account>/<app>" -> why
163}
164
165func (e *IncompleteRefsError) Error() string {
166 keys := make([]string, 0, len(e.apps))
167 for k := range e.apps {
168 keys = append(keys, k)
169 }
170 sort.Strings(keys)
171 parts := make([]string, 0, len(keys))
172 for _, k := range keys {
173 parts = append(parts, k+": "+e.apps[k])
174 }
175 return "settings references incomplete for " + strings.Join(parts, "; ")
176}
177
178// Covers reports whether acct/app's references are incomplete.
179func (e *IncompleteRefsError) Covers(acct, app string) bool {
180 _, ok := e.apps[acct+"/"+app]
181 return ok
182}
183
184// SettingsRefs is every build a row's update settings and build-state copy
185// name (settings spec §4.4, §4.1): its pin, last_executed and a resolved
186// request's T, read as root reads the columns (ParseUpdateSettingsLenient,
187// ParseBuildStateCopy). A copy that kept its last known state after a failed
188// refresh still names it.
189//
190// The error says the list is incomplete, and comes with what could be read:
191// a column that does not parse; or a row settings hold (non-default settings
192// on a row whose build state the agent refreshes, BuildStateKept) whose copy
193// is not read yet, or whose latest refresh failed (ReadErr; Astra round 1
194// finding 6): its last_executed may name another build by now. A row the
195// agent never refreshes (a named unit, the platform's own row, a worker)
196// stays unread forever, and must not make every set incomplete for good.
197func SettingsRefs(rt registry.Route) ([]string, error) {
198 var out []string
199 u, uerr := ParseUpdateSettingsLenient(rt.UpdateSettings)
200 if uerr == nil && u.Pin != "" {
201 out = append(out, u.Pin)
202 }
203 c, cerr := ParseBuildStateCopy(rt.BuildState)
204 if cerr == nil && c.State != nil {
205 if c.State.LastExecuted != "" {
206 out = append(out, c.State.LastExecuted)
207 }
208 if r := c.State.Resolved; r != nil && r.Target != "" {
209 out = append(out, r.Target)
210 }
211 }
212 switch {
213 case uerr != nil:
214 return out, fmt.Errorf("update settings of %s: %w", rt.Host, uerr)
215 case cerr != nil:
216 return out, fmt.Errorf("build state of %s: %w", rt.Host, cerr)
217 case u.Default() || !BuildStateKept(rt):
218 return out, nil
219 case !c.Known:
220 return out, fmt.Errorf("the build state of held %s is not read yet", rt.Host)
221 case c.ReadErr != "":
222 return out, fmt.Errorf("the build state of held %s could not be read at its last refresh (%s)", rt.Host, c.ReadErr)
223 }
224 return out, nil
225}
226
227// BuildStateKept is a row update settings can hold whose build state the
228// agent keeps and refreshes at every reconcile: an activator-managed exec
229// instance (Kind instance, hibernating, a socket, no Backing, no Tunnel), not
230// a named unit and not the platform's own row. It is the host's
231// keepsBuildState less workers, which settings never apply to; this package
232// cannot import the activator, so the condition is spelled out here and the
233// host's sweep uses this one.
234func BuildStateKept(rt registry.Route) bool {
235 return rt.Kind == "instance" && rt.Unit == "" && !rt.Platform &&
236 rt.Hibernate && rt.Socket != "" && rt.Backing == "" && rt.Tunnel == ""
237}
238
239// Generations is the generations file: for each host with a live or pending
240// generation (current, successor, retiring, spawning: spec §8.5), the build
241// each one runs. Live generations exist only in the agent's memory, while the
242// pin set is also needed by root's pass, a separate process; this file is how
243// root learns them (index amendments, 2026-10-07). The agent's activator
244// writes it with WriteGenerations, listing every worker's captured serving
245// build and every build a spawn is about to exec, before that exec and
246// whenever a spawn or stop changes the set (rulings R17, R19); plan 5 extends
247// the same writer to its operation set.
248//
249// A build is named by version (Hosts, placed under the host's row by
250// GenerationPins) and by the absolute staged path it is exec'd from (Paths,
251// pinned as written, no row needed). Paths covers a build whose Version is
252// empty, such as one reached through an operator's chained link, which a
253// version alone cannot name (ruling R19 item 2).
254//
255// On disk: {"schema":1,"seq":N,"complete":true,"hosts":{"<host>":["<version>",...]},
256// "paths":{"<host>":["/abs/path",...]}}. A file with only hosts, only paths,
257// or no seq (as first written, ruling R17) still reads.
258//
259// Publication is two atomic writes (ruling R19 item 4): first the intent
260// file, GenerationsSeqPath, holding the publication's sequence N, then this
261// file carrying seq N and complete:true. A publication that fails between
262// them leaves the intent ahead of the file, and the reader then refuses the
263// older file instead of trusting it.
264type Generations struct {
265 Schema int `json:"schema"` // 1
266 Seq uint64 `json:"seq,omitempty"`
267 Complete bool `json:"complete,omitempty"`
268 Hosts map[string][]string `json:"hosts"`
269 Paths map[string][]string `json:"paths,omitempty"`
270}
271
272// GenerationsPath is the generations file under a data dir:
273// /data/zdd/generations.json in production. The data dir is what every
274// production pass already passes as BakeDir, and the one place both the
275// sandboxed agent and root can write and read.
276func GenerationsPath(dataDir string) string {
277 return filepath.Join(dataDir, "zdd", "generations.json")
278}
279
280// GenerationsSeqPath is the publication intent file beside the generations
281// file at path: /data/zdd/generations.seq in production. It holds the
282// sequence of the latest publication begun, as a decimal number.
283func GenerationsSeqPath(path string) string {
284 return filepath.Join(filepath.Dir(path), "generations.seq")
285}
286
287// ReadGenerations reads the generations file and its intent file, the one
288// reader of both (GenerationPins and the version sweep's GenerationVersions
289// use it). Neither file present is (nil, nil): no generations, which is every
290// box before its agent first published. With the intent present, the file
291// must exist, read, parse, and carry complete:true and the intent's sequence;
292// anything else is a publication that did not finish, and an error. A file
293// with no intent is read as first written (ruling R17) only if it carries no
294// sequence. Any error means the caller's pin set is incomplete.
295func ReadGenerations(path string) (*Generations, error) {
296 seqPath := GenerationsSeqPath(path)
297 intent, hasIntent, err := readGenerationsSeq(seqPath)
298 if err != nil {
299 return nil, err
300 }
301 raw, err := safefile.ReadFile(path)
302 if errors.Is(err, os.ErrNotExist) {
303 if !hasIntent {
304 return nil, nil
305 }
306 return nil, fmt.Errorf("generations file %s: absent while %s names publication %d: incomplete", path, seqPath, intent)
307 }
308 if err != nil {
309 return nil, fmt.Errorf("generations file %s: %w", path, err)
310 }
311 var g Generations
312 if err := json.Unmarshal(raw, &g); err != nil {
313 return nil, fmt.Errorf("generations file %s: %v", path, err)
314 }
315 if g.Schema != 1 {
316 return nil, fmt.Errorf("generations file %s: unsupported schema %d", path, g.Schema)
317 }
318 if err := validGenerations(g); err != nil {
319 return nil, fmt.Errorf("generations file %s: %w", path, err)
320 }
321 switch {
322 case hasIntent && (!g.Complete || g.Seq != intent):
323 return nil, fmt.Errorf("generations file %s: publication %d (complete %t) while %s names publication %d: incomplete", path, g.Seq, g.Complete, seqPath, intent)
324 case !hasIntent && (g.Seq != 0 || g.Complete):
325 return nil, fmt.Errorf("generations file %s: publication %d with no intent file %s: incomplete", path, g.Seq, seqPath)
326 }
327 return &g, nil
328}
329
330// readGenerationsSeq reads the intent file: (0, false, nil) when it is
331// absent, an error when it cannot be read or does not hold a sequence of 1 or
332// more.
333func readGenerationsSeq(seqPath string) (uint64, bool, error) {
334 raw, err := safefile.ReadFile(seqPath)
335 if errors.Is(err, os.ErrNotExist) {
336 return 0, false, nil
337 }
338 if err != nil {
339 return 0, false, fmt.Errorf("generations intent %s: %w", seqPath, err)
340 }
341 n, err := strconv.ParseUint(strings.TrimSpace(string(raw)), 10, 64)
342 if err != nil || n == 0 {
343 return 0, false, fmt.Errorf("generations intent %s: %q is not a publication sequence: incomplete", seqPath, raw)
344 }
345 return n, true, nil
346}
347
348// nextGenerationsSeq is the sequence for the next publication: one more than
349// the larger of the intent on disk and the file's own seq, so it is
350// monotonic across agent restarts. An intent that cannot be read is an error
351// (no publication); one that reads but does not parse, or a file that does
352// not read, counts as 0, because the file on disk can never carry the new
353// sequence either way.
354func nextGenerationsSeq(path string) (uint64, error) {
355 var n uint64
356 seqPath := GenerationsSeqPath(path)
357 raw, err := safefile.ReadFile(seqPath)
358 switch {
359 case err == nil:
360 if v, perr := strconv.ParseUint(strings.TrimSpace(string(raw)), 10, 64); perr == nil {
361 n = v
362 }
363 case !errors.Is(err, os.ErrNotExist):
364 return 0, fmt.Errorf("generations intent %s: %w", seqPath, err)
365 }
366 if body, err := safefile.ReadFile(path); err == nil {
367 var g Generations
368 if json.Unmarshal(body, &g) == nil && g.Seq > n {
369 n = g.Seq
370 }
371 }
372 return n + 1, nil
373}
374
375// WriteGenerations publishes g (ruling R19 item 4). It validates g first, so
376// a refused g touches neither file. Then it writes the intent file holding
377// the next sequence N, and then the generations file carrying seq N and
378// complete:true. Each write is atomic: a temp file in the same directory,
379// fsynced, mode 0644 (root and the agent both read them), then renamed over
380// the old one, so a reader sees the old file or the new one and never a
381// partial write. A failure of either write is returned; after a failed second
382// write the intent is ahead of the file, which the reader refuses. g's own
383// Schema, Seq and Complete are ignored.
384func WriteGenerations(path string, g Generations) error {
385 g.Schema = 1
386 if g.Hosts == nil {
387 g.Hosts = map[string][]string{}
388 }
389 if err := validGenerations(g); err != nil {
390 return err
391 }
392 if err := safefile.MkdirAll(filepath.Dir(path), 0o755); err != nil {
393 return err
394 }
395 seq, err := nextGenerationsSeq(path)
396 if err != nil {
397 return err
398 }
399 if err := writeFileAtomic(GenerationsSeqPath(path), []byte(strconv.FormatUint(seq, 10)+"\n")); err != nil {
400 return err
401 }
402 g.Seq, g.Complete = seq, true
403 body, err := json.MarshalIndent(g, "", " ")
404 if err != nil {
405 return err
406 }
407 return writeFileAtomic(path, append(body, '\n'))
408}
409
410// writeFileAtomic replaces path with body: a temp file in path's directory,
411// written, mode 0644, fsynced, closed, then renamed over path.
412func writeFileAtomic(path string, body []byte) error {
413 return safefile.WriteFile(path, body, 0o644)
414}
415
416func validGenerations(g Generations) error {
417 for host, vers := range g.Hosts {
418 for _, v := range vers {
419 if !ValidVersion(v) {
420 return fmt.Errorf("host %s names invalid version %q", host, v)
421 }
422 }
423 }
424 for host, paths := range g.Paths {
425 for _, p := range paths {
426 if p == "" || !filepath.IsAbs(p) {
427 return fmt.Errorf("host %s names staged path %q, which is not absolute", host, p)
428 }
429 }
430 }
431 return nil
432}
433
434// GenerationPins is the PinSource for the generations file at path. Each
435// listed version is placed under its host's own account and app, from rows,
436// and each listed path is pinned as written (cleaned), with the real path it
437// resolves to when it exists, and needs no row. A file ReadGenerations
438// refuses, a listed version whose host has no row to place it, or a path
439// whose resolution fails other than by not existing, is an error: those
440// bytes are running somewhere this pass cannot name, so nothing may be
441// restaged.
442func GenerationPins(path string, rows func() ([]registry.Route, error)) PinSource {
443 return PinSourceFunc(func(_ context.Context, optDir string) (StagedPaths, error) {
444 g, err := ReadGenerations(path)
445 if err != nil {
446 return nil, err
447 }
448 pins := StagedPaths{}
449 if g == nil {
450 return pins, nil
451 }
452 for _, paths := range g.Paths {
453 for _, p := range paths {
454 if err := pins.addReal(p); err != nil {
455 return nil, fmt.Errorf("generations file %s: %w", path, err)
456 }
457 }
458 }
459 if len(g.Hosts) == 0 {
460 return pins, nil
461 }
462 rs, err := rows()
463 if err != nil {
464 return nil, fmt.Errorf("list routes: %w", err)
465 }
466 byHost := make(map[string]registry.Route, len(rs))
467 for _, rt := range rs {
468 byHost[rt.Host] = rt
469 }
470 for host, vers := range g.Hosts {
471 rt, ok := byHost[host]
472 if !ok || rt.App == "" {
473 return nil, fmt.Errorf("generations file %s: %s has running generations and no registry row to place them", path, host)
474 }
475 for _, v := range vers {
476 pins.add(VersionPath(optDir, RouteAccount(rt), v, rt.App))
477 }
478 }
479 return pins, nil
480 })
481}
482
483// GenerationVersions is the generations file as the version sweep keys its
484// pins (spec §8.5, ruling R19 item 1): the name of every version directory a
485// listed build lives in. Each listed version is one. Each listed path inside
486// optDir's version store names its version directory, whether it is written
487// under the opt dir's own name or reaches the store by its real path (a
488// symlinked opt dir), so a build named only by path still protects its
489// directory. A path outside the store names none: the sweep removes nothing
490// there. It reads the file with ReadGenerations, so neither file is no
491// versions, and an incomplete file, or a path or the store that cannot be
492// resolved other than by not existing, is an error: the sweep's pin set is
493// then incomplete.
494func GenerationVersions(path, optDir string) (map[string]bool, error) {
495 g, err := ReadGenerations(path)
496 if err != nil {
497 return nil, err
498 }
499 vers := map[string]bool{}
500 if g == nil {
501 return vers, nil
502 }
503 for _, vs := range g.Hosts {
504 for _, v := range vs {
505 vers[v] = true
506 }
507 }
508 if len(g.Paths) == 0 {
509 return vers, nil
510 }
511 bases := []string{versionsDir(optDir)}
512 realBase, err := filepath.EvalSymlinks(versionsDir(optDir))
513 switch {
514 case err == nil:
515 bases = append(bases, realBase)
516 case !errors.Is(err, os.ErrNotExist):
517 return nil, fmt.Errorf("resolve %s: %w", versionsDir(optDir), err)
518 }
519 for _, ps := range g.Paths {
520 for _, p := range ps {
521 names := []string{filepath.Clean(p)}
522 real, err := filepath.EvalSymlinks(p)
523 switch {
524 case err == nil:
525 names = append(names, real)
526 case !errors.Is(err, os.ErrNotExist):
527 return nil, fmt.Errorf("generations file %s: resolve %s: %w", path, p, err)
528 }
529 for _, n := range names {
530 for _, b := range bases {
531 if v := versionUnder(b, n); v != "" {
532 vers[v] = true
533 }
534 }
535 }
536 }
537 }
538 return vers, nil
539}
540