package release import ( "time" "github.com/carlosframework/platform/internal/registry" ) // TargetReason is why Target chose what it did; plan 5 renders each. type TargetReason string const ( ReasonAdopt TargetReason = "adopt" ReasonPin TargetReason = "pin" ReasonRequest TargetReason = "request" ReasonManual TargetReason = "manual" ReasonMajor TargetReason = "major" ReasonUnknown TargetReason = "unknown" ReasonNew TargetReason = "new" ReasonBaking TargetReason = "baking" ReasonFailed TargetReason = "failed" ReasonUnauthorized TargetReason = "unauthorized" ) // Evidence is what authorizes an install (settings spec §4.1). type Evidence int const ( EvidencePointer Evidence = iota // the signed head (and its plan-1 digest): today's adoption only EvidenceRecord // a signed promotion record with a matching manifest digest EvidenceStaged // the box's own staged, verified copy (adoptHost's fallback from Record) ) // TargetInput is everything §4.3 decides on, read by the caller so Target // stays pure. type TargetInput struct { Route registry.Route Pointer *ChannelPointer // the verified head; nil when unresolved HeadTag string // the head's semver tag, "" when it has none Settings UpdateSettings Build BuildStateCopy Consumed *Consumed Installed string HeadHeld string // Hold(...) for the head on this box: "" = eligible Failed func(version string) bool // the zero-downtime failure records for this host } // TargetDecision is root's one answer per route per pass. type TargetDecision struct { Version string // "" installs nothing Reason TargetReason Evidence Evidence Bake bool // the caller applies today's Hold to Version Resolve *ResolvedRequest // record before staging: this pass resolves R to T Fail uint64 // record request Fail as completed, failed Detail string // the build or number a reason names } // Target is settings spec §4.3, rules in order, with the zero-downtime // failure records outranking them all. The console never calls it; root is // the only caller that installs (§4). func Target(in TargetInput) TargetDecision { u := in.Settings head := "" if in.Pointer != nil { head = in.Pointer.Version } failed := func(v string) bool { return v != "" && in.Failed != nil && in.Failed(v) } state := in.Build.State wm := Watermark(in.Consumed, state) // Request bookkeeping comes first, before any selection rule, pins // included (§4.3 rule 2: "resolved at the start of every pass"; Astra I3 // #6). A pending R is resolved onto the head eligible now; a resolved R // whose T failed completes failed. A pin then decides what runs, and the // resolution waits, so clearing the pin later installs this T, not a // head that moved on. A completed request never enters here again. // // None of it happens while the build state is unread (!Build.Read(): never // read, or its latest refresh failed and the copy may be stale): the // watermark then sees only the consumed file, or an old copy's, so a // request that already completed would look pending (R-TARGET-UNKNOWN). // The pin, held and adoption rules decide as before, with no resolution // and no failure completion. var ( resolve *ResolvedRequest failedReq uint64 reqTarget string // a resolved T, not failed, above the watermark ) read := in.Build.Read() if read { if u.InstallRequest > wm { if res := resolvedFor(in.Consumed, state, u.InstallRequest); res != nil { if failed(res.Target) { failedReq = res.ID // completes failed, once; the rules below decide what runs } else { reqTarget = res.Target } } else if head != "" && (in.HeadHeld == "" || u.InstallFastTrack) { // The eligible head is the request's T. When this box holds that // head as failed the request is resolved and completed failed in // the same pass and installs nothing (R-TARGET-FAILEDHEAD); the // later rules decide what runs. resolve = &ResolvedRequest{ID: u.InstallRequest, Target: head} if failed(head) { failedReq = u.InstallRequest } else { reqTarget = head } } // Unresolved only while the head is baking here: it blocks nothing. } // A completed request whose target failed after it first ran (a later // health, settle or hold failure) is recorded completed-failed, once, // without reopening it (Astra I1 #10, §4.3 rule 2); also before the pin. if failedReq == 0 && u.InstallRequest != 0 && u.InstallRequest <= wm && !completedFailed(in.Consumed, state, wm) { if res := resolvedFor(in.Consumed, state, wm); res != nil && failed(res.Target) { failedReq = res.ID } } } carry := func(d TargetDecision) TargetDecision { d.Resolve, d.Fail = resolve, failedReq return d } // Rule 1: a pin. A person chose it, so bake does not apply. if u.Pin != "" { if failed(u.Pin) { return carry(TargetDecision{Reason: ReasonFailed, Detail: u.Pin}) } return carry(TargetDecision{Version: u.Pin, Reason: ReasonPin, Evidence: EvidenceRecord, Detail: u.Pin}) } // Rule 2: a resolved request above the watermark (this pass's resolution // included). if reqTarget != "" { ev := EvidenceRecord if reqTarget == head { ev = EvidencePointer } return carry(TargetDecision{Version: reqTarget, Reason: ReasonRequest, Evidence: ev, Detail: reqTarget}) } // Rule 3: held. majorHeld := u.Major != nil && !tagInMajor(in.HeadTag, *u.Major) if u.Updates == UpdatesManual || majorHeld { reason := ReasonManual if u.Updates != UpdatesManual { reason = ReasonMajor } var d TargetDecision switch { case !read: d = TargetDecision{Reason: ReasonUnknown} case state != nil && state.LastExecuted != "": d = TargetDecision{Version: state.LastExecuted, Reason: reason, Evidence: EvidenceRecord, Detail: state.LastExecuted} case in.Installed != "": d = TargetDecision{Version: in.Installed, Reason: reason, Evidence: EvidenceRecord, Detail: in.Installed} case in.Build.Replica: d = TargetDecision{Reason: ReasonUnknown} case head != "" && !failed(head): d = TargetDecision{Version: head, Reason: ReasonNew, Evidence: EvidencePointer, Bake: true, Detail: head} case head == "": d = TargetDecision{Reason: ReasonNew} // new, and no readable head to give it: nothing yet default: d = TargetDecision{Reason: ReasonFailed, Detail: head} } if failed(d.Version) { d = TargetDecision{Reason: ReasonFailed, Detail: d.Version} } return carry(d) } // Rule 4: today's adoption. if head == "" { return carry(TargetDecision{}) } if failed(head) { return carry(TargetDecision{Reason: ReasonFailed, Detail: head}) } return carry(TargetDecision{Version: head, Reason: ReasonAdopt, Evidence: EvidencePointer, Bake: true, Detail: head}) } // completedFailed reports whether either source already records request id // as completed, failed. func completedFailed(c *Consumed, b *BuildState, id uint64) bool { return (c != nil && c.Completed == id && c.CompletedFailed) || (b != nil && b.Completed == id && b.CompletedFailed) } // resolvedFor is R's T from either source (§4.3 rule 2: "locally or in the // build state"), or nil. When both resolve the same R to different builds // the build state's wins (ruling R-RESOLUTION-STATE-WINS): it travels with // the data, and the pre-exec write completes R only when its T runs. A box // that resolved R and lost the instance to a move before any wake keeps an // older resolution in its own file; the destination resolved R again into // the build state. Following the file there would run a T that never // completes R. func resolvedFor(c *Consumed, b *BuildState, id uint64) *ResolvedRequest { if b != nil && b.Resolved != nil && b.Resolved.ID == id { return b.Resolved } if c != nil && c.Resolved != nil && c.Resolved.ID == id { return c.Resolved } return nil } // tagInMajor: a head whose semver tag is absent or has another major is // held (§3.1). func tagInMajor(tag string, major int) bool { v, ok := ParseSemVer(tag) return ok && v.Major == major } // NextConsumed is root's consumed-file update for one decision (settings // spec §4.3 rule 2; ruling R-MERGE): in is what Target decided on, and // in.Consumed the file as read. root's pass writes the result before it // stages anything; the simulation calls the same function. A file // ForeignTo in.Route (another tenant's) is not carried: the record starts // empty (ruling R26). // // - dec.Fail completes that request as failed: above the file's watermark // it becomes the new watermark; equal to it (T failed after it first // ran, Astra I1 #10) the outcome changes and the request stays closed; // below it, nothing. // - dec.Resolve records {R, T}. With R-TARGET-FAILEDHEAD a decision can // resolve R and complete it failed in the same write. A file resolving // the same R as a read build state, to another T, is rewritten to the // build state's T (R-RESOLUTION-STATE-WINS). // - Baking names the head a pending, unresolved request waits on, beside // the decision's reason, never in its place (Astra I1 #8): Reason is what // the held wake check reads. Only with the build state read // (in.Build.Read()), as Target does no request bookkeeping without it. // - Desired, Reason and Detail are the decision; Settings names the // update_settings column it was made for (in.Route.UpdateSettings, // ruling R-HELD-PURE); Account and App name the route's tenant (ruling // R22); At is the writer's. func NextConsumed(in TargetInput, dec TargetDecision) Consumed { next := Consumed{Host: in.Route.Host} // Another tenant's file passes on nothing (ruling R26): the record starts // empty, whatever the caller read. An untagged file is carried as today. if in.Consumed != nil && !in.Consumed.ForeignTo(in.Route) { next = *in.Consumed next.Host = in.Route.Host next.Resolved = copyResolved(in.Consumed.Resolved) } switch { case dec.Fail > next.Completed: next.Completed, next.CompletedFailed = dec.Fail, true case dec.Fail != 0 && dec.Fail == next.Completed: next.CompletedFailed = true } if dec.Resolve != nil { next.Resolved = copyResolved(dec.Resolve) } u, state := in.Settings, in.Build.State // A file that resolves the same request as the build state, to another // build, takes the build state's T (ruling R-RESOLUTION-STATE-WINS), so // the box's own record stops disagreeing with what Target followed. Only // with the build state read: an unread copy says nothing. if in.Build.Read() && state != nil && state.Resolved != nil && next.Resolved != nil && next.Resolved.ID == state.Resolved.ID && next.Resolved.Target != state.Resolved.Target { next.Resolved = copyResolved(state.Resolved) } next.Baking = "" if in.Build.Read() && in.Pointer != nil && in.HeadHeld != "" && !u.InstallFastTrack && dec.Resolve == nil && u.InstallRequest > Watermark(&next, state) && resolvedFor(&next, state, u.InstallRequest) == nil { next.Baking = in.Pointer.Version } next.Desired, next.Reason, next.Detail, next.At = dec.Version, string(dec.Reason), dec.Detail, time.Time{} // The column this decision was made for (ruling R-HELD-PURE): the held // wake check reads Reason only while the column is still this one. next.Settings = SettingsDigest(in.Route.UpdateSettings) // The tenant it was made for (Astra r2 N1, ruling R22), always the // route's: a file read from a previous occupant of this hostname never // passes its tenant on. next.Account, next.App = RouteAccount(in.Route), in.Route.App return next }