| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "net/http" |
| 6 | |
| 7 | "github.com/carlosframework/platform/internal/hostclaim" |
| 8 | "github.com/carlosframework/platform/internal/hostops" |
| 9 | "github.com/carlosframework/platform/internal/provision" |
| 10 | ) |
| 11 | |
| 12 | // claimRetireAccepted reports a delete whose exclusive operation this |
| 13 | // console owns and has made durable, but which is still waiting (draining, |
| 14 | // box acknowledgements, parking). Recovery or a retry of the same delete |
| 15 | // carries it on. It unwraps to hostops.ErrPending, so a caller that only |
| 16 | // checks for failure stays conservative. |
| 17 | type claimRetireAccepted struct { |
| 18 | operation, phase, host string |
| 19 | } |
| 20 | |
| 21 | func (a *claimRetireAccepted) Error() string { |
| 22 | return "console: delete of " + a.host + " accepted, operation " + a.operation + " is " + a.phase |
| 23 | } |
| 24 | |
| 25 | func (a *claimRetireAccepted) Unwrap() error { return hostops.ErrPending } |
| 26 | |
| 27 | // claimRetireWaiting turns a pending answer from a step of an owned delete |
| 28 | // into claimRetireAccepted. Every other error, and nil, passes through. |
| 29 | func claimRetireWaiting(h *hostops.OperationHandle, phase, host string, err error) error { |
| 30 | if err == nil { |
| 31 | return nil |
| 32 | } |
| 33 | var accepted *claimRetireAccepted |
| 34 | if errors.As(err, &accepted) { |
| 35 | return err |
| 36 | } |
| 37 | if h != nil && errors.Is(err, hostops.ErrPending) { |
| 38 | return &claimRetireAccepted{operation: h.ID(), phase: phase, host: host} |
| 39 | } |
| 40 | return err |
| 41 | } |
| 42 | |
| 43 | func claimRetireRecordKey(ref provision.RetireRef) (string, error) { |
| 44 | switch ref.Family { |
| 45 | case "instance": |
| 46 | return provision.RecordKey(ref.Account, ref.App, ref.Host), nil |
| 47 | case "sidecar": |
| 48 | if ref.Name == "" || ref.Host != provision.SidecarHost(ref.Name, ref.App, ref.Account) { |
| 49 | return "", provision.ErrClaimRetirer |
| 50 | } |
| 51 | return provision.SidecarRecordKey(ref.Account, ref.App, ref.Name), nil |
| 52 | default: |
| 53 | return "", provision.ErrClaimRetirer |
| 54 | } |
| 55 | } |
| 56 | |
| 57 | // claimRetireHolderIs: the claim's holder is exactly the record this delete |
| 58 | // names (and, when the caller pinned one, that incarnation). |
| 59 | func claimRetireHolderIs(h *hostclaim.Holder, ref provision.RetireRef, key string) bool { |
| 60 | return h != nil && h.Key == key && h.Family == ref.Family && h.Account == ref.Account && h.App == ref.App && |
| 61 | (ref.Incarnation == "" || h.Incarnation == ref.Incarnation) |
| 62 | } |
| 63 | |
| 64 | // claimRetireOutcome answers a delete door's retire error. It reports |
| 65 | // whether it wrote the response: |
| 66 | // |
| 67 | // - an owned, durable delete still in progress is 202 with the operation |
| 68 | // ID and phase (the caller audits the request first); |
| 69 | // - a delete refused before any effect because another exclusive |
| 70 | // operation holds the app (or the host is not ready for this console) |
| 71 | // is 409; |
| 72 | // - anything else is left to the caller. |
| 73 | func claimRetireOutcome(w http.ResponseWriter, err error) bool { |
| 74 | var accepted *claimRetireAccepted |
| 75 | if errors.As(err, &accepted) { |
| 76 | w.Header().Set("Retry-After", "2") |
| 77 | writeJSON(w, http.StatusAccepted, map[string]string{"result": "accepted", "operation_id": accepted.operation, "phase": accepted.phase, "host": accepted.host}) |
| 78 | return true |
| 79 | } |
| 80 | if errors.Is(err, hostops.ErrPending) { |
| 81 | w.Header().Set("Retry-After", "2") |
| 82 | jsonError(w, http.StatusConflict, claimRetireBusyMsg) |
| 83 | return true |
| 84 | } |
| 85 | return false |
| 86 | } |
| 87 | |
| 88 | // claimRetireBusyMsg is the 409 a delete meets when another operation |
| 89 | // holds the app or the host. |
| 90 | const claimRetireBusyMsg = "another operation is in progress on this app; try again shortly" |
| 91 | |
| 92 | // claimRetireAcceptedOf returns the accepted delete, if err is one. |
| 93 | func claimRetireAcceptedOf(err error) (*claimRetireAccepted, bool) { |
| 94 | var accepted *claimRetireAccepted |
| 95 | if errors.As(err, &accepted) { |
| 96 | return accepted, true |
| 97 | } |
| 98 | return nil, false |
| 99 | } |
| 100 | |
| 101 | // Explicit host-claim refusals of intentionally unsupported doors. Each |
| 102 | // answers 409 with its own message before any registration or effect. |
| 103 | // |
| 104 | // Rename and account transfer are refused for EVERY app on a host-claim |
| 105 | // console, with or without provisioning records. Both rewrite an app's |
| 106 | // identity: its SQL rows (apps.app / account_id and every row keyed by the |
| 107 | // name), its bucket prefixes and its admission object, which is keyed by |
| 108 | // account and name. Doing that safely needs one operation that closes the |
| 109 | // old incarnation's admission and creates the new name's under the same |
| 110 | // gate, atomically, with no request admitted against either name in |
| 111 | // between. That protocol is not built, so neither door may run, whatever |
| 112 | // the app holds. |
| 113 | // |
| 114 | // Instance moves (request, cut, abort) rewrite the record's move journal, |
| 115 | // which only the unbuilt host-claim Moves protocol may do under its own |
| 116 | // operation. |
| 117 | const ( |
| 118 | claimRenameUnsupportedMsg = "renaming an app is not supported on this console yet: it needs a host-claim operation that gates both the old and the new name" |
| 119 | claimTransferUnsupportedMsg = "moving an app to another account is not supported on this console yet: it needs a host-claim operation that gates the app under both accounts" |
| 120 | claimMoveUnsupportedMsg = "instance moves are not available on this console yet: they need the host-claim Moves protocol" |
| 121 | ) |
| 122 | |