package provision import ( "context" "errors" "fmt" "slices" "time" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/seeds" ) // SeedSource is a record's seed (seeded-instances spec §3.2). The run series // declared its Kind and State; those keep their json tags. type SeedSource struct { ID string `json:"id"` // random, minted with the seed; never reused Kind string `json:"kind"` // seeds.KindReplica | seeds.KindImport From string `json:"from,omitempty"` // replica: the source host TXID string `json:"txid,omitempty"` // replica: the TXID actually restored SHA256 string `json:"sha256"` // of snapshot.db Bytes int64 `json:"bytes"` State string `json:"state"` // seeds.State* Reason string `json:"reason,omitempty"` // set with refused, and with aborted when the abort names why By string `json:"by"` // the caller At time.Time `json:"at"` // import only: Replaces string `json:"replaces,omitempty"` // the addr the swap replaces VerifyHash string `json:"verify_hash,omitempty"` // sha256 of the verify token (§5.4) } // ErrSeedProtected is an ordinary record writer trying to change Seed. var ErrSeedProtected = errors.New("provision: the seed changes only through a seed transition") // The two reasons a seed refuses a move (spec §5.8). Plan 8 gives each its // sentence. const ( SeedMoveUnplaced = "unplaced" SeedMoveUncommitted = "uncommitted" ) // Import reports whether the seed is an import. func (s *SeedSource) Import() bool { return s != nil && s.Kind == seeds.KindImport } // Abandoned reports an aborted seed, before or after the box rolled it back. func (s *SeedSource) Abandoned() bool { return s != nil && (s.State == seeds.StateAborted || s.State == seeds.StateRolledBack) } // Unfinished reports a seed that still needs a door, a box or the console // to act before it is done: the states claim activation refuses over // (hostops, merge review S2), because none of those acts on a claim // console or a claim-armed box. // // - reserving, awaiting-upload, uploaded: a door is still writing it; // - ready: a --from seed waits for its first wake to place it, and an // import for its swap; // - an import at swapped or placed: not committed; // - refused: an import the box would not swap, which only the delete // door's import rule can remove; // - aborted, rolled-back: its rollback or its finish is still owed. // // Finished, and the only two: a --from seed at placed and an import at // committed. Any other kind and state is unfinished, including a state or a // kind this build does not know and a pairing no door writes (a committed // --from seed). func (s *SeedSource) Unfinished() bool { if s == nil { return false } finished := (s.Kind == seeds.KindReplica && s.State == seeds.StatePlaced) || (s.Kind == seeds.KindImport && s.State == seeds.StateCommitted) return !finished } // BlocksRun is run admission's predicate (run spec §5.1 step 9, codex // R2-2): an import that is not committed refuses carlos run. func (s *SeedSource) BlocksRun() bool { return s.Import() && s.State != seeds.StateCommitted } // MoveBlock is the move doors' and workers' predicate (spec §5.8): a // --from seed moves only once placed, an import only once committed. func (s *SeedSource) MoveBlock() string { switch { case s == nil: return "" case s.Import(): if s.State != seeds.StateCommitted { return SeedMoveUncommitted } case s.State != seeds.StatePlaced: return SeedMoveUnplaced } return "" } // cloneSeed is a deep copy: SeedSource holds no pointers, so a struct copy // is one. func cloneSeed(s *SeedSource) *SeedSource { if s == nil { return nil } c := *s return &c } func seedEqual(a, b *SeedSource) bool { if a == nil || b == nil { return a == nil && b == nil } x, y := *a, *b if !x.At.Equal(y.At) { return false } x.At, y.At = time.Time{}, time.Time{} return x == y } // ErrSeedConflict is a seed transition the record's seed does not allow: // another seed's id, the wrong kind, or the wrong state. It is the answer, // not a race (a race is retried once by the scaffold, which re-reads and // re-applies the transition). var ErrSeedConflict = errors.New("provision: seed transition refused") // SetRecordSeed applies one seed transition to the record by CAS on its // etag, retrying once on a lost race (updateRecord's scaffold). It is the // only writer of Record.Seed after creation. func SetRecordSeed(ctx context.Context, s release.Store, acct, app, host string, mut func(*Record) error) (*Record, error) { return updateRecordSeeding(ctx, s, acct, app, host, mut, true) } // seedIn checks one row of the transition table: the record's seed must be // id, of kind (any kind when ""), in one of from. func seedIn(rec *Record, id, kind string, from ...string) error { switch { case rec.Seed == nil || rec.Seed.ID != id: return fmt.Errorf("%w: the record's seed is not %s", ErrSeedConflict, id) case kind != "" && rec.Seed.Kind != kind: return fmt.Errorf("%w: seed %s is a %s seed", ErrSeedConflict, id, rec.Seed.Kind) case !slices.Contains(from, rec.Seed.State): return fmt.Errorf("%w: seed %s is %s", ErrSeedConflict, id, rec.Seed.State) } return nil } // seedStep is a transition that is one row of the table: checked by // seedIn, then the seed moves to to and also (when set) records the rest. func seedStep(id, kind string, from []string, to string, also func(*SeedSource)) func(*Record) error { return func(rec *Record) error { if err := seedIn(rec, id, kind, from...); err != nil { return err } rec.Seed.State = to if also != nil { also(rec.Seed) } return nil } } // SeedPublishReplica is --from's publish (spec §4 step 7.3): reserving to // ready, with what the restore actually produced. func SeedPublishReplica(id, txid, sha string, bytes int64) func(*Record) error { return seedStep(id, seeds.KindReplica, []string{seeds.StateReserving}, seeds.StateReady, func(s *SeedSource) { s.TXID, s.SHA256, s.Bytes = txid, sha, bytes }) } // SeedUploaded is the upload's last step (spec §5.2 step 5). func SeedUploaded(id string) func(*Record) error { return seedStep(id, seeds.KindImport, []string{seeds.StateAwaitingUpload}, seeds.StateUploaded, nil) } // SeedStopped is the app saying its own process is down (spec §5.3). func SeedStopped(id string) func(*Record) error { return seedStep(id, seeds.KindImport, []string{seeds.StateUploaded}, seeds.StateReady, nil) } // SeedSwapped is the box's CAS after its swap transaction (spec §5.4). func SeedSwapped(id string) func(*Record) error { return seedStep(id, seeds.KindImport, []string{seeds.StateReady}, seeds.StateSwapped, nil) } // SeedPlace is the box's CAS after the first upload of the host's own // replica (spec §3.4 step 5): a --from seed from ready, an import from // swapped. An import is never placed from ready: that would skip its swap. func SeedPlace(id string) func(*Record) error { return func(rec *Record) error { from := seeds.StateReady if rec.Seed.Import() { from = seeds.StateSwapped } return seedStep(id, "", []string{from}, seeds.StatePlaced, nil)(rec) } } // SeedCommit is the app's verdict that the instance serves (spec §5.4): // refused before placed, because nothing has been verified yet. func SeedCommit(id string) func(*Record) error { return seedStep(id, seeds.KindImport, []string{seeds.StatePlaced}, seeds.StateCommitted, nil) } // SeedRefuse is the box declining the swap, naming why (spec §5.4). func SeedRefuse(id, reason string) func(*Record) error { return seedStep(id, seeds.KindImport, []string{seeds.StateReady}, seeds.StateRefused, func(s *SeedSource) { s.Reason = reason }) } // SeedAbort abandons a seed (spec §5.6, §4 step 7): an import from any // state before committed, a --from seed while reserving or ready. // // An import's abort is also refused while a run is open on the instance // (spec §5.6, run spec §5.1), although run admission already refuses an // uncommitted import: a RunOpenError, so errors.Is(err, ErrRunOpen). Both // specs scope that rule to the import; a --from seed's abort is its door's // own failure path, and a run admitted on the reserved record must not // leave it stuck reserving. func SeedAbort(id string) func(*Record) error { return SeedAbortBecause(id, "") } // SeedAbortBecause is SeedAbort recording why in Reason: the sentence the // --from door answers its caller with when its copy fails after the record // is reserved (spec §4, Plan 5 ruling R2). An empty reason keeps whatever // Reason the seed already had (an import refused before its abort). func SeedAbortBecause(id, reason string) func(*Record) error { return func(rec *Record) error { from := []string{seeds.StateReserving, seeds.StateReady} if rec.Seed.Import() { from = []string{seeds.StateAwaitingUpload, seeds.StateUploaded, seeds.StateReady, seeds.StateSwapped, seeds.StatePlaced, seeds.StateRefused} } if err := seedIn(rec, id, "", from...); err != nil { return err } if rec.Seed.Import() && rec.ActiveRun.Open() { return &RunOpenError{Run: *rec.ActiveRun} } rec.Seed.State = seeds.StateAborted if reason != "" { rec.Seed.Reason = reason } return nil } } // SeedAbortUploadBecause is the import upload's own failure (spec §5.2, // Plan 6 Task 2 fix round 1): SeedAbortBecause, but only while the seed // still waits for its upload. An upload whose import has moved on (another // upload landed it, or it was aborted or rolled back) gets ErrSeedConflict // and aborts nothing. func SeedAbortUploadBecause(id, reason string) func(*Record) error { abort := SeedAbortBecause(id, reason) return func(rec *Record) error { if err := seedIn(rec, id, seeds.KindImport, seeds.StateAwaitingUpload); err != nil { return err } return abort(rec) } } // SeedAbortStale is the console's seeds sweep abandoning a seed it judged // stale (spec §3.1, §5.6): SeedAbort, but only while the record's seed is // exactly what the sweep read, the same id in the same state since the same // At. The sweep judged staleness from that state and At, so a seed that // moved on in any way (published, uploaded, stopped, aborted by its own // door) is refused with ErrSeedConflict, and SetRecordSeed's retry after a // lost race never aborts a seed the sweep did not judge. Every state // SeedAbort refuses stays refused. func SeedAbortStale(id, state string, at time.Time) func(*Record) error { abort := SeedAbort(id) return func(rec *Record) error { if err := seedIn(rec, id, "", state); err != nil { return err } if !rec.Seed.At.Equal(at) { return fmt.Errorf("%w: seed %s was stamped again (at %s, read at %s)", ErrSeedConflict, id, rec.Seed.At.UTC().Format(time.RFC3339Nano), at.UTC().Format(time.RFC3339Nano)) } return abort(rec) } } // SeedRolledBack is the box's last word on an aborted seed (spec §5.6, and // §3.2's no-journal path). The console finishes from here. func SeedRolledBack(id string) func(*Record) error { return seedStep(id, "", []string{seeds.StateAborted}, seeds.StateRolledBack, nil) } // SeedRolledBackBeforeSwap is the delete door's shortcut for an import no // box has swapped (spec §5.6, codex R2-7). func SeedRolledBackBeforeSwap(id string) func(*Record) error { return seedStep(id, seeds.KindImport, []string{seeds.StateAwaitingUpload, seeds.StateUploaded, seeds.StateRefused}, seeds.StateRolledBack, nil) } // SeedColumn is the registry column a record's row carries (seed_source): // "" for a seedless record. func SeedColumn(rec Record) string { if rec.Seed == nil { return "" } return seeds.RowSeed{ID: rec.Seed.ID, Kind: rec.Seed.Kind, SHA256: rec.Seed.SHA256, Bytes: rec.Seed.Bytes, State: rec.Seed.State}.Encode() } // SeedMoveSentence is a move refusal's sentence (spec §5.8), "" for any // block but the two. func SeedMoveSentence(block string) string { switch block { case SeedMoveUnplaced: return "wake the instance once before moving it" case SeedMoveUncommitted: return "commit or abort the import first" } return "" } // SeedMoveError is a move the record's seed refuses: an ErrMoveConflict // whose sentence is SeedMoveSentence(Block). It is typed so a door that // meets it under a CAS (carlos apps place) can answer the sentence alone. type SeedMoveError struct{ Block string } func (e *SeedMoveError) Error() string { return ErrMoveConflict.Error() + ": " + SeedMoveSentence(e.Block) } func (e *SeedMoveError) Unwrap() error { return ErrMoveConflict } // seedMoveRefusal is the seed rule of every write that moves a record (the // two move-start transitions and SetRecordRegion), under the record CAS. func seedMoveRefusal(rec *Record) error { if b := rec.Seed.MoveBlock(); b != "" { return &SeedMoveError{Block: b} } return nil }