| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "fmt" |
| 8 | "io" |
| 9 | "os" |
| 10 | "path/filepath" |
| 11 | |
| 12 | "github.com/carlosframework/platform/internal/release" |
| 13 | "github.com/carlosframework/platform/internal/seeds" |
| 14 | ) |
| 15 | |
| 16 | // The seeds protocol floor on a box (seeded-instances spec §3.3, codex 16): |
| 17 | // the moves floor's mechanism (movesfloor.go), beside it. Its two local |
| 18 | // files live in the same .moves directory and are written under the same |
| 19 | // floor.lock; its verdict on carlos.prev sits beside the moves verdict; the |
| 20 | // two system units carry a second clause for it; the activation rule |
| 21 | // checks it as well. A deployment that never publishes it has none of the |
| 22 | // files, and every path here then does what it did before. |
| 23 | |
| 24 | func seedsPrevAllowedPath(optDir string) string { return filepath.Join(optDir, seeds.PrevAllowedFile) } |
| 25 | |
| 26 | // readLocalSeedsFloor reads <dataDir>/.moves/seeds-floor.json without |
| 27 | // following a symlink below dataDir. (nil, nil): not armed. A file that is |
| 28 | // there but unusable is an error, which callers treat as armed and unknown. |
| 29 | func readLocalSeedsFloor(dataDir string) (*seeds.Floor, error) { |
| 30 | f, err := openBeneath(dataDir, filepath.Dir(seeds.FloorFile), filepath.Base(seeds.FloorFile)) |
| 31 | if errors.Is(err, os.ErrNotExist) { |
| 32 | return nil, nil |
| 33 | } |
| 34 | if err != nil { |
| 35 | return nil, err |
| 36 | } |
| 37 | defer f.Close() |
| 38 | st, err := f.Stat() |
| 39 | if err != nil { |
| 40 | return nil, err |
| 41 | } |
| 42 | if !st.Mode().IsRegular() || st.Size() > movesFloorMaxBytes { |
| 43 | return nil, fmt.Errorf("%s is not a regular file under %d bytes", f.Name(), movesFloorMaxBytes) |
| 44 | } |
| 45 | var fl seeds.Floor |
| 46 | if err := json.NewDecoder(io.LimitReader(f, movesFloorMaxBytes)).Decode(&fl); err != nil { |
| 47 | return nil, fmt.Errorf("%s: %w", f.Name(), err) |
| 48 | } |
| 49 | if ok, err := seeds.ReleaseAtOrAbove(fl.MinRelease, fl.MinRelease); !ok || err != nil { |
| 50 | return nil, fmt.Errorf("%s: min_release %q is not a release", f.Name(), fl.MinRelease) |
| 51 | } |
| 52 | return &fl, nil |
| 53 | } |
| 54 | |
| 55 | // withSeedsFields sets the edges file's seeds fields from this box's own |
| 56 | // files, read at each write, as withMovesFields does for the moves floor. |
| 57 | func withSeedsFields(f edgesFile, dataDir, optDir string) edgesFile { |
| 58 | if _, err := os.Lstat(rootSeedsFloorDir(optDir)); f.MovesFloorState != "" || !errors.Is(err, os.ErrNotExist) { |
| 59 | return withRootSeedsFields(f, optDir) |
| 60 | } |
| 61 | f.SeedsProtocol = seeds.ProtocolVersion |
| 62 | f.SeedsFloorMinRelease = "" |
| 63 | if fl, err := readLocalSeedsFloor(dataDir); err == nil && fl != nil { |
| 64 | f.SeedsFloorMinRelease = fl.MinRelease |
| 65 | } |
| 66 | f.PrevSeedsAllowedFor, _ = readLine(seedsPrevAllowedPath(optDir)) |
| 67 | return f |
| 68 | } |
| 69 | |
| 70 | // seedsFloorArmed: a local seeds floor that reads, or one that is there and |
| 71 | // cannot be read. |
| 72 | func seedsFloorArmed(dataDir string) bool { |
| 73 | f, err := readLocalSeedsFloor(dataDir) |
| 74 | return err != nil || f != nil |
| 75 | } |
| 76 | |
| 77 | // writeSeedsFloorFiles writes the min-release line FIRST and the JSON LAST, |
| 78 | // for writeFloorFiles' reason: after a crash between them the line can only |
| 79 | // be ahead of the JSON, so the units never pick a carlos.prev the Go checks |
| 80 | // would refuse. |
| 81 | func writeSeedsFloorFiles(d *movesDir, f seeds.Floor) error { |
| 82 | body, err := json.Marshal(f) |
| 83 | if err != nil { |
| 84 | return err |
| 85 | } |
| 86 | if err := d.WriteDurable(filepath.Base(seeds.FloorMinReleaseFile), []byte(f.MinRelease+"\n"), 0o644); err != nil { |
| 87 | return err |
| 88 | } |
| 89 | return d.WriteDurable(filepath.Base(seeds.FloorFile), append(body, '\n'), 0o644) |
| 90 | } |
| 91 | |
| 92 | // syncLocalSeedsFloor is syncLocalFloor's rule for the seeds floor: a |
| 93 | // published, usable floor that is not lower is written under the floor |
| 94 | // lock; absent leaves what is on disk; a lower one is refused; an |
| 95 | // unreadable store leaves what is on disk and returns the error. |
| 96 | func syncLocalSeedsFloor(ctx context.Context, s release.Store, dataDir string) (*seeds.Floor, error) { |
| 97 | if s == nil { |
| 98 | local, lerr := readLocalSeedsFloor(dataDir) |
| 99 | return local, errors.Join(errors.New("no deployment store to read the seeds floor from"), lerr) |
| 100 | } |
| 101 | remote, rerr := seeds.ReadFloor(ctx, s) |
| 102 | switch { |
| 103 | case rerr != nil: |
| 104 | local, lerr := readLocalSeedsFloor(dataDir) |
| 105 | return local, errors.Join(rerr, lerr) |
| 106 | case remote == nil: |
| 107 | return readLocalSeedsFloor(dataDir) |
| 108 | } |
| 109 | if ok, err := seeds.ReleaseAtOrAbove(remote.MinRelease, remote.MinRelease); !ok || err != nil { |
| 110 | local, lerr := readLocalSeedsFloor(dataDir) |
| 111 | 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) |
| 112 | } |
| 113 | var out *seeds.Floor |
| 114 | ran := false |
| 115 | err := withFloorLock(dataDir, true, func(d *movesDir) error { |
| 116 | ran = true |
| 117 | local, lerr := readLocalSeedsFloor(dataDir) |
| 118 | if lerr == nil && local != nil { |
| 119 | if remote.Min < local.Min { |
| 120 | out = local |
| 121 | return fmt.Errorf("the published seeds floor's protocol minimum %d is below this box's %d", remote.Min, local.Min) |
| 122 | } |
| 123 | if ok, _ := seeds.ReleaseAtOrAbove(remote.MinRelease, local.MinRelease); !ok { |
| 124 | out = local |
| 125 | return fmt.Errorf("the published seeds min_release %q is below this box's %s", remote.MinRelease, local.MinRelease) |
| 126 | } |
| 127 | if *local == *remote { |
| 128 | if line, ok := readBeneathLine(dataDir, seeds.FloorMinReleaseFile); ok && line == remote.MinRelease { |
| 129 | out = local |
| 130 | return nil |
| 131 | } |
| 132 | } |
| 133 | } |
| 134 | if err := writeSeedsFloorFiles(d, *remote); err != nil { |
| 135 | out, _ = readLocalSeedsFloor(dataDir) |
| 136 | return fmt.Errorf("%w: %v", errFloorWrite, err) |
| 137 | } |
| 138 | out = remote |
| 139 | return nil |
| 140 | }) |
| 141 | if !ran { |
| 142 | out, _ = readLocalSeedsFloor(dataDir) |
| 143 | return out, fmt.Errorf("%w: %v", errFloorWrite, err) |
| 144 | } |
| 145 | return out, err |
| 146 | } |
| 147 | |
| 148 | // judgeSeedsPrev writes the seeds verdict when carlos.prev is at or above |
| 149 | // the armed seeds floor, and removes it otherwise (no carlos.prev, no |
| 150 | // floor, no version, below). Unlike the moves verdict it does not read the |
| 151 | // registry: a seed has no hold rows. |
| 152 | func judgeSeedsPrev(optDir, dataDir string) error { |
| 153 | deny := func(cause error) error { |
| 154 | if err := removeDurable(seedsPrevAllowedPath(optDir)); err != nil { |
| 155 | return errors.Join(cause, fmt.Errorf("remove the seeds verdict on carlos.prev: %w", err)) |
| 156 | } |
| 157 | return cause |
| 158 | } |
| 159 | if _, err := os.Stat(systemPrevPath(optDir)); err != nil { |
| 160 | return deny(nil) |
| 161 | } |
| 162 | ran := false |
| 163 | err := withFloorLock(dataDir, false, func(*movesDir) error { |
| 164 | ran = true |
| 165 | floor, err := readLocalSeedsFloor(dataDir) |
| 166 | if err != nil { |
| 167 | return deny(fmt.Errorf("seeds protocol floor: the floor on this box cannot be read: %v", err)) |
| 168 | } |
| 169 | if floor == nil { |
| 170 | return deny(nil) |
| 171 | } |
| 172 | version, ok := readLine(prevVersionPath(optDir)) |
| 173 | if !ok { |
| 174 | return deny(nil) |
| 175 | } |
| 176 | if at, _ := seeds.ReleaseAtOrAbove(version, floor.MinRelease); !at { |
| 177 | return deny(nil) |
| 178 | } |
| 179 | return writeFileDurable(seedsPrevAllowedPath(optDir), []byte(floor.MinRelease+"\n"), 0o644) |
| 180 | }) |
| 181 | if !ran { |
| 182 | return deny(fmt.Errorf("seeds protocol floor: the floor on this box cannot be read: %v", err)) |
| 183 | } |
| 184 | return err |
| 185 | } |
| 186 | |
| 187 | // seedsActivationRefusal is the seeds half of the activation rule: version |
| 188 | // below the armed seeds floor is refused (SD-N1). The floor is read from |
| 189 | // the local file alone (boot recovery runs before the network). Unarmed: |
| 190 | // nil. A version of "" (a carlos.prev that records none) is below any |
| 191 | // floor: seeds.ReleaseAtOrAbove("", ...) is false. |
| 192 | func seedsActivationRefusal(version, dataDir string) error { |
| 193 | floor, err := readLocalSeedsFloor(dataDir) |
| 194 | if err != nil { |
| 195 | return refuse("seeds protocol floor: the floor on this box cannot be read: %v", err) |
| 196 | } |
| 197 | if floor == nil { |
| 198 | return nil |
| 199 | } |
| 200 | if ok, _ := seeds.ReleaseAtOrAbove(version, floor.MinRelease); !ok { |
| 201 | return refuse("seeds protocol floor: %s is below %s", version, floor.MinRelease) |
| 202 | } |
| 203 | return nil |
| 204 | } |
| 205 | |
| 206 | // armSeedsFloor is armFloor's seeds half, with the same rule: a published |
| 207 | // floor this box could not write down defers the apply; anything else is a |
| 208 | // log line. |
| 209 | func (a *systemApplier) armSeedsFloor(ctx context.Context) error { |
| 210 | if id, required, err := a.rootMoves.read(); err != nil { |
| 211 | return err |
| 212 | } else if required { |
| 213 | return refreshRootSeedsFloor(ctx, a.store, a.optDir, a.dataDir, id) |
| 214 | } |
| 215 | f, err := syncLocalSeedsFloor(ctx, a.store, a.dataDir) |
| 216 | if err == nil { |
| 217 | return nil |
| 218 | } |
| 219 | if errors.Is(err, errFloorWrite) { |
| 220 | return fmt.Errorf("%w: seeds protocol floor: %v", errSystemDeferred, err) |
| 221 | } |
| 222 | onDisk := "none" |
| 223 | if f != nil { |
| 224 | onDisk = f.MinRelease |
| 225 | } |
| 226 | a.ops.logf("system apply: seeds protocol floor: %v (the seeds floor on this box stays %s)", err, onDisk) |
| 227 | return nil |
| 228 | } |
| 229 | |
| 230 | // rootJudgeSeedsPrev is the root adopt pass's call: nothing to do on a box |
| 231 | // that has neither a seeds floor nor a seeds verdict. |
| 232 | func rootJudgeSeedsPrev() error { |
| 233 | optDir, dd := release.OptDir(), dataDir() |
| 234 | if !seedsFloorArmed(dd) { |
| 235 | if _, err := os.Stat(seedsPrevAllowedPath(optDir)); errors.Is(err, os.ErrNotExist) { |
| 236 | return nil |
| 237 | } |
| 238 | } |
| 239 | return withPrevLock(optDir, func() error { return judgeSeedsPrev(optDir, dd) }) |
| 240 | } |
| 241 | |
| 242 | // seedsFloorRefresh is the agent's hourly refresh of the seeds floor. |
| 243 | func seedsFloorRefresh(ctx context.Context, s release.Store, dataDir string, logf func(string, ...any)) { |
| 244 | if _, err := syncLocalSeedsFloor(ctx, s, dataDir); err != nil { |
| 245 | logf("agent: seeds protocol floor refresh: %v", err) |
| 246 | } |
| 247 | } |
| 248 | |