package release import ( "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "os" "path/filepath" "time" "github.com/carlosframework/platform/internal/controlname" "github.com/carlosframework/platform/internal/registry" ) // ResolvedRequest is an install request R resolved to a build T (settings // spec §4.3 rule 2). type ResolvedRequest struct { ID uint64 `json:"id"` Target string `json:"target"` } // BuildState is the object beside an instance's replica (settings spec §4.2): // the build whose process last opened the database, the request root last // resolved, and the highest request completed. The agent writes it under the // instance's lease before any process runs a build against the database; it // is trusted the way settings are (§2 decision 5). type BuildState struct { Schema int `json:"schema"` // 1 Host string `json:"host"` LastExecuted string `json:"last_executed,omitempty"` Resolved *ResolvedRequest `json:"resolved,omitempty"` Completed uint64 `json:"completed,omitempty"` CompletedFailed bool `json:"completed_failed,omitempty"` At time.Time `json:"at"` } // BuildStateKey is beside the lease's lock.json at the same prefix. func BuildStateKey(replicaPrefix string) string { return replicaPrefix + "/build-state.json" } // BuildStateCopy is the agent's registry copy, refreshed at every reconcile // and before every wake. Known is false until the agent has read the bucket // object (absent counts as read); Replica is whether the instance has a // replica, which decides rule 3's "unknown". type BuildStateCopy struct { Known bool `json:"known"` Replica bool `json:"replica,omitempty"` State *BuildState `json:"state,omitempty"` ReadAt time.Time `json:"read_at"` // ReadErr is set when the latest refresh could not read the object; the // copy keeps its last known state, and the box reports the host as // incomplete for retention (Astra I1 #6). Cleared by a good read. ReadErr string `json:"read_err,omitempty"` } // Read reports a copy root and the wake may decide on: read, and its latest // refresh did not fail. A copy that kept its last known state after a failed // refresh (ReadErr) may be stale (a box the instance returned to, whose row's // refreshes failed while the instance was away), so its watermark and // last_executed are not decided on: it reads as unread (R-TARGET-UNKNOWN). // Retention still protects the builds it names (SettingsRefs). func (c BuildStateCopy) Read() bool { return c.Known && c.ReadErr == "" } // ParseBuildStateCopy reads the registry column. An empty column is the zero // copy: Known false, nothing read yet. func ParseBuildStateCopy(col string) (BuildStateCopy, error) { if col == "" { return BuildStateCopy{}, nil } var c BuildStateCopy if err := json.Unmarshal([]byte(col), &c); err != nil { return BuildStateCopy{}, fmt.Errorf("build state copy: %v", err) } return c, nil } // Column is the registry column text for c; ParseBuildStateCopy reverses it. func (c BuildStateCopy) Column() string { b, _ := json.Marshal(c) return string(b) } // Consumed is root's own record for one host at /opt/carlos/consumed/ // (settings spec §4.3 rule 2): the request it resolved, before staging, and // the highest it saw complete, plus its last decision for reports. Root-owned; // the agent reads it. type Consumed struct { Schema int `json:"schema"` // 1 Host string `json:"host"` Resolved *ResolvedRequest `json:"resolved,omitempty"` Completed uint64 `json:"completed,omitempty"` CompletedFailed bool `json:"completed_failed,omitempty"` Desired string `json:"desired,omitempty"` Reason string `json:"reason,omitempty"` Detail string `json:"detail,omitempty"` // Baking is the head a pending, unresolved request waits on (this box's // bake window). Reported beside Reason, never in place of it: Reason is // what the held wake check reads (Astra I1 #8). Baking string `json:"baking,omitempty"` // Settings is SettingsDigest of the update_settings column this decision // was made for (ruling R-HELD-PURE). The held wake check trusts Reason // only for the column it was decided for: after a member's change, a // missing or stale decision never makes a wake unheld. Empty in a file // from before this field: no decision for any column. Settings string `json:"settings,omitempty"` // Account and App name the tenant this decision was made for: the // route's (RouteAccount, Route.App) when root wrote it (Astra r2 N1, // ruling R22). The file is keyed by hostname only, and a hostname can // move between tenants on one box, so a report shows the decision only // to the route it names (DecidedFor). Empty in a file from before these // fields: decided for no tenant. Account string `json:"account,omitempty"` App string `json:"app,omitempty"` At time.Time `json:"at"` } // DecidedFor is whether c is root's decision for rt's tenant: c names an // account and an app, and they are rt's (RouteAccount, rt.App). A file with // no tenant (from before ruling R22) or another tenant's is decided for no // one here, and a report shows none of it. func (c *Consumed) DecidedFor(rt registry.Route) bool { return c != nil && c.Account != "" && c.App != "" && c.Account == RouteAccount(rt) && c.App == rt.App } // ForeignTo is whether c was decided for another tenant than rt's: it names // a tenant (Account or App set) and that tenant is not (RouteAccount(rt), // rt.App). A hostname can move between tenants on one box, and the file is // keyed by hostname, so root and the agent treat a foreign file as absent // wherever they decide from it (ruling R26): the adoption read, NextConsumed, // the pre-exec write, a move's carry and the held wake check. An untagged // file (from before ruling R22) is not foreign: those readers take it as the // route's own, so a completion, resolution or failure only it holds still // counts (ruling R28). The update report is stricter and shows only a file // DecidedFor the route. func (c *Consumed) ForeignTo(rt registry.Route) bool { return c != nil && (c.Account != "" || c.App != "") && (c.Account != RouteAccount(rt) || c.App != rt.App) } // SettingsDigest names one update_settings column exactly (the registry's // text, as root and the agent both read it), for Consumed.Settings. func SettingsDigest(col string) string { sum := sha256.Sum256([]byte(col)) return "sha256:" + hex.EncodeToString(sum[:]) } // ConsumedPath is the path for host's consumed file. The caller must pass a // host already validated with controlname.ValidHostShape (ReadConsumed and // WriteConsumed do); an unvalidated host could escape consumed/. func ConsumedPath(optDir, host string) string { return filepath.Join(optDir, "consumed", host) } // ReadConsumed is (nil, nil) when root has never written one for host. func ReadConsumed(optDir, host string) (*Consumed, error) { if !controlname.ValidHostShape(host) { return nil, fmt.Errorf("consumed: invalid host %q", host) } raw, err := os.ReadFile(ConsumedPath(optDir, host)) if errors.Is(err, os.ErrNotExist) { return nil, nil } if err != nil { return nil, err } var c Consumed if err := json.Unmarshal(raw, &c); err != nil { return nil, fmt.Errorf("consumed %s: %v", host, err) } if c.Schema != 1 || c.Host != host { return nil, fmt.Errorf("consumed %s names %q, schema %d", host, c.Host, c.Schema) } return &c, nil } func WriteConsumed(optDir string, c Consumed) error { if !controlname.ValidHostShape(c.Host) { return fmt.Errorf("consumed: invalid host %q", c.Host) } c.Schema = 1 if c.At.IsZero() { c.At = time.Now().UTC().Truncate(time.Second) } raw, err := json.MarshalIndent(c, "", " ") if err != nil { return err } if err := os.MkdirAll(filepath.Dir(ConsumedPath(optDir, c.Host)), 0o755); err != nil { return err } return WriteFileAtomic(ConsumedPath(optDir, c.Host), raw, 0o644) } // Watermark is the highest completed request id either source knows (§4.3 // rule 2): an older local file on a box the instance returns to can never // make a newer, durably completed request look pending. func Watermark(c *Consumed, b *BuildState) uint64 { var w uint64 if c != nil { w = c.Completed } if b != nil && b.Completed > w { w = b.Completed } return w } // NextBuildState is the pre-exec write's merge (settings spec §4.2, §4.3 // rule 2): before build runs against the instance's database, last_executed // becomes build, root's resolution is carried, and the request completes if // build is its target. That write is what completes a request: at first // run, not at install. A request root recorded as failed is carried as // completed, failed. Schema, Host and At are the writer's. func NextBuildState(cur *BuildState, c *Consumed, build string) BuildState { next := BuildState{} if cur != nil { next = *cur next.Resolved = copyResolved(cur.Resolved) } if c != nil { // Carry root's resolution only when it is newer than everything the // state already knows: above the watermark of both sources and above // the state's own resolution. An instance that moved A to B and back // meets A's old consumed file, which must not overwrite B's newer R. if c.Resolved != nil && c.Resolved.ID > Watermark(c, &next) && (next.Resolved == nil || c.Resolved.ID > next.Resolved.ID) { next.Resolved = copyResolved(c.Resolved) } switch { case c.Completed > next.Completed: next.Completed, next.CompletedFailed = c.Completed, c.CompletedFailed case c.Completed == next.Completed && c.CompletedFailed: // Root learned the completed request's T failed after it ran // (Astra I1 #10): the outcome changes, the request stays closed. next.CompletedFailed = true } } next.LastExecuted = build if next.Resolved != nil && next.Resolved.Target == build && next.Resolved.ID > next.Completed { next.Completed, next.CompletedFailed = next.Resolved.ID, false } return next } // CarryRequest is a graceful move's merge (settings spec §4.3, §4.3a; ruling // R-SPEC-43A-2, Astra round 1 finding 3). Root records a resolution, and a // request whose T failed as completed-failed, in its own consumed file; the // build state learns them only at the next pre-exec write. A request closed // as failed on a sleeper runs nothing, so no such write comes. Before a cut // seals the build state (the legacy cut, the Phase 1 cut's final round, a // dirty return's lane), the source box carries its record into the state // under the lease with this merge, so the destination holds "the same // request state" (§4.3a) and a completed request never enters rule 2 again. // // It carries root's resolution on NextBuildState's terms (newer than both // sources' watermark and the state's own resolution), and a failed // completion: one above the state's watermark becomes it, one equal to it // marks it failed. Never a successful completion (only the pre-exec write // completes a request successfully, when its T runs) and never // last_executed (only a run changes it). A lost box carries nothing: what it // held is the spec's lost request (§4.2). The host and the update // simulation both call this function. func CarryRequest(state *BuildState, c *Consumed) BuildState { next := BuildState{} if state != nil { next = *state next.Resolved = copyResolved(state.Resolved) } if c == nil { return next } if c.Resolved != nil && c.Resolved.ID > Watermark(c, &next) && (next.Resolved == nil || c.Resolved.ID > next.Resolved.ID) { next.Resolved = copyResolved(c.Resolved) } if c.CompletedFailed && c.Completed != 0 { switch { case c.Completed > next.Completed: next.Completed, next.CompletedFailed = c.Completed, true case c.Completed == next.Completed: next.CompletedFailed = true } } return next } // SameRequestState reports whether a and b hold the same request state: // resolution, completed id and its outcome. A nil state holds none. func SameRequestState(a, b *BuildState) bool { var x, y BuildState if a != nil { x = *a } if b != nil { y = *b } rx, ry := x.Resolved, y.Resolved sameRes := (rx == nil && ry == nil) || (rx != nil && ry != nil && *rx == *ry) return sameRes && x.Completed == y.Completed && x.CompletedFailed == y.CompletedFailed } func copyResolved(r *ResolvedRequest) *ResolvedRequest { if r == nil { return nil } v := *r return &v }