package host import ( "context" "log" "os" "path/filepath" "strings" "time" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" ) // versionSweepGraceEnv overrides release.DefaultSweepGrace, as a Go duration. // An escape hatch for a box that ships far faster than the fleet (or an // operator who wants a longer rollback window on one), not something any unit // sets: the default is the fleet's answer. const versionSweepGraceEnv = "CARLOS_VERSIONS_GRACE" func versionSweepGrace(getenv func(string) string) time.Duration { raw := getenv(versionSweepGraceEnv) if raw == "" { return release.DefaultSweepGrace } d, err := time.ParseDuration(raw) if err != nil || d < 0 { log.Printf("adopt: %s=%q is not a non-negative duration — sweeping with the default %s", versionSweepGraceEnv, raw, release.DefaultSweepGrace) return release.DefaultSweepGrace } return d } // routeLister is what the sweep needs of the registry: the rows, unfiltered. // Deliberately NOT release.Adoptable or any awake/canary/TTL filter — those // answer "should this route move to a new version", and the question here is // "is this version still spoken for", which a hibernating route, an expired // canary and a route mid-rollback all answer yes to. type routeLister interface { List() ([]registry.Route, error) } // sweepStagedVersions evicts the staged version trees nothing on this box // still names (discussion #111). // // genPath is the generations file (release.GenerationsPath of the data dir). // // It never fails the pass. Every failure path here declines to delete and // returns: an unreadable live dir, an unlistable registry or an incomplete // generations file means the pin set is incomplete, and an incomplete pin set is how a running app loses its // binary. Freeing disk an hour later is cheap; deleting a live version is not. func sweepStagedVersions(reg routeLister, optDir, genPath string, grace time.Duration, now time.Time, sources ...release.PinSource) { pinned, err := release.LivePins(optDir) if err != nil { log.Printf("adopt: staged-version sweep skipped: %v", err) return } live := len(pinned) routes, err := reg.List() if err != nil { log.Printf("adopt: staged-version sweep skipped: list routes: %v", err) return } for _, r := range routes { // PrevVersion is a pin with no grace attached: it is the rollback // target, and a rollback is wanted precisely when a box has been // running the new build long enough for the grace to have expired. if r.Version != "" { pinned[r.Version] = true } if r.PrevVersion != "" { pinned[r.PrevVersion] = true } // GoodVersion likewise (spec §8.5): it is what a failed first wake // falls back to, however long ago it last served. if r.GoodVersion != "" { pinned[r.GoodVersion] = true } } // The builds the instances' settings and build states name (ruling // R-T9-STORE): a held instance's recovery onto last_executed runs its // staged copy here (EvidenceStaged), so that copy is spoken for however // long ago it last ran. settings, err := settingsPins(routes) if err != nil { log.Printf("adopt: staged-version sweep skipped: %v", err) return } for v := range settings { pinned[v] = true } // Every live or pending generation the agent published (spec §8.5, // ruling R19 item 1): a worker's captured build stays pinned after the // live link and the row have both moved on. Neither file present is no // generations; a file that does not read or whose publication did not // finish is an incomplete pin set. gens, err := release.GenerationVersions(genPath, optDir) if err != nil { log.Printf("adopt: staged-version sweep skipped: generations: %v", err) return } for v := range gens { pinned[v] = true } for _, source := range sources { paths, err := source.StagedPins(context.Background(), optDir) if err != nil { log.Printf("adopt: staged-version sweep skipped: generations: %v", err) return } for path := range paths { rel, err := filepath.Rel(filepath.Join(optDir, "versions"), path) version, _, _ := strings.Cut(rel, string(filepath.Separator)) if err != nil || !filepath.IsLocal(rel) || !release.ValidVersion(version) { log.Printf("adopt: staged-version sweep skipped: generations: %v", os.ErrPermission) return } pinned[version] = true } } res, err := release.SweepStaged(optDir, pinned, grace, now) if err != nil { log.Printf("adopt: staged-version sweep: %v", err) return } for _, e := range res.Errs { log.Printf("adopt: staged-version sweep: %v", e) } if len(res.Removed) == 0 { // One line an hour on a box with nothing to do is worth having: it is // the difference between "the sweep found nothing" and "the sweep is // not running", which is the question the next full disk will ask. log.Printf("adopt: staged-version sweep: %d staged, %d pinned (%d live), %d within the %s grace, none removed", res.Scanned, res.Pinned, live, res.Young, grace) return } log.Printf("adopt: staged-version sweep: removed %d of %d staged versions (%d bytes), %d pinned (%d live), %d within the %s grace: %v", len(res.Removed), res.Scanned, res.Bytes, res.Pinned, live, res.Young, grace, res.Removed) } // sweepStagedVersionsPass is the RunAdopt entry point: the grace from the // environment, the clock from the wall. func sweepStagedVersionsPass(reg routeLister, sources ...release.PinSource) { sweepStagedVersions(reg, release.OptDir(), release.GenerationsPath(dataDir()), versionSweepGrace(os.Getenv), time.Now(), sources...) } // settingsPins is every build the rows' update settings and build-state // copies name: a pin, last_executed and a resolved request's T // (release.SettingsRefs, the reading the restaging pin set uses too). A copy // that kept its last known state after a failed refresh still names it. Any // row whose references are incomplete makes the whole set incomplete, so the // sweep deletes nothing: a column that does not parse, or a held instance // (settings other than default) whose build state is not read yet or whose // last refresh failed (ReadErr; Astra round 1 finding 6), whose // last_executed may name a build only its staged copy here still holds. // Version trees hold every app, so this refusal is box-wide. Only a row // whose copy the agent refreshes counts (release.BuildStateKept): one // keepsBuildState never selects (a named unit, the platform's own row) // stays unread forever and must not stop the sweep for good. func settingsPins(routes []registry.Route) (map[string]bool, error) { out := map[string]bool{} for _, r := range routes { refs, err := release.SettingsRefs(r) if err != nil { return nil, err } for _, v := range refs { out[v] = true } } return out, nil }