| 1 | package release |
| 2 | |
| 3 | import ( |
| 4 | "time" |
| 5 | |
| 6 | "github.com/carlosframework/platform/internal/registry" |
| 7 | ) |
| 8 | |
| 9 | // TargetReason is why Target chose what it did; plan 5 renders each. |
| 10 | type TargetReason string |
| 11 | |
| 12 | const ( |
| 13 | ReasonAdopt TargetReason = "adopt" |
| 14 | ReasonPin TargetReason = "pin" |
| 15 | ReasonRequest TargetReason = "request" |
| 16 | ReasonManual TargetReason = "manual" |
| 17 | ReasonMajor TargetReason = "major" |
| 18 | ReasonUnknown TargetReason = "unknown" |
| 19 | ReasonNew TargetReason = "new" |
| 20 | ReasonBaking TargetReason = "baking" |
| 21 | ReasonFailed TargetReason = "failed" |
| 22 | ReasonUnauthorized TargetReason = "unauthorized" |
| 23 | ) |
| 24 | |
| 25 | // Evidence is what authorizes an install (settings spec §4.1). |
| 26 | type Evidence int |
| 27 | |
| 28 | const ( |
| 29 | EvidencePointer Evidence = iota // the signed head (and its plan-1 digest): today's adoption only |
| 30 | EvidenceRecord // a signed promotion record with a matching manifest digest |
| 31 | EvidenceStaged // the box's own staged, verified copy (adoptHost's fallback from Record) |
| 32 | ) |
| 33 | |
| 34 | // TargetInput is everything §4.3 decides on, read by the caller so Target |
| 35 | // stays pure. |
| 36 | type TargetInput struct { |
| 37 | Route registry.Route |
| 38 | Pointer *ChannelPointer // the verified head; nil when unresolved |
| 39 | HeadTag string // the head's semver tag, "" when it has none |
| 40 | Settings UpdateSettings |
| 41 | Build BuildStateCopy |
| 42 | Consumed *Consumed |
| 43 | Installed string |
| 44 | HeadHeld string // Hold(...) for the head on this box: "" = eligible |
| 45 | Failed func(version string) bool // the zero-downtime failure records for this host |
| 46 | } |
| 47 | |
| 48 | // TargetDecision is root's one answer per route per pass. |
| 49 | type TargetDecision struct { |
| 50 | Version string // "" installs nothing |
| 51 | Reason TargetReason |
| 52 | Evidence Evidence |
| 53 | Bake bool // the caller applies today's Hold to Version |
| 54 | Resolve *ResolvedRequest // record before staging: this pass resolves R to T |
| 55 | Fail uint64 // record request Fail as completed, failed |
| 56 | Detail string // the build or number a reason names |
| 57 | } |
| 58 | |
| 59 | // Target is settings spec §4.3, rules in order, with the zero-downtime |
| 60 | // failure records outranking them all. The console never calls it; root is |
| 61 | // the only caller that installs (§4). |
| 62 | func Target(in TargetInput) TargetDecision { |
| 63 | u := in.Settings |
| 64 | head := "" |
| 65 | if in.Pointer != nil { |
| 66 | head = in.Pointer.Version |
| 67 | } |
| 68 | failed := func(v string) bool { return v != "" && in.Failed != nil && in.Failed(v) } |
| 69 | state := in.Build.State |
| 70 | wm := Watermark(in.Consumed, state) |
| 71 | |
| 72 | // Request bookkeeping comes first, before any selection rule, pins |
| 73 | // included (§4.3 rule 2: "resolved at the start of every pass"; Astra I3 |
| 74 | // #6). A pending R is resolved onto the head eligible now; a resolved R |
| 75 | // whose T failed completes failed. A pin then decides what runs, and the |
| 76 | // resolution waits, so clearing the pin later installs this T, not a |
| 77 | // head that moved on. A completed request never enters here again. |
| 78 | // |
| 79 | // None of it happens while the build state is unread (!Build.Read(): never |
| 80 | // read, or its latest refresh failed and the copy may be stale): the |
| 81 | // watermark then sees only the consumed file, or an old copy's, so a |
| 82 | // request that already completed would look pending (R-TARGET-UNKNOWN). |
| 83 | // The pin, held and adoption rules decide as before, with no resolution |
| 84 | // and no failure completion. |
| 85 | var ( |
| 86 | resolve *ResolvedRequest |
| 87 | failedReq uint64 |
| 88 | reqTarget string // a resolved T, not failed, above the watermark |
| 89 | ) |
| 90 | read := in.Build.Read() |
| 91 | if read { |
| 92 | if u.InstallRequest > wm { |
| 93 | if res := resolvedFor(in.Consumed, state, u.InstallRequest); res != nil { |
| 94 | if failed(res.Target) { |
| 95 | failedReq = res.ID // completes failed, once; the rules below decide what runs |
| 96 | } else { |
| 97 | reqTarget = res.Target |
| 98 | } |
| 99 | } else if head != "" && (in.HeadHeld == "" || u.InstallFastTrack) { |
| 100 | // The eligible head is the request's T. When this box holds that |
| 101 | // head as failed the request is resolved and completed failed in |
| 102 | // the same pass and installs nothing (R-TARGET-FAILEDHEAD); the |
| 103 | // later rules decide what runs. |
| 104 | resolve = &ResolvedRequest{ID: u.InstallRequest, Target: head} |
| 105 | if failed(head) { |
| 106 | failedReq = u.InstallRequest |
| 107 | } else { |
| 108 | reqTarget = head |
| 109 | } |
| 110 | } |
| 111 | // Unresolved only while the head is baking here: it blocks nothing. |
| 112 | } |
| 113 | // A completed request whose target failed after it first ran (a later |
| 114 | // health, settle or hold failure) is recorded completed-failed, once, |
| 115 | // without reopening it (Astra I1 #10, §4.3 rule 2); also before the pin. |
| 116 | if failedReq == 0 && u.InstallRequest != 0 && u.InstallRequest <= wm && !completedFailed(in.Consumed, state, wm) { |
| 117 | if res := resolvedFor(in.Consumed, state, wm); res != nil && failed(res.Target) { |
| 118 | failedReq = res.ID |
| 119 | } |
| 120 | } |
| 121 | } |
| 122 | carry := func(d TargetDecision) TargetDecision { |
| 123 | d.Resolve, d.Fail = resolve, failedReq |
| 124 | return d |
| 125 | } |
| 126 | |
| 127 | // Rule 1: a pin. A person chose it, so bake does not apply. |
| 128 | if u.Pin != "" { |
| 129 | if failed(u.Pin) { |
| 130 | return carry(TargetDecision{Reason: ReasonFailed, Detail: u.Pin}) |
| 131 | } |
| 132 | return carry(TargetDecision{Version: u.Pin, Reason: ReasonPin, Evidence: EvidenceRecord, Detail: u.Pin}) |
| 133 | } |
| 134 | |
| 135 | // Rule 2: a resolved request above the watermark (this pass's resolution |
| 136 | // included). |
| 137 | if reqTarget != "" { |
| 138 | ev := EvidenceRecord |
| 139 | if reqTarget == head { |
| 140 | ev = EvidencePointer |
| 141 | } |
| 142 | return carry(TargetDecision{Version: reqTarget, Reason: ReasonRequest, Evidence: ev, Detail: reqTarget}) |
| 143 | } |
| 144 | |
| 145 | // Rule 3: held. |
| 146 | majorHeld := u.Major != nil && !tagInMajor(in.HeadTag, *u.Major) |
| 147 | if u.Updates == UpdatesManual || majorHeld { |
| 148 | reason := ReasonManual |
| 149 | if u.Updates != UpdatesManual { |
| 150 | reason = ReasonMajor |
| 151 | } |
| 152 | var d TargetDecision |
| 153 | switch { |
| 154 | case !read: |
| 155 | d = TargetDecision{Reason: ReasonUnknown} |
| 156 | case state != nil && state.LastExecuted != "": |
| 157 | d = TargetDecision{Version: state.LastExecuted, Reason: reason, Evidence: EvidenceRecord, Detail: state.LastExecuted} |
| 158 | case in.Installed != "": |
| 159 | d = TargetDecision{Version: in.Installed, Reason: reason, Evidence: EvidenceRecord, Detail: in.Installed} |
| 160 | case in.Build.Replica: |
| 161 | d = TargetDecision{Reason: ReasonUnknown} |
| 162 | case head != "" && !failed(head): |
| 163 | d = TargetDecision{Version: head, Reason: ReasonNew, Evidence: EvidencePointer, Bake: true, Detail: head} |
| 164 | case head == "": |
| 165 | d = TargetDecision{Reason: ReasonNew} // new, and no readable head to give it: nothing yet |
| 166 | default: |
| 167 | d = TargetDecision{Reason: ReasonFailed, Detail: head} |
| 168 | } |
| 169 | if failed(d.Version) { |
| 170 | d = TargetDecision{Reason: ReasonFailed, Detail: d.Version} |
| 171 | } |
| 172 | return carry(d) |
| 173 | } |
| 174 | |
| 175 | // Rule 4: today's adoption. |
| 176 | if head == "" { |
| 177 | return carry(TargetDecision{}) |
| 178 | } |
| 179 | if failed(head) { |
| 180 | return carry(TargetDecision{Reason: ReasonFailed, Detail: head}) |
| 181 | } |
| 182 | return carry(TargetDecision{Version: head, Reason: ReasonAdopt, Evidence: EvidencePointer, Bake: true, Detail: head}) |
| 183 | } |
| 184 | |
| 185 | // completedFailed reports whether either source already records request id |
| 186 | // as completed, failed. |
| 187 | func completedFailed(c *Consumed, b *BuildState, id uint64) bool { |
| 188 | return (c != nil && c.Completed == id && c.CompletedFailed) || (b != nil && b.Completed == id && b.CompletedFailed) |
| 189 | } |
| 190 | |
| 191 | // resolvedFor is R's T from either source (§4.3 rule 2: "locally or in the |
| 192 | // build state"), or nil. When both resolve the same R to different builds |
| 193 | // the build state's wins (ruling R-RESOLUTION-STATE-WINS): it travels with |
| 194 | // the data, and the pre-exec write completes R only when its T runs. A box |
| 195 | // that resolved R and lost the instance to a move before any wake keeps an |
| 196 | // older resolution in its own file; the destination resolved R again into |
| 197 | // the build state. Following the file there would run a T that never |
| 198 | // completes R. |
| 199 | func resolvedFor(c *Consumed, b *BuildState, id uint64) *ResolvedRequest { |
| 200 | if b != nil && b.Resolved != nil && b.Resolved.ID == id { |
| 201 | return b.Resolved |
| 202 | } |
| 203 | if c != nil && c.Resolved != nil && c.Resolved.ID == id { |
| 204 | return c.Resolved |
| 205 | } |
| 206 | return nil |
| 207 | } |
| 208 | |
| 209 | // tagInMajor: a head whose semver tag is absent or has another major is |
| 210 | // held (§3.1). |
| 211 | func tagInMajor(tag string, major int) bool { |
| 212 | v, ok := ParseSemVer(tag) |
| 213 | return ok && v.Major == major |
| 214 | } |
| 215 | |
| 216 | // NextConsumed is root's consumed-file update for one decision (settings |
| 217 | // spec §4.3 rule 2; ruling R-MERGE): in is what Target decided on, and |
| 218 | // in.Consumed the file as read. root's pass writes the result before it |
| 219 | // stages anything; the simulation calls the same function. A file |
| 220 | // ForeignTo in.Route (another tenant's) is not carried: the record starts |
| 221 | // empty (ruling R26). |
| 222 | // |
| 223 | // - dec.Fail completes that request as failed: above the file's watermark |
| 224 | // it becomes the new watermark; equal to it (T failed after it first |
| 225 | // ran, Astra I1 #10) the outcome changes and the request stays closed; |
| 226 | // below it, nothing. |
| 227 | // - dec.Resolve records {R, T}. With R-TARGET-FAILEDHEAD a decision can |
| 228 | // resolve R and complete it failed in the same write. A file resolving |
| 229 | // the same R as a read build state, to another T, is rewritten to the |
| 230 | // build state's T (R-RESOLUTION-STATE-WINS). |
| 231 | // - Baking names the head a pending, unresolved request waits on, beside |
| 232 | // the decision's reason, never in its place (Astra I1 #8): Reason is what |
| 233 | // the held wake check reads. Only with the build state read |
| 234 | // (in.Build.Read()), as Target does no request bookkeeping without it. |
| 235 | // - Desired, Reason and Detail are the decision; Settings names the |
| 236 | // update_settings column it was made for (in.Route.UpdateSettings, |
| 237 | // ruling R-HELD-PURE); Account and App name the route's tenant (ruling |
| 238 | // R22); At is the writer's. |
| 239 | func NextConsumed(in TargetInput, dec TargetDecision) Consumed { |
| 240 | next := Consumed{Host: in.Route.Host} |
| 241 | // Another tenant's file passes on nothing (ruling R26): the record starts |
| 242 | // empty, whatever the caller read. An untagged file is carried as today. |
| 243 | if in.Consumed != nil && !in.Consumed.ForeignTo(in.Route) { |
| 244 | next = *in.Consumed |
| 245 | next.Host = in.Route.Host |
| 246 | next.Resolved = copyResolved(in.Consumed.Resolved) |
| 247 | } |
| 248 | switch { |
| 249 | case dec.Fail > next.Completed: |
| 250 | next.Completed, next.CompletedFailed = dec.Fail, true |
| 251 | case dec.Fail != 0 && dec.Fail == next.Completed: |
| 252 | next.CompletedFailed = true |
| 253 | } |
| 254 | if dec.Resolve != nil { |
| 255 | next.Resolved = copyResolved(dec.Resolve) |
| 256 | } |
| 257 | u, state := in.Settings, in.Build.State |
| 258 | // A file that resolves the same request as the build state, to another |
| 259 | // build, takes the build state's T (ruling R-RESOLUTION-STATE-WINS), so |
| 260 | // the box's own record stops disagreeing with what Target followed. Only |
| 261 | // with the build state read: an unread copy says nothing. |
| 262 | if in.Build.Read() && state != nil && state.Resolved != nil && next.Resolved != nil && |
| 263 | next.Resolved.ID == state.Resolved.ID && next.Resolved.Target != state.Resolved.Target { |
| 264 | next.Resolved = copyResolved(state.Resolved) |
| 265 | } |
| 266 | next.Baking = "" |
| 267 | if in.Build.Read() && in.Pointer != nil && in.HeadHeld != "" && !u.InstallFastTrack && dec.Resolve == nil && |
| 268 | u.InstallRequest > Watermark(&next, state) && resolvedFor(&next, state, u.InstallRequest) == nil { |
| 269 | next.Baking = in.Pointer.Version |
| 270 | } |
| 271 | next.Desired, next.Reason, next.Detail, next.At = dec.Version, string(dec.Reason), dec.Detail, time.Time{} |
| 272 | // The column this decision was made for (ruling R-HELD-PURE): the held |
| 273 | // wake check reads Reason only while the column is still this one. |
| 274 | next.Settings = SettingsDigest(in.Route.UpdateSettings) |
| 275 | // The tenant it was made for (Astra r2 N1, ruling R22), always the |
| 276 | // route's: a file read from a previous occupant of this hostname never |
| 277 | // passes its tenant on. |
| 278 | next.Account, next.App = RouteAccount(in.Route), in.Route.App |
| 279 | return next |
| 280 | } |
| 281 | |