carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package release
2
3import (
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).
19type 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).
29type 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.
40func 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".
46type 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).
63func (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.
67func 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.
79func (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.
88type 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.
122func (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.
136func (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.
142func 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/.
150func 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.
153func 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
174func 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.
195func 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.
212func 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.
261func 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.
287func 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
300func copyResolved(r *ResolvedRequest) *ResolvedRequest {
301 if r == nil {
302 return nil
303 }
304 v := *r
305 return &v
306}
307