| 1 | // The fleet's layout intent (isolated-exec design §8.2): the append log that |
| 2 | // tells a box which storage layout, flat or nested, a route should converge |
| 3 | // to. It records a MODE, not a one-shot request, so a row that becomes |
| 4 | // eligible after the intent was recorded is migrated on the next pass, and a |
| 5 | // box that was away converges when it returns. An old box never reads the |
| 6 | // key; an old console never writes it. |
| 7 | // |
| 8 | // The shape mirrors restart-scope.json (see restartscope.go): a small |
| 9 | // envelope carrying a schema and a log of requests, oldest first, so two |
| 10 | // requests issued within one poll window cannot collapse into a single etag |
| 11 | // move and lose one of them. |
| 12 | package release |
| 13 | |
| 14 | import ( |
| 15 | "context" |
| 16 | "crypto/rand" |
| 17 | "encoding/hex" |
| 18 | "encoding/json" |
| 19 | "errors" |
| 20 | "fmt" |
| 21 | "io" |
| 22 | "os" |
| 23 | "strings" |
| 24 | "time" |
| 25 | ) |
| 26 | |
| 27 | const ( |
| 28 | LayoutModeNone = "" |
| 29 | LayoutModeFlat = "flat" |
| 30 | LayoutModeNested = "nested" |
| 31 | ) |
| 32 | |
| 33 | // LayoutScope narrows an intent to one box by label; an empty Box is the |
| 34 | // whole fleet. |
| 35 | type LayoutScope struct { |
| 36 | Box string `json:"box,omitempty"` |
| 37 | } |
| 38 | |
| 39 | // LayoutIntent is one recorded request in control/layout.json: which layout |
| 40 | // to converge to, and which box or boxes it addresses. |
| 41 | type LayoutIntent struct { |
| 42 | ID string `json:"id"` |
| 43 | RequestedAt string `json:"requested_at"` |
| 44 | Mode string `json:"mode"` |
| 45 | Scope LayoutScope `json:"scope"` |
| 46 | // Force asks the pass to stop an awake host for its move at once |
| 47 | // rather than defer it for the awake grace (layout.Pass.AwakeGrace). |
| 48 | // Additive: a box that does not know the field defers as before. |
| 49 | Force bool `json:"force,omitempty"` |
| 50 | } |
| 51 | |
| 52 | // LayoutIntents is the object at control/layout.json: the append log of |
| 53 | // LayoutIntent requests, oldest first. |
| 54 | type LayoutIntents struct { |
| 55 | Schema int `json:"schema"` // 1 |
| 56 | Requests []LayoutIntent `json:"requests"` |
| 57 | } |
| 58 | |
| 59 | // LayoutIntentKey is the bucket key of the fleet's layout intent log. |
| 60 | func LayoutIntentKey() string { return "control/layout.json" } |
| 61 | |
| 62 | // ValidLayoutMode reports whether m is a mode a box can converge to. The |
| 63 | // empty mode is a valid ABSENCE (LayoutModeNone), never a valid request. |
| 64 | func ValidLayoutMode(m string) bool { return m == LayoutModeFlat || m == LayoutModeNested } |
| 65 | |
| 66 | // Applies reports whether the intent addresses the box named boxLabel: every |
| 67 | // box when Scope.Box is empty, or only the one box it names. |
| 68 | func (in *LayoutIntent) Applies(boxLabel string) bool { |
| 69 | return in != nil && (in.Scope.Box == "" || in.Scope.Box == boxLabel) |
| 70 | } |
| 71 | |
| 72 | func parseLayoutIntents(key string, raw []byte) (*LayoutIntents, error) { |
| 73 | var g LayoutIntents |
| 74 | if err := json.Unmarshal(raw, &g); err != nil { |
| 75 | return nil, fmt.Errorf("layout intent at %s: %w", key, err) |
| 76 | } |
| 77 | if g.Schema > 1 { |
| 78 | return nil, fmt.Errorf("layout intent at %s: schema %d is newer than this binary understands; upgrade carlos", key, g.Schema) |
| 79 | } |
| 80 | return &g, nil |
| 81 | } |
| 82 | |
| 83 | // LoadLayoutIntent reads the log and returns the newest request whose scope |
| 84 | // applies to boxLabel. found=false means either the object is absent, which |
| 85 | // is every deployment's state until an operator records an intent, or it has |
| 86 | // requests but none of them address this box. |
| 87 | // |
| 88 | // A newer schema is an error rather than a best-effort parse: a box that |
| 89 | // cannot be sure what a request means must do nothing, so the request is |
| 90 | // picked up whenever the box is upgraded rather than dropped or guessed at. |
| 91 | func LoadLayoutIntent(ctx context.Context, s Store, boxLabel string) (*LayoutIntent, bool, error) { |
| 92 | key := LayoutIntentKey() |
| 93 | raw, _, err := s.Get(ctx, key) |
| 94 | if errors.Is(err, os.ErrNotExist) { |
| 95 | return nil, false, nil |
| 96 | } |
| 97 | if err != nil { |
| 98 | return nil, false, err |
| 99 | } |
| 100 | g, err := parseLayoutIntents(key, raw) |
| 101 | if err != nil { |
| 102 | return nil, false, err |
| 103 | } |
| 104 | for i := len(g.Requests) - 1; i >= 0; i-- { |
| 105 | if g.Requests[i].Applies(boxLabel) { |
| 106 | in := g.Requests[i] |
| 107 | return &in, true, nil |
| 108 | } |
| 109 | } |
| 110 | return nil, false, nil |
| 111 | } |
| 112 | |
| 113 | // LoadLayoutIntents reads the whole log, every scope. found=false means |
| 114 | // the object is absent. The console door's status read uses it to show the |
| 115 | // newest request whatever box it addresses; a box reads LoadLayoutIntent, |
| 116 | // which answers for its own label. |
| 117 | func LoadLayoutIntents(ctx context.Context, s Store) (*LayoutIntents, bool, error) { |
| 118 | key := LayoutIntentKey() |
| 119 | raw, _, err := s.Get(ctx, key) |
| 120 | if errors.Is(err, os.ErrNotExist) { |
| 121 | return nil, false, nil |
| 122 | } |
| 123 | if err != nil { |
| 124 | return nil, false, err |
| 125 | } |
| 126 | g, err := parseLayoutIntents(key, raw) |
| 127 | if err != nil { |
| 128 | return nil, false, err |
| 129 | } |
| 130 | return g, true, nil |
| 131 | } |
| 132 | |
| 133 | // AppendLayoutIntent records one request, oldest-first, after every request |
| 134 | // already in the log, and returns the stamped request it wrote. ID and |
| 135 | // RequestedAt are stamped here when the caller leaves them empty, so a |
| 136 | // caller that only cares about Mode and Scope never has to mint either |
| 137 | // itself, and returning the stamped value means the console door (plan 5) |
| 138 | // never has to read the object back just to learn the id it recorded. |
| 139 | // |
| 140 | // A CAS conflict is retried, the same shape and for the same reason as |
| 141 | // AppendRestartScope: another writer's request names a different mode or |
| 142 | // scope, so dropping ours would answer "requested" for a convergence that |
| 143 | // will never happen. The retry budget is small on purpose; this is a |
| 144 | // human-paced verb, and a contended object means something is wrong that |
| 145 | // another lap will not fix. |
| 146 | func AppendLayoutIntent(ctx context.Context, s Store, in LayoutIntent) (LayoutIntent, error) { |
| 147 | if !ValidLayoutMode(in.Mode) { |
| 148 | return LayoutIntent{}, fmt.Errorf("layout intent: mode %q is not flat or nested", in.Mode) |
| 149 | } |
| 150 | if in.ID == "" { |
| 151 | id := make([]byte, 16) |
| 152 | if _, err := io.ReadFull(rand.Reader, id); err != nil { |
| 153 | return LayoutIntent{}, err |
| 154 | } |
| 155 | in.ID = hex.EncodeToString(id) |
| 156 | } |
| 157 | if in.RequestedAt == "" { |
| 158 | in.RequestedAt = time.Now().UTC().Format(time.RFC3339) |
| 159 | } |
| 160 | key := LayoutIntentKey() |
| 161 | var lastErr error |
| 162 | for attempt := 0; attempt < 3; attempt++ { |
| 163 | raw, etag, err := s.Get(ctx, key) |
| 164 | if err != nil && !errors.Is(err, os.ErrNotExist) { |
| 165 | return LayoutIntent{}, err |
| 166 | } |
| 167 | g := &LayoutIntents{Schema: 1} |
| 168 | if err == nil { |
| 169 | if g, err = parseLayoutIntents(key, raw); err != nil { |
| 170 | // Refusing beats overwriting: a log this binary cannot read is |
| 171 | // one whose entries it would silently drop on the next write. |
| 172 | return LayoutIntent{}, err |
| 173 | } |
| 174 | } else { |
| 175 | etag = "" // create-only |
| 176 | } |
| 177 | g.Schema = 1 |
| 178 | g.Requests = append(g.Requests, in) |
| 179 | body, merr := json.Marshal(g) |
| 180 | if merr != nil { |
| 181 | return LayoutIntent{}, merr |
| 182 | } |
| 183 | if _, err := s.Put(ctx, key, body, etag); err != nil { |
| 184 | if errors.Is(err, ErrCASConflict) { |
| 185 | lastErr = err |
| 186 | continue |
| 187 | } |
| 188 | return LayoutIntent{}, err |
| 189 | } |
| 190 | return in, nil |
| 191 | } |
| 192 | return LayoutIntent{}, fmt.Errorf("layout intent: %s is being written by someone else; nothing was requested; try again: %w", key, lastErr) |
| 193 | } |
| 194 | |
| 195 | // LayoutFloor is the first platform release on which a nested row WAKES: |
| 196 | // the one cut from a main that contains plan 4 (7b7d3026), whose edge unit |
| 197 | // drops RestrictSUIDSGID= so the agent's openat2 calls work. v0.20.0 to |
| 198 | // v0.24.2 carry the instance mount units, so a nested row's data is safe on |
| 199 | // them, but the agent cannot open a nested directory there and the row |
| 200 | // never wakes, which is an outage; so the floor is the release that works, |
| 201 | // not the release that merely keeps the files. No tag contains 7b7d3026 at |
| 202 | // the time of writing (latest v0.24.2), so this names the next: v0.25.0 |
| 203 | // MUST be cut from a main that contains 7b7d3026. |
| 204 | // |
| 205 | // Both console system doors refuse a pointer move below this floor while |
| 206 | // any box could hold a nested row, and the layout door refuses `migrate` |
| 207 | // while any box in scope runs below it. The box-side guard |
| 208 | // (host/systemapply.go) judges the candidate's staged unit set, and, when |
| 209 | // the box holds nested rows, the candidate's own reported version against |
| 210 | // this floor. TestLayoutFloorIsTheFirstReleaseWhereNestedRowsWake pins it. |
| 211 | var LayoutFloor = SemVer{Major: 0, Minor: 25, Patch: 0} |
| 212 | |
| 213 | // ParseReleaseVersion parses a release tag with or without its leading v. |
| 214 | func ParseReleaseVersion(version string) (SemVer, bool) { |
| 215 | return ParseSemVer(strings.TrimPrefix(version, "v")) |
| 216 | } |
| 217 | |
| 218 | // BelowFloor reports whether version parses and is below floor. A tag that |
| 219 | // does not parse is false: the caller decides what an unplaceable version |
| 220 | // means for its own rule. |
| 221 | func BelowFloor(version string, floor SemVer) bool { |
| 222 | v, ok := ParseReleaseVersion(version) |
| 223 | return ok && v.Compare(floor) < 0 |
| 224 | } |
| 225 | |
| 226 | // PreLayout reports whether a release tag predates the layout. A tag that |
| 227 | // does not parse as a version is treated as pre-layout: refusing is the |
| 228 | // safe answer for a release nobody can place against the floor, and the |
| 229 | // refusal names the tag so an operator can see why. |
| 230 | func PreLayout(version string) bool { |
| 231 | v, ok := ParseReleaseVersion(version) |
| 232 | if !ok { |
| 233 | return true |
| 234 | } |
| 235 | return v.Compare(LayoutFloor) < 0 |
| 236 | } |
| 237 | |
| 238 | // RecordLayoutIntent is the console door's writer (plan 5): one request, |
| 239 | // stamped here, appended under CAS by AppendLayoutIntent. force asks the |
| 240 | // boxes in scope to stop an awake host for its move at once rather than |
| 241 | // wait out its awake grace (LayoutIntent.Force). actor is recorded by the |
| 242 | // console's own audit row, not in the object: the object is read by every |
| 243 | // box, and an operator's address has no business in a box's view. |
| 244 | func RecordLayoutIntent(ctx context.Context, s Store, mode, box string, force bool) (LayoutIntent, error) { |
| 245 | return AppendLayoutIntent(ctx, s, LayoutIntent{Mode: mode, Scope: LayoutScope{Box: box}, Force: force}) |
| 246 | } |
| 247 | |