| 1 | package release |
| 2 | |
| 3 | import ( |
| 4 | "crypto/sha256" |
| 5 | "encoding/hex" |
| 6 | "encoding/json" |
| 7 | "errors" |
| 8 | "fmt" |
| 9 | "os" |
| 10 | "path/filepath" |
| 11 | "time" |
| 12 | |
| 13 | "github.com/carlosframework/platform/internal/controlname" |
| 14 | "github.com/carlosframework/platform/internal/registry" |
| 15 | ) |
| 16 | |
| 17 | // ResolvedRequest is an install request R resolved to a build T (settings |
| 18 | // spec §4.3 rule 2). |
| 19 | type ResolvedRequest struct { |
| 20 | ID uint64 `json:"id"` |
| 21 | Target string `json:"target"` |
| 22 | } |
| 23 | |
| 24 | // BuildState is the object beside an instance's replica (settings spec §4.2): |
| 25 | // the build whose process last opened the database, the request root last |
| 26 | // resolved, and the highest request completed. The agent writes it under the |
| 27 | // instance's lease before any process runs a build against the database; it |
| 28 | // is trusted the way settings are (§2 decision 5). |
| 29 | type BuildState struct { |
| 30 | Schema int `json:"schema"` // 1 |
| 31 | Host string `json:"host"` |
| 32 | LastExecuted string `json:"last_executed,omitempty"` |
| 33 | Resolved *ResolvedRequest `json:"resolved,omitempty"` |
| 34 | Completed uint64 `json:"completed,omitempty"` |
| 35 | CompletedFailed bool `json:"completed_failed,omitempty"` |
| 36 | At time.Time `json:"at"` |
| 37 | } |
| 38 | |
| 39 | // BuildStateKey is beside the lease's lock.json at the same prefix. |
| 40 | func BuildStateKey(replicaPrefix string) string { return replicaPrefix + "/build-state.json" } |
| 41 | |
| 42 | // BuildStateCopy is the agent's registry copy, refreshed at every reconcile |
| 43 | // and before every wake. Known is false until the agent has read the bucket |
| 44 | // object (absent counts as read); Replica is whether the instance has a |
| 45 | // replica, which decides rule 3's "unknown". |
| 46 | type BuildStateCopy struct { |
| 47 | Known bool `json:"known"` |
| 48 | Replica bool `json:"replica,omitempty"` |
| 49 | State *BuildState `json:"state,omitempty"` |
| 50 | ReadAt time.Time `json:"read_at"` |
| 51 | // ReadErr is set when the latest refresh could not read the object; the |
| 52 | // copy keeps its last known state, and the box reports the host as |
| 53 | // incomplete for retention (Astra I1 #6). Cleared by a good read. |
| 54 | ReadErr string `json:"read_err,omitempty"` |
| 55 | } |
| 56 | |
| 57 | // Read reports a copy root and the wake may decide on: read, and its latest |
| 58 | // refresh did not fail. A copy that kept its last known state after a failed |
| 59 | // refresh (ReadErr) may be stale (a box the instance returned to, whose row's |
| 60 | // refreshes failed while the instance was away), so its watermark and |
| 61 | // last_executed are not decided on: it reads as unread (R-TARGET-UNKNOWN). |
| 62 | // Retention still protects the builds it names (SettingsRefs). |
| 63 | func (c BuildStateCopy) Read() bool { return c.Known && c.ReadErr == "" } |
| 64 | |
| 65 | // ParseBuildStateCopy reads the registry column. An empty column is the zero |
| 66 | // copy: Known false, nothing read yet. |
| 67 | func ParseBuildStateCopy(col string) (BuildStateCopy, error) { |
| 68 | if col == "" { |
| 69 | return BuildStateCopy{}, nil |
| 70 | } |
| 71 | var c BuildStateCopy |
| 72 | if err := json.Unmarshal([]byte(col), &c); err != nil { |
| 73 | return BuildStateCopy{}, fmt.Errorf("build state copy: %v", err) |
| 74 | } |
| 75 | return c, nil |
| 76 | } |
| 77 | |
| 78 | // Column is the registry column text for c; ParseBuildStateCopy reverses it. |
| 79 | func (c BuildStateCopy) Column() string { |
| 80 | b, _ := json.Marshal(c) |
| 81 | return string(b) |
| 82 | } |
| 83 | |
| 84 | // Consumed is root's own record for one host at /opt/carlos/consumed/<host> |
| 85 | // (settings spec §4.3 rule 2): the request it resolved, before staging, and |
| 86 | // the highest it saw complete, plus its last decision for reports. Root-owned; |
| 87 | // the agent reads it. |
| 88 | type Consumed struct { |
| 89 | Schema int `json:"schema"` // 1 |
| 90 | Host string `json:"host"` |
| 91 | Resolved *ResolvedRequest `json:"resolved,omitempty"` |
| 92 | Completed uint64 `json:"completed,omitempty"` |
| 93 | CompletedFailed bool `json:"completed_failed,omitempty"` |
| 94 | Desired string `json:"desired,omitempty"` |
| 95 | Reason string `json:"reason,omitempty"` |
| 96 | Detail string `json:"detail,omitempty"` |
| 97 | // Baking is the head a pending, unresolved request waits on (this box's |
| 98 | // bake window). Reported beside Reason, never in place of it: Reason is |
| 99 | // what the held wake check reads (Astra I1 #8). |
| 100 | Baking string `json:"baking,omitempty"` |
| 101 | // Settings is SettingsDigest of the update_settings column this decision |
| 102 | // was made for (ruling R-HELD-PURE). The held wake check trusts Reason |
| 103 | // only for the column it was decided for: after a member's change, a |
| 104 | // missing or stale decision never makes a wake unheld. Empty in a file |
| 105 | // from before this field: no decision for any column. |
| 106 | Settings string `json:"settings,omitempty"` |
| 107 | // Account and App name the tenant this decision was made for: the |
| 108 | // route's (RouteAccount, Route.App) when root wrote it (Astra r2 N1, |
| 109 | // ruling R22). The file is keyed by hostname only, and a hostname can |
| 110 | // move between tenants on one box, so a report shows the decision only |
| 111 | // to the route it names (DecidedFor). Empty in a file from before these |
| 112 | // fields: decided for no tenant. |
| 113 | Account string `json:"account,omitempty"` |
| 114 | App string `json:"app,omitempty"` |
| 115 | At time.Time `json:"at"` |
| 116 | } |
| 117 | |
| 118 | // DecidedFor is whether c is root's decision for rt's tenant: c names an |
| 119 | // account and an app, and they are rt's (RouteAccount, rt.App). A file with |
| 120 | // no tenant (from before ruling R22) or another tenant's is decided for no |
| 121 | // one here, and a report shows none of it. |
| 122 | func (c *Consumed) DecidedFor(rt registry.Route) bool { |
| 123 | return c != nil && c.Account != "" && c.App != "" && c.Account == RouteAccount(rt) && c.App == rt.App |
| 124 | } |
| 125 | |
| 126 | // ForeignTo is whether c was decided for another tenant than rt's: it names |
| 127 | // a tenant (Account or App set) and that tenant is not (RouteAccount(rt), |
| 128 | // rt.App). A hostname can move between tenants on one box, and the file is |
| 129 | // keyed by hostname, so root and the agent treat a foreign file as absent |
| 130 | // wherever they decide from it (ruling R26): the adoption read, NextConsumed, |
| 131 | // the pre-exec write, a move's carry and the held wake check. An untagged |
| 132 | // file (from before ruling R22) is not foreign: those readers take it as the |
| 133 | // route's own, so a completion, resolution or failure only it holds still |
| 134 | // counts (ruling R28). The update report is stricter and shows only a file |
| 135 | // DecidedFor the route. |
| 136 | func (c *Consumed) ForeignTo(rt registry.Route) bool { |
| 137 | return c != nil && (c.Account != "" || c.App != "") && (c.Account != RouteAccount(rt) || c.App != rt.App) |
| 138 | } |
| 139 | |
| 140 | // SettingsDigest names one update_settings column exactly (the registry's |
| 141 | // text, as root and the agent both read it), for Consumed.Settings. |
| 142 | func SettingsDigest(col string) string { |
| 143 | sum := sha256.Sum256([]byte(col)) |
| 144 | return "sha256:" + hex.EncodeToString(sum[:]) |
| 145 | } |
| 146 | |
| 147 | // ConsumedPath is the path for host's consumed file. The caller must pass a |
| 148 | // host already validated with controlname.ValidHostShape (ReadConsumed and |
| 149 | // WriteConsumed do); an unvalidated host could escape consumed/. |
| 150 | func ConsumedPath(optDir, host string) string { return filepath.Join(optDir, "consumed", host) } |
| 151 | |
| 152 | // ReadConsumed is (nil, nil) when root has never written one for host. |
| 153 | func ReadConsumed(optDir, host string) (*Consumed, error) { |
| 154 | if !controlname.ValidHostShape(host) { |
| 155 | return nil, fmt.Errorf("consumed: invalid host %q", host) |
| 156 | } |
| 157 | raw, err := os.ReadFile(ConsumedPath(optDir, host)) |
| 158 | if errors.Is(err, os.ErrNotExist) { |
| 159 | return nil, nil |
| 160 | } |
| 161 | if err != nil { |
| 162 | return nil, err |
| 163 | } |
| 164 | var c Consumed |
| 165 | if err := json.Unmarshal(raw, &c); err != nil { |
| 166 | return nil, fmt.Errorf("consumed %s: %v", host, err) |
| 167 | } |
| 168 | if c.Schema != 1 || c.Host != host { |
| 169 | return nil, fmt.Errorf("consumed %s names %q, schema %d", host, c.Host, c.Schema) |
| 170 | } |
| 171 | return &c, nil |
| 172 | } |
| 173 | |
| 174 | func WriteConsumed(optDir string, c Consumed) error { |
| 175 | if !controlname.ValidHostShape(c.Host) { |
| 176 | return fmt.Errorf("consumed: invalid host %q", c.Host) |
| 177 | } |
| 178 | c.Schema = 1 |
| 179 | if c.At.IsZero() { |
| 180 | c.At = time.Now().UTC().Truncate(time.Second) |
| 181 | } |
| 182 | raw, err := json.MarshalIndent(c, "", " ") |
| 183 | if err != nil { |
| 184 | return err |
| 185 | } |
| 186 | if err := os.MkdirAll(filepath.Dir(ConsumedPath(optDir, c.Host)), 0o755); err != nil { |
| 187 | return err |
| 188 | } |
| 189 | return WriteFileAtomic(ConsumedPath(optDir, c.Host), raw, 0o644) |
| 190 | } |
| 191 | |
| 192 | // Watermark is the highest completed request id either source knows (§4.3 |
| 193 | // rule 2): an older local file on a box the instance returns to can never |
| 194 | // make a newer, durably completed request look pending. |
| 195 | func Watermark(c *Consumed, b *BuildState) uint64 { |
| 196 | var w uint64 |
| 197 | if c != nil { |
| 198 | w = c.Completed |
| 199 | } |
| 200 | if b != nil && b.Completed > w { |
| 201 | w = b.Completed |
| 202 | } |
| 203 | return w |
| 204 | } |
| 205 | |
| 206 | // NextBuildState is the pre-exec write's merge (settings spec §4.2, §4.3 |
| 207 | // rule 2): before build runs against the instance's database, last_executed |
| 208 | // becomes build, root's resolution is carried, and the request completes if |
| 209 | // build is its target. That write is what completes a request: at first |
| 210 | // run, not at install. A request root recorded as failed is carried as |
| 211 | // completed, failed. Schema, Host and At are the writer's. |
| 212 | func NextBuildState(cur *BuildState, c *Consumed, build string) BuildState { |
| 213 | next := BuildState{} |
| 214 | if cur != nil { |
| 215 | next = *cur |
| 216 | next.Resolved = copyResolved(cur.Resolved) |
| 217 | } |
| 218 | if c != nil { |
| 219 | // Carry root's resolution only when it is newer than everything the |
| 220 | // state already knows: above the watermark of both sources and above |
| 221 | // the state's own resolution. An instance that moved A to B and back |
| 222 | // meets A's old consumed file, which must not overwrite B's newer R. |
| 223 | if c.Resolved != nil && c.Resolved.ID > Watermark(c, &next) && |
| 224 | (next.Resolved == nil || c.Resolved.ID > next.Resolved.ID) { |
| 225 | next.Resolved = copyResolved(c.Resolved) |
| 226 | } |
| 227 | switch { |
| 228 | case c.Completed > next.Completed: |
| 229 | next.Completed, next.CompletedFailed = c.Completed, c.CompletedFailed |
| 230 | case c.Completed == next.Completed && c.CompletedFailed: |
| 231 | // Root learned the completed request's T failed after it ran |
| 232 | // (Astra I1 #10): the outcome changes, the request stays closed. |
| 233 | next.CompletedFailed = true |
| 234 | } |
| 235 | } |
| 236 | next.LastExecuted = build |
| 237 | if next.Resolved != nil && next.Resolved.Target == build && next.Resolved.ID > next.Completed { |
| 238 | next.Completed, next.CompletedFailed = next.Resolved.ID, false |
| 239 | } |
| 240 | return next |
| 241 | } |
| 242 | |
| 243 | // CarryRequest is a graceful move's merge (settings spec §4.3, §4.3a; ruling |
| 244 | // R-SPEC-43A-2, Astra round 1 finding 3). Root records a resolution, and a |
| 245 | // request whose T failed as completed-failed, in its own consumed file; the |
| 246 | // build state learns them only at the next pre-exec write. A request closed |
| 247 | // as failed on a sleeper runs nothing, so no such write comes. Before a cut |
| 248 | // seals the build state (the legacy cut, the Phase 1 cut's final round, a |
| 249 | // dirty return's lane), the source box carries its record into the state |
| 250 | // under the lease with this merge, so the destination holds "the same |
| 251 | // request state" (§4.3a) and a completed request never enters rule 2 again. |
| 252 | // |
| 253 | // It carries root's resolution on NextBuildState's terms (newer than both |
| 254 | // sources' watermark and the state's own resolution), and a failed |
| 255 | // completion: one above the state's watermark becomes it, one equal to it |
| 256 | // marks it failed. Never a successful completion (only the pre-exec write |
| 257 | // completes a request successfully, when its T runs) and never |
| 258 | // last_executed (only a run changes it). A lost box carries nothing: what it |
| 259 | // held is the spec's lost request (§4.2). The host and the update |
| 260 | // simulation both call this function. |
| 261 | func CarryRequest(state *BuildState, c *Consumed) BuildState { |
| 262 | next := BuildState{} |
| 263 | if state != nil { |
| 264 | next = *state |
| 265 | next.Resolved = copyResolved(state.Resolved) |
| 266 | } |
| 267 | if c == nil { |
| 268 | return next |
| 269 | } |
| 270 | if c.Resolved != nil && c.Resolved.ID > Watermark(c, &next) && |
| 271 | (next.Resolved == nil || c.Resolved.ID > next.Resolved.ID) { |
| 272 | next.Resolved = copyResolved(c.Resolved) |
| 273 | } |
| 274 | if c.CompletedFailed && c.Completed != 0 { |
| 275 | switch { |
| 276 | case c.Completed > next.Completed: |
| 277 | next.Completed, next.CompletedFailed = c.Completed, true |
| 278 | case c.Completed == next.Completed: |
| 279 | next.CompletedFailed = true |
| 280 | } |
| 281 | } |
| 282 | return next |
| 283 | } |
| 284 | |
| 285 | // SameRequestState reports whether a and b hold the same request state: |
| 286 | // resolution, completed id and its outcome. A nil state holds none. |
| 287 | func SameRequestState(a, b *BuildState) bool { |
| 288 | var x, y BuildState |
| 289 | if a != nil { |
| 290 | x = *a |
| 291 | } |
| 292 | if b != nil { |
| 293 | y = *b |
| 294 | } |
| 295 | rx, ry := x.Resolved, y.Resolved |
| 296 | sameRes := (rx == nil && ry == nil) || (rx != nil && ry != nil && *rx == *ry) |
| 297 | return sameRes && x.Completed == y.Completed && x.CompletedFailed == y.CompletedFailed |
| 298 | } |
| 299 | |
| 300 | func copyResolved(r *ResolvedRequest) *ResolvedRequest { |
| 301 | if r == nil { |
| 302 | return nil |
| 303 | } |
| 304 | v := *r |
| 305 | return &v |
| 306 | } |
| 307 | |