carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3import (
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).
20var 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".
25var 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.
33func (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.
128func (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.
150func 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.
186func 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