| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "log" |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | "strings" |
| 9 | "time" |
| 10 | |
| 11 | "github.com/carlosframework/platform/internal/registry" |
| 12 | "github.com/carlosframework/platform/internal/release" |
| 13 | ) |
| 14 | |
| 15 | // versionSweepGraceEnv overrides release.DefaultSweepGrace, as a Go duration. |
| 16 | // An escape hatch for a box that ships far faster than the fleet (or an |
| 17 | // operator who wants a longer rollback window on one), not something any unit |
| 18 | // sets: the default is the fleet's answer. |
| 19 | const versionSweepGraceEnv = "CARLOS_VERSIONS_GRACE" |
| 20 | |
| 21 | func versionSweepGrace(getenv func(string) string) time.Duration { |
| 22 | raw := getenv(versionSweepGraceEnv) |
| 23 | if raw == "" { |
| 24 | return release.DefaultSweepGrace |
| 25 | } |
| 26 | d, err := time.ParseDuration(raw) |
| 27 | if err != nil || d < 0 { |
| 28 | log.Printf("adopt: %s=%q is not a non-negative duration — sweeping with the default %s", versionSweepGraceEnv, raw, release.DefaultSweepGrace) |
| 29 | return release.DefaultSweepGrace |
| 30 | } |
| 31 | return d |
| 32 | } |
| 33 | |
| 34 | // routeLister is what the sweep needs of the registry: the rows, unfiltered. |
| 35 | // Deliberately NOT release.Adoptable or any awake/canary/TTL filter — those |
| 36 | // answer "should this route move to a new version", and the question here is |
| 37 | // "is this version still spoken for", which a hibernating route, an expired |
| 38 | // canary and a route mid-rollback all answer yes to. |
| 39 | type routeLister interface { |
| 40 | List() ([]registry.Route, error) |
| 41 | } |
| 42 | |
| 43 | // sweepStagedVersions evicts the staged version trees nothing on this box |
| 44 | // still names (discussion #111). |
| 45 | // |
| 46 | // genPath is the generations file (release.GenerationsPath of the data dir). |
| 47 | // |
| 48 | // It never fails the pass. Every failure path here declines to delete and |
| 49 | // returns: an unreadable live dir, an unlistable registry or an incomplete |
| 50 | // generations file means the pin set is incomplete, and an incomplete pin set is how a running app loses its |
| 51 | // binary. Freeing disk an hour later is cheap; deleting a live version is not. |
| 52 | func sweepStagedVersions(reg routeLister, optDir, genPath string, grace time.Duration, now time.Time, sources ...release.PinSource) { |
| 53 | pinned, err := release.LivePins(optDir) |
| 54 | if err != nil { |
| 55 | log.Printf("adopt: staged-version sweep skipped: %v", err) |
| 56 | return |
| 57 | } |
| 58 | live := len(pinned) |
| 59 | routes, err := reg.List() |
| 60 | if err != nil { |
| 61 | log.Printf("adopt: staged-version sweep skipped: list routes: %v", err) |
| 62 | return |
| 63 | } |
| 64 | for _, r := range routes { |
| 65 | // PrevVersion is a pin with no grace attached: it is the rollback |
| 66 | // target, and a rollback is wanted precisely when a box has been |
| 67 | // running the new build long enough for the grace to have expired. |
| 68 | if r.Version != "" { |
| 69 | pinned[r.Version] = true |
| 70 | } |
| 71 | if r.PrevVersion != "" { |
| 72 | pinned[r.PrevVersion] = true |
| 73 | } |
| 74 | // GoodVersion likewise (spec §8.5): it is what a failed first wake |
| 75 | // falls back to, however long ago it last served. |
| 76 | if r.GoodVersion != "" { |
| 77 | pinned[r.GoodVersion] = true |
| 78 | } |
| 79 | } |
| 80 | // The builds the instances' settings and build states name (ruling |
| 81 | // R-T9-STORE): a held instance's recovery onto last_executed runs its |
| 82 | // staged copy here (EvidenceStaged), so that copy is spoken for however |
| 83 | // long ago it last ran. |
| 84 | settings, err := settingsPins(routes) |
| 85 | if err != nil { |
| 86 | log.Printf("adopt: staged-version sweep skipped: %v", err) |
| 87 | return |
| 88 | } |
| 89 | for v := range settings { |
| 90 | pinned[v] = true |
| 91 | } |
| 92 | // Every live or pending generation the agent published (spec §8.5, |
| 93 | // ruling R19 item 1): a worker's captured build stays pinned after the |
| 94 | // live link and the row have both moved on. Neither file present is no |
| 95 | // generations; a file that does not read or whose publication did not |
| 96 | // finish is an incomplete pin set. |
| 97 | gens, err := release.GenerationVersions(genPath, optDir) |
| 98 | if err != nil { |
| 99 | log.Printf("adopt: staged-version sweep skipped: generations: %v", err) |
| 100 | return |
| 101 | } |
| 102 | for v := range gens { |
| 103 | pinned[v] = true |
| 104 | } |
| 105 | for _, source := range sources { |
| 106 | paths, err := source.StagedPins(context.Background(), optDir) |
| 107 | if err != nil { |
| 108 | log.Printf("adopt: staged-version sweep skipped: generations: %v", err) |
| 109 | return |
| 110 | } |
| 111 | for path := range paths { |
| 112 | rel, err := filepath.Rel(filepath.Join(optDir, "versions"), path) |
| 113 | version, _, _ := strings.Cut(rel, string(filepath.Separator)) |
| 114 | if err != nil || !filepath.IsLocal(rel) || !release.ValidVersion(version) { |
| 115 | log.Printf("adopt: staged-version sweep skipped: generations: %v", os.ErrPermission) |
| 116 | return |
| 117 | } |
| 118 | pinned[version] = true |
| 119 | } |
| 120 | } |
| 121 | res, err := release.SweepStaged(optDir, pinned, grace, now) |
| 122 | if err != nil { |
| 123 | log.Printf("adopt: staged-version sweep: %v", err) |
| 124 | return |
| 125 | } |
| 126 | for _, e := range res.Errs { |
| 127 | log.Printf("adopt: staged-version sweep: %v", e) |
| 128 | } |
| 129 | if len(res.Removed) == 0 { |
| 130 | // One line an hour on a box with nothing to do is worth having: it is |
| 131 | // the difference between "the sweep found nothing" and "the sweep is |
| 132 | // not running", which is the question the next full disk will ask. |
| 133 | log.Printf("adopt: staged-version sweep: %d staged, %d pinned (%d live), %d within the %s grace, none removed", |
| 134 | res.Scanned, res.Pinned, live, res.Young, grace) |
| 135 | return |
| 136 | } |
| 137 | log.Printf("adopt: staged-version sweep: removed %d of %d staged versions (%d bytes), %d pinned (%d live), %d within the %s grace: %v", |
| 138 | len(res.Removed), res.Scanned, res.Bytes, res.Pinned, live, res.Young, grace, res.Removed) |
| 139 | } |
| 140 | |
| 141 | // sweepStagedVersionsPass is the RunAdopt entry point: the grace from the |
| 142 | // environment, the clock from the wall. |
| 143 | func sweepStagedVersionsPass(reg routeLister, sources ...release.PinSource) { |
| 144 | sweepStagedVersions(reg, release.OptDir(), release.GenerationsPath(dataDir()), versionSweepGrace(os.Getenv), time.Now(), sources...) |
| 145 | } |
| 146 | |
| 147 | // settingsPins is every build the rows' update settings and build-state |
| 148 | // copies name: a pin, last_executed and a resolved request's T |
| 149 | // (release.SettingsRefs, the reading the restaging pin set uses too). A copy |
| 150 | // that kept its last known state after a failed refresh still names it. Any |
| 151 | // row whose references are incomplete makes the whole set incomplete, so the |
| 152 | // sweep deletes nothing: a column that does not parse, or a held instance |
| 153 | // (settings other than default) whose build state is not read yet or whose |
| 154 | // last refresh failed (ReadErr; Astra round 1 finding 6), whose |
| 155 | // last_executed may name a build only its staged copy here still holds. |
| 156 | // Version trees hold every app, so this refusal is box-wide. Only a row |
| 157 | // whose copy the agent refreshes counts (release.BuildStateKept): one |
| 158 | // keepsBuildState never selects (a named unit, the platform's own row) |
| 159 | // stays unread forever and must not stop the sweep for good. |
| 160 | func settingsPins(routes []registry.Route) (map[string]bool, error) { |
| 161 | out := map[string]bool{} |
| 162 | for _, r := range routes { |
| 163 | refs, err := release.SettingsRefs(r) |
| 164 | if err != nil { |
| 165 | return nil, err |
| 166 | } |
| 167 | for _, v := range refs { |
| 168 | out[v] = true |
| 169 | } |
| 170 | } |
| 171 | return out, nil |
| 172 | } |
| 173 | |