| 1 | package provision |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "slices" |
| 8 | "time" |
| 9 | |
| 10 | "github.com/carlosframework/platform/internal/release" |
| 11 | "github.com/carlosframework/platform/internal/seeds" |
| 12 | ) |
| 13 | |
| 14 | // SeedSource is a record's seed (seeded-instances spec §3.2). The run series |
| 15 | // declared its Kind and State; those keep their json tags. |
| 16 | type SeedSource struct { |
| 17 | ID string `json:"id"` // random, minted with the seed; never reused |
| 18 | Kind string `json:"kind"` // seeds.KindReplica | seeds.KindImport |
| 19 | From string `json:"from,omitempty"` // replica: the source host |
| 20 | TXID string `json:"txid,omitempty"` // replica: the TXID actually restored |
| 21 | SHA256 string `json:"sha256"` // of snapshot.db |
| 22 | Bytes int64 `json:"bytes"` |
| 23 | State string `json:"state"` // seeds.State* |
| 24 | Reason string `json:"reason,omitempty"` // set with refused, and with aborted when the abort names why |
| 25 | By string `json:"by"` // the caller |
| 26 | At time.Time `json:"at"` |
| 27 | // import only: |
| 28 | Replaces string `json:"replaces,omitempty"` // the addr the swap replaces |
| 29 | VerifyHash string `json:"verify_hash,omitempty"` // sha256 of the verify token (§5.4) |
| 30 | } |
| 31 | |
| 32 | // ErrSeedProtected is an ordinary record writer trying to change Seed. |
| 33 | var ErrSeedProtected = errors.New("provision: the seed changes only through a seed transition") |
| 34 | |
| 35 | // The two reasons a seed refuses a move (spec §5.8). Plan 8 gives each its |
| 36 | // sentence. |
| 37 | const ( |
| 38 | SeedMoveUnplaced = "unplaced" |
| 39 | SeedMoveUncommitted = "uncommitted" |
| 40 | ) |
| 41 | |
| 42 | // Import reports whether the seed is an import. |
| 43 | func (s *SeedSource) Import() bool { return s != nil && s.Kind == seeds.KindImport } |
| 44 | |
| 45 | // Abandoned reports an aborted seed, before or after the box rolled it back. |
| 46 | func (s *SeedSource) Abandoned() bool { |
| 47 | return s != nil && (s.State == seeds.StateAborted || s.State == seeds.StateRolledBack) |
| 48 | } |
| 49 | |
| 50 | // Unfinished reports a seed that still needs a door, a box or the console |
| 51 | // to act before it is done: the states claim activation refuses over |
| 52 | // (hostops, merge review S2), because none of those acts on a claim |
| 53 | // console or a claim-armed box. |
| 54 | // |
| 55 | // - reserving, awaiting-upload, uploaded: a door is still writing it; |
| 56 | // - ready: a --from seed waits for its first wake to place it, and an |
| 57 | // import for its swap; |
| 58 | // - an import at swapped or placed: not committed; |
| 59 | // - refused: an import the box would not swap, which only the delete |
| 60 | // door's import rule can remove; |
| 61 | // - aborted, rolled-back: its rollback or its finish is still owed. |
| 62 | // |
| 63 | // Finished, and the only two: a --from seed at placed and an import at |
| 64 | // committed. Any other kind and state is unfinished, including a state or a |
| 65 | // kind this build does not know and a pairing no door writes (a committed |
| 66 | // --from seed). |
| 67 | func (s *SeedSource) Unfinished() bool { |
| 68 | if s == nil { |
| 69 | return false |
| 70 | } |
| 71 | finished := (s.Kind == seeds.KindReplica && s.State == seeds.StatePlaced) || |
| 72 | (s.Kind == seeds.KindImport && s.State == seeds.StateCommitted) |
| 73 | return !finished |
| 74 | } |
| 75 | |
| 76 | // BlocksRun is run admission's predicate (run spec §5.1 step 9, codex |
| 77 | // R2-2): an import that is not committed refuses carlos run. |
| 78 | func (s *SeedSource) BlocksRun() bool { return s.Import() && s.State != seeds.StateCommitted } |
| 79 | |
| 80 | // MoveBlock is the move doors' and workers' predicate (spec §5.8): a |
| 81 | // --from seed moves only once placed, an import only once committed. |
| 82 | func (s *SeedSource) MoveBlock() string { |
| 83 | switch { |
| 84 | case s == nil: |
| 85 | return "" |
| 86 | case s.Import(): |
| 87 | if s.State != seeds.StateCommitted { |
| 88 | return SeedMoveUncommitted |
| 89 | } |
| 90 | case s.State != seeds.StatePlaced: |
| 91 | return SeedMoveUnplaced |
| 92 | } |
| 93 | return "" |
| 94 | } |
| 95 | |
| 96 | // cloneSeed is a deep copy: SeedSource holds no pointers, so a struct copy |
| 97 | // is one. |
| 98 | func cloneSeed(s *SeedSource) *SeedSource { |
| 99 | if s == nil { |
| 100 | return nil |
| 101 | } |
| 102 | c := *s |
| 103 | return &c |
| 104 | } |
| 105 | |
| 106 | func seedEqual(a, b *SeedSource) bool { |
| 107 | if a == nil || b == nil { |
| 108 | return a == nil && b == nil |
| 109 | } |
| 110 | x, y := *a, *b |
| 111 | if !x.At.Equal(y.At) { |
| 112 | return false |
| 113 | } |
| 114 | x.At, y.At = time.Time{}, time.Time{} |
| 115 | return x == y |
| 116 | } |
| 117 | |
| 118 | // ErrSeedConflict is a seed transition the record's seed does not allow: |
| 119 | // another seed's id, the wrong kind, or the wrong state. It is the answer, |
| 120 | // not a race (a race is retried once by the scaffold, which re-reads and |
| 121 | // re-applies the transition). |
| 122 | var ErrSeedConflict = errors.New("provision: seed transition refused") |
| 123 | |
| 124 | // SetRecordSeed applies one seed transition to the record by CAS on its |
| 125 | // etag, retrying once on a lost race (updateRecord's scaffold). It is the |
| 126 | // only writer of Record.Seed after creation. |
| 127 | func SetRecordSeed(ctx context.Context, s release.Store, acct, app, host string, mut func(*Record) error) (*Record, error) { |
| 128 | return updateRecordSeeding(ctx, s, acct, app, host, mut, true) |
| 129 | } |
| 130 | |
| 131 | // seedIn checks one row of the transition table: the record's seed must be |
| 132 | // id, of kind (any kind when ""), in one of from. |
| 133 | func seedIn(rec *Record, id, kind string, from ...string) error { |
| 134 | switch { |
| 135 | case rec.Seed == nil || rec.Seed.ID != id: |
| 136 | return fmt.Errorf("%w: the record's seed is not %s", ErrSeedConflict, id) |
| 137 | case kind != "" && rec.Seed.Kind != kind: |
| 138 | return fmt.Errorf("%w: seed %s is a %s seed", ErrSeedConflict, id, rec.Seed.Kind) |
| 139 | case !slices.Contains(from, rec.Seed.State): |
| 140 | return fmt.Errorf("%w: seed %s is %s", ErrSeedConflict, id, rec.Seed.State) |
| 141 | } |
| 142 | return nil |
| 143 | } |
| 144 | |
| 145 | // seedStep is a transition that is one row of the table: checked by |
| 146 | // seedIn, then the seed moves to to and also (when set) records the rest. |
| 147 | func seedStep(id, kind string, from []string, to string, also func(*SeedSource)) func(*Record) error { |
| 148 | return func(rec *Record) error { |
| 149 | if err := seedIn(rec, id, kind, from...); err != nil { |
| 150 | return err |
| 151 | } |
| 152 | rec.Seed.State = to |
| 153 | if also != nil { |
| 154 | also(rec.Seed) |
| 155 | } |
| 156 | return nil |
| 157 | } |
| 158 | } |
| 159 | |
| 160 | // SeedPublishReplica is --from's publish (spec §4 step 7.3): reserving to |
| 161 | // ready, with what the restore actually produced. |
| 162 | func SeedPublishReplica(id, txid, sha string, bytes int64) func(*Record) error { |
| 163 | return seedStep(id, seeds.KindReplica, []string{seeds.StateReserving}, seeds.StateReady, func(s *SeedSource) { |
| 164 | s.TXID, s.SHA256, s.Bytes = txid, sha, bytes |
| 165 | }) |
| 166 | } |
| 167 | |
| 168 | // SeedUploaded is the upload's last step (spec §5.2 step 5). |
| 169 | func SeedUploaded(id string) func(*Record) error { |
| 170 | return seedStep(id, seeds.KindImport, []string{seeds.StateAwaitingUpload}, seeds.StateUploaded, nil) |
| 171 | } |
| 172 | |
| 173 | // SeedStopped is the app saying its own process is down (spec §5.3). |
| 174 | func SeedStopped(id string) func(*Record) error { |
| 175 | return seedStep(id, seeds.KindImport, []string{seeds.StateUploaded}, seeds.StateReady, nil) |
| 176 | } |
| 177 | |
| 178 | // SeedSwapped is the box's CAS after its swap transaction (spec §5.4). |
| 179 | func SeedSwapped(id string) func(*Record) error { |
| 180 | return seedStep(id, seeds.KindImport, []string{seeds.StateReady}, seeds.StateSwapped, nil) |
| 181 | } |
| 182 | |
| 183 | // SeedPlace is the box's CAS after the first upload of the host's own |
| 184 | // replica (spec §3.4 step 5): a --from seed from ready, an import from |
| 185 | // swapped. An import is never placed from ready: that would skip its swap. |
| 186 | func SeedPlace(id string) func(*Record) error { |
| 187 | return func(rec *Record) error { |
| 188 | from := seeds.StateReady |
| 189 | if rec.Seed.Import() { |
| 190 | from = seeds.StateSwapped |
| 191 | } |
| 192 | return seedStep(id, "", []string{from}, seeds.StatePlaced, nil)(rec) |
| 193 | } |
| 194 | } |
| 195 | |
| 196 | // SeedCommit is the app's verdict that the instance serves (spec §5.4): |
| 197 | // refused before placed, because nothing has been verified yet. |
| 198 | func SeedCommit(id string) func(*Record) error { |
| 199 | return seedStep(id, seeds.KindImport, []string{seeds.StatePlaced}, seeds.StateCommitted, nil) |
| 200 | } |
| 201 | |
| 202 | // SeedRefuse is the box declining the swap, naming why (spec §5.4). |
| 203 | func SeedRefuse(id, reason string) func(*Record) error { |
| 204 | return seedStep(id, seeds.KindImport, []string{seeds.StateReady}, seeds.StateRefused, func(s *SeedSource) { |
| 205 | s.Reason = reason |
| 206 | }) |
| 207 | } |
| 208 | |
| 209 | // SeedAbort abandons a seed (spec §5.6, §4 step 7): an import from any |
| 210 | // state before committed, a --from seed while reserving or ready. |
| 211 | // |
| 212 | // An import's abort is also refused while a run is open on the instance |
| 213 | // (spec §5.6, run spec §5.1), although run admission already refuses an |
| 214 | // uncommitted import: a RunOpenError, so errors.Is(err, ErrRunOpen). Both |
| 215 | // specs scope that rule to the import; a --from seed's abort is its door's |
| 216 | // own failure path, and a run admitted on the reserved record must not |
| 217 | // leave it stuck reserving. |
| 218 | func SeedAbort(id string) func(*Record) error { return SeedAbortBecause(id, "") } |
| 219 | |
| 220 | // SeedAbortBecause is SeedAbort recording why in Reason: the sentence the |
| 221 | // --from door answers its caller with when its copy fails after the record |
| 222 | // is reserved (spec §4, Plan 5 ruling R2). An empty reason keeps whatever |
| 223 | // Reason the seed already had (an import refused before its abort). |
| 224 | func SeedAbortBecause(id, reason string) func(*Record) error { |
| 225 | return func(rec *Record) error { |
| 226 | from := []string{seeds.StateReserving, seeds.StateReady} |
| 227 | if rec.Seed.Import() { |
| 228 | from = []string{seeds.StateAwaitingUpload, seeds.StateUploaded, seeds.StateReady, |
| 229 | seeds.StateSwapped, seeds.StatePlaced, seeds.StateRefused} |
| 230 | } |
| 231 | if err := seedIn(rec, id, "", from...); err != nil { |
| 232 | return err |
| 233 | } |
| 234 | if rec.Seed.Import() && rec.ActiveRun.Open() { |
| 235 | return &RunOpenError{Run: *rec.ActiveRun} |
| 236 | } |
| 237 | rec.Seed.State = seeds.StateAborted |
| 238 | if reason != "" { |
| 239 | rec.Seed.Reason = reason |
| 240 | } |
| 241 | return nil |
| 242 | } |
| 243 | } |
| 244 | |
| 245 | // SeedAbortUploadBecause is the import upload's own failure (spec §5.2, |
| 246 | // Plan 6 Task 2 fix round 1): SeedAbortBecause, but only while the seed |
| 247 | // still waits for its upload. An upload whose import has moved on (another |
| 248 | // upload landed it, or it was aborted or rolled back) gets ErrSeedConflict |
| 249 | // and aborts nothing. |
| 250 | func SeedAbortUploadBecause(id, reason string) func(*Record) error { |
| 251 | abort := SeedAbortBecause(id, reason) |
| 252 | return func(rec *Record) error { |
| 253 | if err := seedIn(rec, id, seeds.KindImport, seeds.StateAwaitingUpload); err != nil { |
| 254 | return err |
| 255 | } |
| 256 | return abort(rec) |
| 257 | } |
| 258 | } |
| 259 | |
| 260 | // SeedAbortStale is the console's seeds sweep abandoning a seed it judged |
| 261 | // stale (spec §3.1, §5.6): SeedAbort, but only while the record's seed is |
| 262 | // exactly what the sweep read, the same id in the same state since the same |
| 263 | // At. The sweep judged staleness from that state and At, so a seed that |
| 264 | // moved on in any way (published, uploaded, stopped, aborted by its own |
| 265 | // door) is refused with ErrSeedConflict, and SetRecordSeed's retry after a |
| 266 | // lost race never aborts a seed the sweep did not judge. Every state |
| 267 | // SeedAbort refuses stays refused. |
| 268 | func SeedAbortStale(id, state string, at time.Time) func(*Record) error { |
| 269 | abort := SeedAbort(id) |
| 270 | return func(rec *Record) error { |
| 271 | if err := seedIn(rec, id, "", state); err != nil { |
| 272 | return err |
| 273 | } |
| 274 | if !rec.Seed.At.Equal(at) { |
| 275 | return fmt.Errorf("%w: seed %s was stamped again (at %s, read at %s)", ErrSeedConflict, id, |
| 276 | rec.Seed.At.UTC().Format(time.RFC3339Nano), at.UTC().Format(time.RFC3339Nano)) |
| 277 | } |
| 278 | return abort(rec) |
| 279 | } |
| 280 | } |
| 281 | |
| 282 | // SeedRolledBack is the box's last word on an aborted seed (spec §5.6, and |
| 283 | // §3.2's no-journal path). The console finishes from here. |
| 284 | func SeedRolledBack(id string) func(*Record) error { |
| 285 | return seedStep(id, "", []string{seeds.StateAborted}, seeds.StateRolledBack, nil) |
| 286 | } |
| 287 | |
| 288 | // SeedRolledBackBeforeSwap is the delete door's shortcut for an import no |
| 289 | // box has swapped (spec §5.6, codex R2-7). |
| 290 | func SeedRolledBackBeforeSwap(id string) func(*Record) error { |
| 291 | return seedStep(id, seeds.KindImport, []string{seeds.StateAwaitingUpload, seeds.StateUploaded, seeds.StateRefused}, |
| 292 | seeds.StateRolledBack, nil) |
| 293 | } |
| 294 | |
| 295 | // SeedColumn is the registry column a record's row carries (seed_source): |
| 296 | // "" for a seedless record. |
| 297 | func SeedColumn(rec Record) string { |
| 298 | if rec.Seed == nil { |
| 299 | return "" |
| 300 | } |
| 301 | return seeds.RowSeed{ID: rec.Seed.ID, Kind: rec.Seed.Kind, SHA256: rec.Seed.SHA256, |
| 302 | Bytes: rec.Seed.Bytes, State: rec.Seed.State}.Encode() |
| 303 | } |
| 304 | |
| 305 | // SeedMoveSentence is a move refusal's sentence (spec §5.8), "" for any |
| 306 | // block but the two. |
| 307 | func SeedMoveSentence(block string) string { |
| 308 | switch block { |
| 309 | case SeedMoveUnplaced: |
| 310 | return "wake the instance once before moving it" |
| 311 | case SeedMoveUncommitted: |
| 312 | return "commit or abort the import first" |
| 313 | } |
| 314 | return "" |
| 315 | } |
| 316 | |
| 317 | // SeedMoveError is a move the record's seed refuses: an ErrMoveConflict |
| 318 | // whose sentence is SeedMoveSentence(Block). It is typed so a door that |
| 319 | // meets it under a CAS (carlos apps place) can answer the sentence alone. |
| 320 | type SeedMoveError struct{ Block string } |
| 321 | |
| 322 | func (e *SeedMoveError) Error() string { |
| 323 | return ErrMoveConflict.Error() + ": " + SeedMoveSentence(e.Block) |
| 324 | } |
| 325 | |
| 326 | func (e *SeedMoveError) Unwrap() error { return ErrMoveConflict } |
| 327 | |
| 328 | // seedMoveRefusal is the seed rule of every write that moves a record (the |
| 329 | // two move-start transitions and SetRecordRegion), under the record CAS. |
| 330 | func seedMoveRefusal(rec *Record) error { |
| 331 | if b := rec.Seed.MoveBlock(); b != "" { |
| 332 | return &SeedMoveError{Block: b} |
| 333 | } |
| 334 | return nil |
| 335 | } |
| 336 | |