package host import ( "context" "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/seeds" ) // The seeds protocol floor on a box (seeded-instances spec ยง3.3, codex 16): // the moves floor's mechanism (movesfloor.go), beside it. Its two local // files live in the same .moves directory and are written under the same // floor.lock; its verdict on carlos.prev sits beside the moves verdict; the // two system units carry a second clause for it; the activation rule // checks it as well. A deployment that never publishes it has none of the // files, and every path here then does what it did before. func seedsPrevAllowedPath(optDir string) string { return filepath.Join(optDir, seeds.PrevAllowedFile) } // readLocalSeedsFloor reads /.moves/seeds-floor.json without // following a symlink below dataDir. (nil, nil): not armed. A file that is // there but unusable is an error, which callers treat as armed and unknown. func readLocalSeedsFloor(dataDir string) (*seeds.Floor, error) { f, err := openBeneath(dataDir, filepath.Dir(seeds.FloorFile), filepath.Base(seeds.FloorFile)) if errors.Is(err, os.ErrNotExist) { return nil, nil } if err != nil { return nil, err } defer f.Close() st, err := f.Stat() if err != nil { return nil, err } if !st.Mode().IsRegular() || st.Size() > movesFloorMaxBytes { return nil, fmt.Errorf("%s is not a regular file under %d bytes", f.Name(), movesFloorMaxBytes) } var fl seeds.Floor if err := json.NewDecoder(io.LimitReader(f, movesFloorMaxBytes)).Decode(&fl); err != nil { return nil, fmt.Errorf("%s: %w", f.Name(), err) } if ok, err := seeds.ReleaseAtOrAbove(fl.MinRelease, fl.MinRelease); !ok || err != nil { return nil, fmt.Errorf("%s: min_release %q is not a release", f.Name(), fl.MinRelease) } return &fl, nil } // withSeedsFields sets the edges file's seeds fields from this box's own // files, read at each write, as withMovesFields does for the moves floor. func withSeedsFields(f edgesFile, dataDir, optDir string) edgesFile { if _, err := os.Lstat(rootSeedsFloorDir(optDir)); f.MovesFloorState != "" || !errors.Is(err, os.ErrNotExist) { return withRootSeedsFields(f, optDir) } f.SeedsProtocol = seeds.ProtocolVersion f.SeedsFloorMinRelease = "" if fl, err := readLocalSeedsFloor(dataDir); err == nil && fl != nil { f.SeedsFloorMinRelease = fl.MinRelease } f.PrevSeedsAllowedFor, _ = readLine(seedsPrevAllowedPath(optDir)) return f } // seedsFloorArmed: a local seeds floor that reads, or one that is there and // cannot be read. func seedsFloorArmed(dataDir string) bool { f, err := readLocalSeedsFloor(dataDir) return err != nil || f != nil } // writeSeedsFloorFiles writes the min-release line FIRST and the JSON LAST, // for writeFloorFiles' reason: after a crash between them the line can only // be ahead of the JSON, so the units never pick a carlos.prev the Go checks // would refuse. func writeSeedsFloorFiles(d *movesDir, f seeds.Floor) error { body, err := json.Marshal(f) if err != nil { return err } if err := d.WriteDurable(filepath.Base(seeds.FloorMinReleaseFile), []byte(f.MinRelease+"\n"), 0o644); err != nil { return err } return d.WriteDurable(filepath.Base(seeds.FloorFile), append(body, '\n'), 0o644) } // syncLocalSeedsFloor is syncLocalFloor's rule for the seeds floor: a // published, usable floor that is not lower is written under the floor // lock; absent leaves what is on disk; a lower one is refused; an // unreadable store leaves what is on disk and returns the error. func syncLocalSeedsFloor(ctx context.Context, s release.Store, dataDir string) (*seeds.Floor, error) { if s == nil { local, lerr := readLocalSeedsFloor(dataDir) return local, errors.Join(errors.New("no deployment store to read the seeds floor from"), lerr) } remote, rerr := seeds.ReadFloor(ctx, s) switch { case rerr != nil: local, lerr := readLocalSeedsFloor(dataDir) return local, errors.Join(rerr, lerr) case remote == nil: return readLocalSeedsFloor(dataDir) } if ok, err := seeds.ReleaseAtOrAbove(remote.MinRelease, remote.MinRelease); !ok || err != nil { local, lerr := readLocalSeedsFloor(dataDir) return local, errors.Join(fmt.Errorf("the published seeds min_release %q is not a release; the seeds floor on this box stays as it was", remote.MinRelease), lerr) } var out *seeds.Floor ran := false err := withFloorLock(dataDir, true, func(d *movesDir) error { ran = true local, lerr := readLocalSeedsFloor(dataDir) if lerr == nil && local != nil { if remote.Min < local.Min { out = local return fmt.Errorf("the published seeds floor's protocol minimum %d is below this box's %d", remote.Min, local.Min) } if ok, _ := seeds.ReleaseAtOrAbove(remote.MinRelease, local.MinRelease); !ok { out = local return fmt.Errorf("the published seeds min_release %q is below this box's %s", remote.MinRelease, local.MinRelease) } if *local == *remote { if line, ok := readBeneathLine(dataDir, seeds.FloorMinReleaseFile); ok && line == remote.MinRelease { out = local return nil } } } if err := writeSeedsFloorFiles(d, *remote); err != nil { out, _ = readLocalSeedsFloor(dataDir) return fmt.Errorf("%w: %v", errFloorWrite, err) } out = remote return nil }) if !ran { out, _ = readLocalSeedsFloor(dataDir) return out, fmt.Errorf("%w: %v", errFloorWrite, err) } return out, err } // judgeSeedsPrev writes the seeds verdict when carlos.prev is at or above // the armed seeds floor, and removes it otherwise (no carlos.prev, no // floor, no version, below). Unlike the moves verdict it does not read the // registry: a seed has no hold rows. func judgeSeedsPrev(optDir, dataDir string) error { deny := func(cause error) error { if err := removeDurable(seedsPrevAllowedPath(optDir)); err != nil { return errors.Join(cause, fmt.Errorf("remove the seeds verdict on carlos.prev: %w", err)) } return cause } if _, err := os.Stat(systemPrevPath(optDir)); err != nil { return deny(nil) } ran := false err := withFloorLock(dataDir, false, func(*movesDir) error { ran = true floor, err := readLocalSeedsFloor(dataDir) if err != nil { return deny(fmt.Errorf("seeds protocol floor: the floor on this box cannot be read: %v", err)) } if floor == nil { return deny(nil) } version, ok := readLine(prevVersionPath(optDir)) if !ok { return deny(nil) } if at, _ := seeds.ReleaseAtOrAbove(version, floor.MinRelease); !at { return deny(nil) } return writeFileDurable(seedsPrevAllowedPath(optDir), []byte(floor.MinRelease+"\n"), 0o644) }) if !ran { return deny(fmt.Errorf("seeds protocol floor: the floor on this box cannot be read: %v", err)) } return err } // seedsActivationRefusal is the seeds half of the activation rule: version // below the armed seeds floor is refused (SD-N1). The floor is read from // the local file alone (boot recovery runs before the network). Unarmed: // nil. A version of "" (a carlos.prev that records none) is below any // floor: seeds.ReleaseAtOrAbove("", ...) is false. func seedsActivationRefusal(version, dataDir string) error { floor, err := readLocalSeedsFloor(dataDir) if err != nil { return refuse("seeds protocol floor: the floor on this box cannot be read: %v", err) } if floor == nil { return nil } if ok, _ := seeds.ReleaseAtOrAbove(version, floor.MinRelease); !ok { return refuse("seeds protocol floor: %s is below %s", version, floor.MinRelease) } return nil } // armSeedsFloor is armFloor's seeds half, with the same rule: a published // floor this box could not write down defers the apply; anything else is a // log line. func (a *systemApplier) armSeedsFloor(ctx context.Context) error { if id, required, err := a.rootMoves.read(); err != nil { return err } else if required { return refreshRootSeedsFloor(ctx, a.store, a.optDir, a.dataDir, id) } f, err := syncLocalSeedsFloor(ctx, a.store, a.dataDir) if err == nil { return nil } if errors.Is(err, errFloorWrite) { return fmt.Errorf("%w: seeds protocol floor: %v", errSystemDeferred, err) } onDisk := "none" if f != nil { onDisk = f.MinRelease } a.ops.logf("system apply: seeds protocol floor: %v (the seeds floor on this box stays %s)", err, onDisk) return nil } // rootJudgeSeedsPrev is the root adopt pass's call: nothing to do on a box // that has neither a seeds floor nor a seeds verdict. func rootJudgeSeedsPrev() error { optDir, dd := release.OptDir(), dataDir() if !seedsFloorArmed(dd) { if _, err := os.Stat(seedsPrevAllowedPath(optDir)); errors.Is(err, os.ErrNotExist) { return nil } } return withPrevLock(optDir, func() error { return judgeSeedsPrev(optDir, dd) }) } // seedsFloorRefresh is the agent's hourly refresh of the seeds floor. func seedsFloorRefresh(ctx context.Context, s release.Store, dataDir string, logf func(string, ...any)) { if _, err := syncLocalSeedsFloor(ctx, s, dataDir); err != nil { logf("agent: seeds protocol floor refresh: %v", err) } }