package console import ( "context" "encoding/json" "errors" "fmt" "log" "net/http" "strings" "github.com/carlosframework/platform/internal/controlname" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/seeds" ) // errLegacyRouteBusy: the name's current assignment has an import between // ready and committed, so the assignment cannot change (spec §6.4). var errLegacyRouteBusy = errors.New("an import is in flight under the current assignment") // errLegacyRoutesUnread: the assignments, or the records the in-flight // check needs, could not be read completely. Never "no assignment" and // never "idle". var errLegacyRoutesUnread = errors.New("could not read the legacy route assignments") // handleAPISystemLegacyRoutes is `carlos system legacy-routes assign` // (seeded-instances spec §6.4, codex 9): an operator maps an app name, as // filed under the operator account, to one account and app. The swap reads // the file through seeds.ReadLegacyOwners. The update doors' authority: an // ops account owner or an address on CARLOS_SYSTEM_OPERATORS // (apiSystemRoller); the route is closed to every service credential. func (s *Server) handleAPISystemLegacyRoutes(w http.ResponseWriter, r *http.Request) { addr, _, ok := s.apiSystemRoller(w, r) if !ok { return } // The assignment widens what an import may take over, and the claim // protocol has no place for seeded instances yet (#250): a claim // console refuses before it reads anything (preflight M4). if s.cfg.HostClaim { ref := seedClaimConsoleRefusal() jsonError(w, ref.Status, ref.Msg) return } // A console with no deployment bucket has no assignments to read: the // handler's own sentence, not a panic (final review carry item). if s.cfg.Releases == nil { jsonError(w, http.StatusServiceUnavailable, "could not read the legacy route assignments. Try again.") return } var req struct { Name string `json:"name"` To string `json:"to"` } if !readJSON(w, r, &req) { return } name := strings.TrimSpace(req.Name) if !release.ValidApp(name) { jsonError(w, http.StatusBadRequest, "name must be an app name") return } acctSqid, app, found := strings.Cut(strings.TrimSpace(req.To), "/") if !found || !controlname.ValidAccount(acctSqid) || !release.ValidApp(app) { jsonError(w, http.StatusBadRequest, "to must be /") return } // The box's swap takes a legacy route only for the app that carries its // name, so an assignment to any other app could never be used. if app != name { 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)) return } acct, err := s.store.AccountBySqid(acctSqid) if err != nil { log.Printf("console: legacy routes: account %s: %v", acctSqid, err) } if err != nil || acct == nil { jsonError(w, http.StatusBadRequest, "to must be /") return } has, err := s.store.HasAppIn(acct.ID, app) if err != nil { log.Printf("console: legacy routes: has-app %s/%s: %v", acct.Sqid, app, err) } if err != nil || !has { jsonError(w, http.StatusBadRequest, "to must be /") return } ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) defer cancel() want := seeds.LegacyOwner{Account: acct.Sqid, App: app} changed, prev, err := assignLegacyRoute(ctx, s.cfg.Releases, name, want) switch { case errors.Is(err, errLegacyRouteBusy): jsonError(w, http.StatusConflict, fmt.Sprintf("%s has an import in flight under its current assignment. Commit or abort it first.", name)) return case errors.Is(err, errLegacyRoutesUnread): log.Printf("console: legacy routes: %v", err) jsonError(w, http.StatusBadGateway, "could not read the legacy route assignments. Try again.") return case err != nil: if !errors.Is(err, release.ErrCASConflict) { log.Printf("console: legacy routes: %v", err) } jsonError(w, http.StatusBadGateway, "could not save the legacy route assignment. Try again.") return } if changed { detail := fmt.Sprintf("assigned the legacy routes named %s to %s/%s", name, want.Account, want.App) s.auditSystem(addr, "legacy-route-assign", detail) // Spec §7 lists the row under what a member sees: the app the name // now belongs to, and the app it was taken from, each get it in the // audit trail their activity tab reads. s.audit(acct, app, addr, "legacy-route-assign", detail) if prev != (seeds.LegacyOwner{}) { s.auditPreviousLegacyOwner(prev, addr, detail) } s.bumpSerial(ctx) } writeJSON(w, http.StatusOK, map[string]string{"name": name, "account": want.Account, "app": want.App}) } // auditPreviousLegacyOwner writes the assignment's audit row to the app a // re-assignment moved the name away from. Best effort, as every audit row // is: an account that no longer exists has no trail to write to. func (s *Server) auditPreviousLegacyOwner(prev seeds.LegacyOwner, addr, detail string) { acct, err := s.store.AccountBySqid(prev.Account) if err != nil || acct == nil { log.Printf("console: legacy routes: audit the previous owner %s/%s: account %v, %v", prev.Account, prev.App, acct, err) return } s.audit(acct, prev.App, addr, "legacy-route-assign", detail) } // assignLegacyRoute maps name to want in the assignment file. It reads the // file through the swap's own strict reader, and writes it by CAS on the // etag that read returned (create-only when the file was absent), so two // assigns at once never lose one: the loser gets release.ErrCASConflict and // nothing is retried behind the operator's back. The same mapping again is // no write at all (changed false). prev is the entry a write replaced, or // the zero owner when the name had none. // // The in-flight check and the write are not one atomic step, and both // orders fail closed. An import of the old mapping that reaches ready // between them meets the new entry at its swap and refuses the row // (SD-N94). One whose box swaps between them read the old entry, which it // was authorized under, and nothing after the swap reads the file again. func assignLegacyRoute(ctx context.Context, rs release.Store, name string, want seeds.LegacyOwner) (changed bool, prev seeds.LegacyOwner, err error) { owners, etag, err := seeds.ReadLegacyOwners(ctx, rs) if err != nil { return false, seeds.LegacyOwner{}, fmt.Errorf("%w: %w", errLegacyRoutesUnread, err) } if cur, ok := owners.Lookup(name); ok { if cur == want { return false, seeds.LegacyOwner{}, nil } busy, err := importInFlight(ctx, rs, cur) if err != nil { return false, seeds.LegacyOwner{}, fmt.Errorf("%w: in-flight check of %s/%s: %w", errLegacyRoutesUnread, cur.Account, cur.App, err) } if busy { return false, seeds.LegacyOwner{}, errLegacyRouteBusy } prev = cur } owners.Names[name] = want body, err := json.Marshal(owners) if err != nil { return false, seeds.LegacyOwner{}, err } if _, err := rs.Put(ctx, seeds.LegacyOwnersKey, body, etag); err != nil { return false, seeds.LegacyOwner{}, fmt.Errorf("write %s: %w", seeds.LegacyOwnersKey, err) } return true, prev, nil } // importInFlight reports whether any record of o's account and app holds an // import between ready and committed (ready, swapped or placed; spec §6.4). // It reads strictly (provision.ListRecordsStrict, preflight M7): a listing // it cannot complete, or one past instanceListMax, is an error, and a record // that reads but will not decode counts as busy, since it could be such an // import. An entry the strict reader let through with a name no record path // can hold is an error too: no listing of it means anything. func importInFlight(ctx context.Context, rs release.Store, o seeds.LegacyOwner) (bool, error) { if !controlname.ValidAccount(o.Account) || !release.ValidApp(o.App) { return false, fmt.Errorf("the assignment names %q/%q, which is not an account and app", o.Account, o.App) } recs, unreadable, err := provision.ListRecordsStrict(ctx, rs, o.Account, o.App, instanceListMax) if err != nil { return false, err } if len(unreadable) > 0 { return true, nil } for _, rec := range recs { if !rec.Seed.Import() { continue } switch rec.Seed.State { case seeds.StateReady, seeds.StateSwapped, seeds.StatePlaced: return true, nil } } return false, nil }