| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "fmt" |
| 8 | "log" |
| 9 | "net/http" |
| 10 | "strings" |
| 11 | |
| 12 | "github.com/carlosframework/platform/internal/controlname" |
| 13 | "github.com/carlosframework/platform/internal/provision" |
| 14 | "github.com/carlosframework/platform/internal/release" |
| 15 | "github.com/carlosframework/platform/internal/seeds" |
| 16 | ) |
| 17 | |
| 18 | // errLegacyRouteBusy: the name's current assignment has an import between |
| 19 | // ready and committed, so the assignment cannot change (spec §6.4). |
| 20 | var errLegacyRouteBusy = errors.New("an import is in flight under the current assignment") |
| 21 | |
| 22 | // errLegacyRoutesUnread: the assignments, or the records the in-flight |
| 23 | // check needs, could not be read completely. Never "no assignment" and |
| 24 | // never "idle". |
| 25 | var errLegacyRoutesUnread = errors.New("could not read the legacy route assignments") |
| 26 | |
| 27 | // handleAPISystemLegacyRoutes is `carlos system legacy-routes assign` |
| 28 | // (seeded-instances spec §6.4, codex 9): an operator maps an app name, as |
| 29 | // filed under the operator account, to one account and app. The swap reads |
| 30 | // the file through seeds.ReadLegacyOwners. The update doors' authority: an |
| 31 | // ops account owner or an address on CARLOS_SYSTEM_OPERATORS |
| 32 | // (apiSystemRoller); the route is closed to every service credential. |
| 33 | func (s *Server) handleAPISystemLegacyRoutes(w http.ResponseWriter, r *http.Request) { |
| 34 | addr, _, ok := s.apiSystemRoller(w, r) |
| 35 | if !ok { |
| 36 | return |
| 37 | } |
| 38 | // The assignment widens what an import may take over, and the claim |
| 39 | // protocol has no place for seeded instances yet (#250): a claim |
| 40 | // console refuses before it reads anything (preflight M4). |
| 41 | if s.cfg.HostClaim { |
| 42 | ref := seedClaimConsoleRefusal() |
| 43 | jsonError(w, ref.Status, ref.Msg) |
| 44 | return |
| 45 | } |
| 46 | // A console with no deployment bucket has no assignments to read: the |
| 47 | // handler's own sentence, not a panic (final review carry item). |
| 48 | if s.cfg.Releases == nil { |
| 49 | jsonError(w, http.StatusServiceUnavailable, "could not read the legacy route assignments. Try again.") |
| 50 | return |
| 51 | } |
| 52 | var req struct { |
| 53 | Name string `json:"name"` |
| 54 | To string `json:"to"` |
| 55 | } |
| 56 | if !readJSON(w, r, &req) { |
| 57 | return |
| 58 | } |
| 59 | name := strings.TrimSpace(req.Name) |
| 60 | if !release.ValidApp(name) { |
| 61 | jsonError(w, http.StatusBadRequest, "name must be an app name") |
| 62 | return |
| 63 | } |
| 64 | acctSqid, app, found := strings.Cut(strings.TrimSpace(req.To), "/") |
| 65 | if !found || !controlname.ValidAccount(acctSqid) || !release.ValidApp(app) { |
| 66 | jsonError(w, http.StatusBadRequest, "to must be <account>/<app>") |
| 67 | return |
| 68 | } |
| 69 | // The box's swap takes a legacy route only for the app that carries its |
| 70 | // name, so an assignment to any other app could never be used. |
| 71 | if app != name { |
| 72 | jsonError(w, http.StatusBadRequest, fmt.Sprintf("the routes are filed under %s, so they can only be assigned to an app named %s. Use --to %s/%s.", name, name, acctSqid, name)) |
| 73 | return |
| 74 | } |
| 75 | acct, err := s.store.AccountBySqid(acctSqid) |
| 76 | if err != nil { |
| 77 | log.Printf("console: legacy routes: account %s: %v", acctSqid, err) |
| 78 | } |
| 79 | if err != nil || acct == nil { |
| 80 | jsonError(w, http.StatusBadRequest, "to must be <account>/<app>") |
| 81 | return |
| 82 | } |
| 83 | has, err := s.store.HasAppIn(acct.ID, app) |
| 84 | if err != nil { |
| 85 | log.Printf("console: legacy routes: has-app %s/%s: %v", acct.Sqid, app, err) |
| 86 | } |
| 87 | if err != nil || !has { |
| 88 | jsonError(w, http.StatusBadRequest, "to must be <account>/<app>") |
| 89 | return |
| 90 | } |
| 91 | ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| 92 | defer cancel() |
| 93 | want := seeds.LegacyOwner{Account: acct.Sqid, App: app} |
| 94 | changed, prev, err := assignLegacyRoute(ctx, s.cfg.Releases, name, want) |
| 95 | switch { |
| 96 | case errors.Is(err, errLegacyRouteBusy): |
| 97 | jsonError(w, http.StatusConflict, fmt.Sprintf("%s has an import in flight under its current assignment. Commit or abort it first.", name)) |
| 98 | return |
| 99 | case errors.Is(err, errLegacyRoutesUnread): |
| 100 | log.Printf("console: legacy routes: %v", err) |
| 101 | jsonError(w, http.StatusBadGateway, "could not read the legacy route assignments. Try again.") |
| 102 | return |
| 103 | case err != nil: |
| 104 | if !errors.Is(err, release.ErrCASConflict) { |
| 105 | log.Printf("console: legacy routes: %v", err) |
| 106 | } |
| 107 | jsonError(w, http.StatusBadGateway, "could not save the legacy route assignment. Try again.") |
| 108 | return |
| 109 | } |
| 110 | if changed { |
| 111 | detail := fmt.Sprintf("assigned the legacy routes named %s to %s/%s", name, want.Account, want.App) |
| 112 | s.auditSystem(addr, "legacy-route-assign", detail) |
| 113 | // Spec §7 lists the row under what a member sees: the app the name |
| 114 | // now belongs to, and the app it was taken from, each get it in the |
| 115 | // audit trail their activity tab reads. |
| 116 | s.audit(acct, app, addr, "legacy-route-assign", detail) |
| 117 | if prev != (seeds.LegacyOwner{}) { |
| 118 | s.auditPreviousLegacyOwner(prev, addr, detail) |
| 119 | } |
| 120 | s.bumpSerial(ctx) |
| 121 | } |
| 122 | writeJSON(w, http.StatusOK, map[string]string{"name": name, "account": want.Account, "app": want.App}) |
| 123 | } |
| 124 | |
| 125 | // auditPreviousLegacyOwner writes the assignment's audit row to the app a |
| 126 | // re-assignment moved the name away from. Best effort, as every audit row |
| 127 | // is: an account that no longer exists has no trail to write to. |
| 128 | func (s *Server) auditPreviousLegacyOwner(prev seeds.LegacyOwner, addr, detail string) { |
| 129 | acct, err := s.store.AccountBySqid(prev.Account) |
| 130 | if err != nil || acct == nil { |
| 131 | log.Printf("console: legacy routes: audit the previous owner %s/%s: account %v, %v", prev.Account, prev.App, acct, err) |
| 132 | return |
| 133 | } |
| 134 | s.audit(acct, prev.App, addr, "legacy-route-assign", detail) |
| 135 | } |
| 136 | |
| 137 | // assignLegacyRoute maps name to want in the assignment file. It reads the |
| 138 | // file through the swap's own strict reader, and writes it by CAS on the |
| 139 | // etag that read returned (create-only when the file was absent), so two |
| 140 | // assigns at once never lose one: the loser gets release.ErrCASConflict and |
| 141 | // nothing is retried behind the operator's back. The same mapping again is |
| 142 | // no write at all (changed false). prev is the entry a write replaced, or |
| 143 | // the zero owner when the name had none. |
| 144 | // |
| 145 | // The in-flight check and the write are not one atomic step, and both |
| 146 | // orders fail closed. An import of the old mapping that reaches ready |
| 147 | // between them meets the new entry at its swap and refuses the row |
| 148 | // (SD-N94). One whose box swaps between them read the old entry, which it |
| 149 | // was authorized under, and nothing after the swap reads the file again. |
| 150 | func assignLegacyRoute(ctx context.Context, rs release.Store, name string, want seeds.LegacyOwner) (changed bool, prev seeds.LegacyOwner, err error) { |
| 151 | owners, etag, err := seeds.ReadLegacyOwners(ctx, rs) |
| 152 | if err != nil { |
| 153 | return false, seeds.LegacyOwner{}, fmt.Errorf("%w: %w", errLegacyRoutesUnread, err) |
| 154 | } |
| 155 | if cur, ok := owners.Lookup(name); ok { |
| 156 | if cur == want { |
| 157 | return false, seeds.LegacyOwner{}, nil |
| 158 | } |
| 159 | busy, err := importInFlight(ctx, rs, cur) |
| 160 | if err != nil { |
| 161 | return false, seeds.LegacyOwner{}, fmt.Errorf("%w: in-flight check of %s/%s: %w", errLegacyRoutesUnread, cur.Account, cur.App, err) |
| 162 | } |
| 163 | if busy { |
| 164 | return false, seeds.LegacyOwner{}, errLegacyRouteBusy |
| 165 | } |
| 166 | prev = cur |
| 167 | } |
| 168 | owners.Names[name] = want |
| 169 | body, err := json.Marshal(owners) |
| 170 | if err != nil { |
| 171 | return false, seeds.LegacyOwner{}, err |
| 172 | } |
| 173 | if _, err := rs.Put(ctx, seeds.LegacyOwnersKey, body, etag); err != nil { |
| 174 | return false, seeds.LegacyOwner{}, fmt.Errorf("write %s: %w", seeds.LegacyOwnersKey, err) |
| 175 | } |
| 176 | return true, prev, nil |
| 177 | } |
| 178 | |
| 179 | // importInFlight reports whether any record of o's account and app holds an |
| 180 | // import between ready and committed (ready, swapped or placed; spec §6.4). |
| 181 | // It reads strictly (provision.ListRecordsStrict, preflight M7): a listing |
| 182 | // it cannot complete, or one past instanceListMax, is an error, and a record |
| 183 | // that reads but will not decode counts as busy, since it could be such an |
| 184 | // import. An entry the strict reader let through with a name no record path |
| 185 | // can hold is an error too: no listing of it means anything. |
| 186 | func importInFlight(ctx context.Context, rs release.Store, o seeds.LegacyOwner) (bool, error) { |
| 187 | if !controlname.ValidAccount(o.Account) || !release.ValidApp(o.App) { |
| 188 | return false, fmt.Errorf("the assignment names %q/%q, which is not an account and app", o.Account, o.App) |
| 189 | } |
| 190 | recs, unreadable, err := provision.ListRecordsStrict(ctx, rs, o.Account, o.App, instanceListMax) |
| 191 | if err != nil { |
| 192 | return false, err |
| 193 | } |
| 194 | if len(unreadable) > 0 { |
| 195 | return true, nil |
| 196 | } |
| 197 | for _, rec := range recs { |
| 198 | if !rec.Seed.Import() { |
| 199 | continue |
| 200 | } |
| 201 | switch rec.Seed.State { |
| 202 | case seeds.StateReady, seeds.StateSwapped, seeds.StatePlaced: |
| 203 | return true, nil |
| 204 | } |
| 205 | } |
| 206 | return false, nil |
| 207 | } |
| 208 | |