| 1 | package hostops |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "os" |
| 8 | "time" |
| 9 | |
| 10 | "github.com/carlosframework/platform/internal/domains" |
| 11 | "github.com/carlosframework/platform/internal/hostclaim" |
| 12 | "github.com/carlosframework/platform/internal/provision" |
| 13 | "github.com/carlosframework/platform/internal/release" |
| 14 | ) |
| 15 | |
| 16 | type productionCreateAuthority interface { |
| 17 | StartCreate(context.Context, hostclaim.Observed, Record, string, string) (hostclaim.Observed, error) |
| 18 | RecordEffects(context.Context, hostclaim.Observed, Record) error |
| 19 | } |
| 20 | |
| 21 | // RecordEffects writes the ownership marker and the record as two effects |
| 22 | // under the request's own registration. The runner comes from the |
| 23 | // StartHandle the console put in ctx; without it (recovery, another |
| 24 | // process) this refuses rather than impersonating the registration owner. |
| 25 | func (a ProductionAuthority) RecordEffects(ctx context.Context, obs hostclaim.Observed, r Record) error { |
| 26 | if obs.Absent || obs.Claim.Holder == nil || obs.Claim.Op.Kind != hostclaim.KindCreating || obs.Claim.Op.Phase == "cancelling" || !matches(r, obs.Claim.Holder) { |
| 27 | return ErrIdentity |
| 28 | } |
| 29 | h, op := obs.Claim.Holder, obs.Claim.Op |
| 30 | if h.Acquisition == "" || op.Intent["ownership_effect"] == "" || op.Intent["record_effect"] == "" { |
| 31 | return ErrContract |
| 32 | } |
| 33 | appID := op.Intent["app_id"] |
| 34 | start, ok := startHandleFor(ctx, h.Account, h.App, appID, op.ID) |
| 35 | if !ok || start.owner.Console != a.ConsoleID || start.owner.Enrollment != a.Enrollment { |
| 36 | return ErrPending |
| 37 | } |
| 38 | adm, err := ReadAdmission(ctx, a.Store, h.Account, h.App, appID) |
| 39 | if err != nil { |
| 40 | return err |
| 41 | } |
| 42 | reg, ok := adm.Value.Registrations[op.ID] |
| 43 | if !ok || reg.Owner != start.owner || !RegistrationLive(reg, a.Now()) { |
| 44 | return ErrPending |
| 45 | } |
| 46 | runner := start.Runner(a.Now) |
| 47 | marker := domains.Claim{Schema: 1, Host: obs.Host, Account: h.Account, App: h.App, CreatedAt: h.CreatedAt.UTC().Format(time.RFC3339Nano), CreatedBy: op.By, Acquisition: h.Acquisition, RecordIncarnation: h.Incarnation, OwnershipState: "active"} |
| 48 | body, err := json.Marshal(marker) |
| 49 | if err != nil { |
| 50 | return err |
| 51 | } |
| 52 | key := domains.ClaimKey(obs.Host) |
| 53 | expectedTag := "" |
| 54 | previous, tag, readErr := a.Store.Get(ctx, key) |
| 55 | if readErr != nil && !errors.Is(readErr, os.ErrNotExist) { |
| 56 | return readErr |
| 57 | } |
| 58 | if readErr == nil { |
| 59 | var prior domains.Claim |
| 60 | if json.Unmarshal(previous, &prior) != nil || prior.Host != obs.Host || prior.Account != h.Account || prior.App != h.App { |
| 61 | return ErrIdentity |
| 62 | } |
| 63 | if prior.Acquisition == "" && prior.RecordIncarnation == "" { |
| 64 | if prior.Pending && op.Intent["ownership_verified"] != "true" { |
| 65 | return ErrIdentity |
| 66 | } |
| 67 | // Existing same-owner TXT reservations become the same ownership |
| 68 | // record; never delete/recreate the marker or discard capabilities. |
| 69 | prior.Acquisition, prior.RecordIncarnation = h.Acquisition, h.Incarnation |
| 70 | prior.OwnershipState, prior.Pending, prior.PendingUntil = "active", false, "" |
| 71 | if body, err = json.Marshal(prior); err != nil { |
| 72 | return err |
| 73 | } |
| 74 | expectedTag = tag |
| 75 | } else if prior.Acquisition == h.Acquisition && prior.RecordIncarnation == h.Incarnation && prior.OwnershipState == "active" { |
| 76 | body, expectedTag = previous, tag |
| 77 | } else { |
| 78 | return ErrIdentity |
| 79 | } |
| 80 | } |
| 81 | _, err = runner.Run(ctx, Effect{ID: op.Intent["ownership_effect"], Resource: key, Action: "ownership-put", Inputs: map[string]string{"body": string(body), "expected_etag": expectedTag}}, func(ctx context.Context) (string, error) { |
| 82 | etag, err := a.Store.Put(ctx, key, body, expectedTag) |
| 83 | if !errors.Is(err, release.ErrCASConflict) { |
| 84 | return etag, err |
| 85 | } |
| 86 | raw, etag, err := a.Store.Get(ctx, key) |
| 87 | if err != nil { |
| 88 | return "", err |
| 89 | } |
| 90 | var got domains.Claim |
| 91 | if err = json.Unmarshal(raw, &got); err != nil { |
| 92 | return "", err |
| 93 | } |
| 94 | if got.Host != obs.Host || got.Account != h.Account || got.App != h.App || got.Acquisition != h.Acquisition || got.RecordIncarnation != h.Incarnation || got.OwnershipState != "active" { |
| 95 | return "", ErrIdentity |
| 96 | } |
| 97 | return etag, nil |
| 98 | }) |
| 99 | if err != nil { |
| 100 | return err |
| 101 | } |
| 102 | _, err = runner.Run(ctx, Effect{ID: op.Intent["record_effect"], Resource: h.Key, Action: "record-put", Inputs: map[string]string{"body": string(r.Body)}}, func(ctx context.Context) (string, error) { |
| 103 | etag, _, err := provision.WriteRecord(provision.WithRecordSequence(ctx), a.Store, h.Key, []byte(`{}`), r.Body, "") |
| 104 | if !errors.Is(err, release.ErrCASConflict) { |
| 105 | return etag, err |
| 106 | } |
| 107 | existing, err := ReadRecord(ctx, a.Store, h.Key) |
| 108 | if err != nil { |
| 109 | return "", err |
| 110 | } |
| 111 | if !matches(existing, h) { |
| 112 | return "", ErrIdentity |
| 113 | } |
| 114 | return existing.ETag, nil |
| 115 | }) |
| 116 | return err |
| 117 | } |
| 118 | |