| 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. |
| 6 | package release |
| 7 | |
| 8 | import ( |
| 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. |
| 25 | type StagedPaths map[string]bool |
| 26 | |
| 27 | func (sp StagedPaths) add(p string) { sp[filepath.Clean(p)] = true } |
| 28 | |
| 29 | // Has reports whether p is referred to. |
| 30 | func (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. |
| 37 | func (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. |
| 55 | func (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. |
| 72 | type PinSource interface { |
| 73 | StagedPins(ctx context.Context, optDir string) (StagedPaths, error) |
| 74 | } |
| 75 | |
| 76 | // PinSourceFunc adapts a function to PinSource. |
| 77 | type PinSourceFunc func(ctx context.Context, optDir string) (StagedPaths, error) |
| 78 | |
| 79 | func (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. |
| 106 | func 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. |
| 161 | type IncompleteRefsError struct { |
| 162 | apps map[string]string // "<account>/<app>" -> why |
| 163 | } |
| 164 | |
| 165 | func (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. |
| 179 | func (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. |
| 197 | func 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. |
| 234 | func 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. |
| 264 | type 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. |
| 276 | func 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. |
| 283 | func 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. |
| 295 | func 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. |
| 333 | func 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. |
| 354 | func 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. |
| 384 | func 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. |
| 412 | func writeFileAtomic(path string, body []byte) error { |
| 413 | return safefile.WriteFile(path, body, 0o644) |
| 414 | } |
| 415 | |
| 416 | func 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. |
| 442 | func 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. |
| 494 | func 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 | |