package console import ( "context" "net/http" "sync" "time" "github.com/carlosframework/platform/internal/hostclaim" "github.com/carlosframework/platform/internal/hostops" ) type claimCreateAuthority interface { StartCreate(context.Context, hostclaim.Observed, hostops.Record, string, string) (hostclaim.Observed, error) RecordEffects(context.Context, hostclaim.Observed, hostops.Record) error } type claimCreateRequest struct { Record hostops.Record Observed hostclaim.Observed Authority claimCreateAuthority Context context.Context Close func() } // beginClaimCreate registers through the typed control writer and carries // the resulting StartHandle in the request context. RecordEffects and the // private-store issuer take their runner from that handle; nothing derives // authority from the owner tuple stored in the bucket. func (s *Server) beginClaimCreate(ctx context.Context, acct *Account, app, host, by string, build func(time.Time, string) (hostops.Record, error)) (*claimCreateRequest, error) { if !s.cfg.HostClaim || s.cfg.HostClaimOps == nil || s.cfg.ClaimAuthority == nil || s.cfg.ClaimAuthority.Control == nil { return nil, hostops.ErrContract } authority, ok := s.cfg.HostClaimOps.Authority.(claimCreateAuthority) if !ok { return nil, hostops.ErrContract } before, err := hostclaim.Read(ctx, s.cfg.Releases, host) if err != nil { return nil, err } // Region HA (spec 2 ยง7, H27): choose before registering anything. A // door's earlier choice is kept only if this read is the claim it was // decided on (census ruling region-ha-2a, item 5). if ctx, err = s.chooseExecutorFirst(ctx, host, before, build); err != nil { return nil, err } appID, err := s.store.ClaimAppID(acct.ID, app) if err != nil { return nil, err } admission, err := hostops.ReadAdmission(ctx, s.cfg.Releases, acct.Sqid, app, appID) if err != nil { return nil, err } id, err := claimEffectID() if err != nil { return nil, err } controller, err := claimEffectID() if err != nil { return nil, err } a := s.cfg.ClaimAuthority owner := hostops.RegistrationOwner{Console: a.ConsoleID, Enrollment: a.Enrollment, Controller: controller} registration := hostops.Registration{Kind: "create", Host: host, Owner: owner, AppID: appID, Epoch: admission.Value.Epoch, Claim: hostops.SavedObservation(before)} start, err := a.Control.RegisterStart(ctx, acct.Sqid, app, appID, id, registration, time.Now()) if err != nil { return nil, err } record, err := build(time.Now().UTC(), id) if err != nil { return nil, err } inc, err := record.Incarnation() if err != nil { return nil, err } if err = start.Bind(ctx, record.Holder.CreatedAt, inc, time.Now()); err != nil { return nil, err } requestCtx, cancel := context.WithCancel(ctx) var wg sync.WaitGroup wg.Add(1) go func() { defer wg.Done() ticker := time.NewTicker(hostops.RegistrationRenewEvery) defer ticker.Stop() for { select { case <-requestCtx.Done(): return case <-ticker.C: // An ambiguous renewal stops further work immediately; it // never extends the acknowledged deadline. if err := start.Renew(requestCtx, time.Now()); err != nil { cancel() return } } } }() closeRequest := func() { cancel(); wg.Wait() } // The start CAS and record effects write claims under this // registration's lifecycle grant and nothing broader. startCtx := hostops.WithStartHandle(start.Lifecycle(requestCtx), start) observed, err := authority.StartCreate(startCtx, before, record, id, by) if err != nil { closeRequest() return nil, err } issuer := claimStoreEffects{Runner: start.Runner(time.Now), OperationID: id} effectCtx := context.WithValue(startCtx, claimEffectContextKey{}, issuer) return &claimCreateRequest{Record: record, Observed: observed, Authority: authority, Context: effectCtx, Close: closeRequest}, nil } func (s *Server) finishClaimCreate(w http.ResponseWriter, q *claimCreateRequest) bool { err := q.Authority.RecordEffects(q.Context, q.Observed, q.Record) if err == nil { _, err = s.cfg.HostClaimOps.Complete(q.Context, q.Observed.Host, q.Observed.Claim.Op.ID) } if err == nil { return true } // The start CAS already accepted this operation. A backend error can // leave uncertain effects and is observable as pending. fresh, readErr := hostclaim.Read(context.WithoutCancel(q.Context), s.cfg.Releases, q.Observed.Host) if readErr == nil && !fresh.Absent && fresh.Claim.Holder != nil && fresh.Claim.Holder.Incarnation == q.Observed.Claim.Holder.Incarnation && fresh.Claim.Op.ID == q.Observed.Claim.Op.ID { phase := fresh.Claim.Op.Phase if phase == "" { phase = fresh.Claim.Op.Kind } writeJSON(w, http.StatusAccepted, map[string]string{"operation_id": fresh.Claim.Op.ID, "phase": phase, "host": fresh.Host}) return false } writeClaimResponseError(w, err) return false } // claimPrivateResponse captures the legacy helper's status without // committing it to the HTTP connection after lifecycle acceptance. type claimPrivateResponse struct{ header http.Header } func (w *claimPrivateResponse) Header() http.Header { return w.header } func (w *claimPrivateResponse) Write(body []byte) (int, error) { return len(body), nil } func (w *claimPrivateResponse) WriteHeader(int) {}