| 1 | package provision |
| 2 | |
| 3 | // The replica provisioning surface (#162, edge checkout). The instance |
| 4 | // record family's own verb, exactly as the discussion's Ask 2 frames it: a |
| 5 | // replica is the sandbox verb with a different role and a chosen placement — |
| 6 | // same app, same account, role=replica, placed on the box that carries the |
| 7 | // placement code. |
| 8 | // |
| 9 | // The record the console's create door writes (apiinstances.go) and the |
| 10 | // record a home's own code would write share ONE constructor, Replica, so |
| 11 | // "what a replica record looks like" has exactly one answer: everything |
| 12 | // copied from the home record's policy half (app, account, pool, channel), |
| 13 | // Region = the replica's placement, Role = "replica", and ValidateShape run |
| 14 | // over the result — the same rails the door applies, because neither side |
| 15 | // trusts the other about what is in the bucket. |
| 16 | // |
| 17 | // The routing half of #162 lives in the reconciler and the edges file; the |
| 18 | // lease/drain protocol that runs BETWEEN home and replica is titogo's own. |
| 19 | |
| 20 | import ( |
| 21 | "context" |
| 22 | "errors" |
| 23 | "time" |
| 24 | |
| 25 | "github.com/carlosframework/platform/internal/release" |
| 26 | ) |
| 27 | |
| 28 | // ErrReplicaOfReplica and ErrReplicaIncompleteHome are Replica's refusals: |
| 29 | // a replica of a replica is not a shape the platform carries, and a home |
| 30 | // link to an incomplete record would mint a replica whose account or app is |
| 31 | // nothing the home ever was. |
| 32 | var ( |
| 33 | ErrReplicaOfReplica = errors.New("provision: record is itself a replica — a replica of a replica is not a shape the platform carries") |
| 34 | ErrReplicaIncompleteHome = errors.New("provision: home record is not a complete instance record (host/app/account)") |
| 35 | ) |
| 36 | |
| 37 | // Replica turns a home-shaped record into the replica record |
| 38 | // CreateRecordReplica writes, validating the result as it goes. host is the |
| 39 | // replica's own host (one label + the app's instance domain — the same shape |
| 40 | // a home claims), placement is the replica's placement code (ValidRegion's |
| 41 | // vocabulary, the same one the create door validates as armed), home is the |
| 42 | // home record as read from the bucket. The returned record has passed |
| 43 | // ValidateShape — the proof ReplicaRecord's type carries. |
| 44 | func Replica(home Record, host, placement string) (ReplicaRecord, error) { |
| 45 | if home.Role == "replica" { |
| 46 | return ReplicaRecord{}, ErrReplicaOfReplica |
| 47 | } |
| 48 | if home.Host == "" || home.App == "" || home.Account == "" { |
| 49 | return ReplicaRecord{}, ErrReplicaIncompleteHome |
| 50 | } |
| 51 | rec := Record{ |
| 52 | Schema: 1, |
| 53 | Host: host, |
| 54 | Slug: host, |
| 55 | App: home.App, |
| 56 | Account: home.Account, |
| 57 | Pool: home.Pool, |
| 58 | Region: placement, |
| 59 | Channel: home.Channel, |
| 60 | CreatedAt: time.Now().UTC(), |
| 61 | Role: "replica", |
| 62 | } |
| 63 | if err := ValidateShape(rec); err != nil { |
| 64 | return ReplicaRecord{}, err |
| 65 | } |
| 66 | return ReplicaRecord{rec}, nil |
| 67 | } |
| 68 | |
| 69 | // ReplicaRecord is a Record that has passed the replica rails: ValidateShape |
| 70 | // (which refuses a replica role on anything but a socket-backed instance) |
| 71 | // has seen this exact value. Constructors return it; nothing else may build |
| 72 | // one, so a writer that wants to stamp the role onto a record must go |
| 73 | // through the same validation the door applies. |
| 74 | type ReplicaRecord struct { |
| 75 | Record |
| 76 | } |
| 77 | |
| 78 | // CreateRecordReplica writes the replica record — create-only, the |
| 79 | // CreateRecord contract (a lost race is release.ErrCASConflict, never a |
| 80 | // silent overwrite). The record MUST have come from Replica: the type is |
| 81 | // the proof ValidateShape's replica rails have already run, and this writer |
| 82 | // re-checks nothing. |
| 83 | func CreateRecordReplica(ctx context.Context, s release.Store, rec ReplicaRecord) error { |
| 84 | return CreateRecord(ctx, s, rec.Record) |
| 85 | } |
| 86 | |
| 87 | // ListRecordReplicas returns an app's records that ARE replicas — the |
| 88 | // instance-API list the home instance walks to find whom it leases to, |
| 89 | // addressable by Host + Region. Records are listed through ListRecords (the |
| 90 | // same capped, path-validated fold the instance list uses) and filtered to |
| 91 | // role=replica, so a home instance can never mistake a sibling home for its |
| 92 | // own replica even before it checks the role on the wire view. |
| 93 | func ListRecordReplicas(ctx context.Context, s release.Store, acct, app string, max int) ([]Record, error) { |
| 94 | recs, _, err := ListRecords(ctx, s, acct, app, max) |
| 95 | if err != nil { |
| 96 | return nil, err |
| 97 | } |
| 98 | var out []Record |
| 99 | for _, rec := range recs { |
| 100 | if rec.Role == "replica" { |
| 101 | out = append(out, rec) |
| 102 | } |
| 103 | } |
| 104 | return out, nil |
| 105 | } |
| 106 | |