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 "net/http"
5
6 "github.com/carlosframework/platform/internal/controlname"
7 "github.com/carlosframework/platform/internal/release"
8)
9
10// The layout door (isolated-exec design §8.2, plan 5): `carlos system
11// layout migrate|revert|status`. POST records the fleet's intent in
12// control/layout.json and bumps the serial; every box in scope converges
13// on its next serial move. The console moves no file itself: the pass on
14// each box does, with the box's own registry as the authority on what is
15// eligible, so this door is a request, never an instruction about paths.
16//
17// GATE: apiSystemRoller, the same as the update and rollback doors. The
18// intent moves every tenant's files on the boxes in scope, which is the
19// blast radius of a platform update, so it is the same people: an owner of
20// the ops account or an address on CARLOS_SYSTEM_OPERATORS. Anyone else
21// gets the bare 404 those doors give. Registered through cliRoute with an
22// EMPTY role set: no service credential of any role reaches it.
23
24// apiSystemLayoutResponse is both doors' answer. Mode is "none" when no
25// request has ever been recorded; Box is "" for the fleet.
26type apiSystemLayoutResponse struct {
27 Mode string `json:"mode"`
28 Box string `json:"box"`
29 ID string `json:"id"`
30 RequestedAt string `json:"requested_at"`
31 Force bool `json:"force"`
32 // Requests is how many requests the log holds. Additive; the CLI
33 // ignores it today.
34 Requests int `json:"requests"`
35}
36
37func (s *Server) handleAPISystemLayoutGet(w http.ResponseWriter, r *http.Request) {
38 if _, _, ok := s.apiSystemRoller(w, r); !ok {
39 return
40 }
41 if !s.systemBucket(w) {
42 return
43 }
44 log, found, err := release.LoadLayoutIntents(r.Context(), s.cfg.Releases)
45 if err != nil {
46 jsonError(w, http.StatusBadGateway, "could not read the layout intent: "+err.Error())
47 return
48 }
49 out := apiSystemLayoutResponse{Mode: "none"}
50 if found && len(log.Requests) > 0 {
51 in := log.Requests[len(log.Requests)-1]
52 out = apiSystemLayoutResponse{Mode: in.Mode, Box: in.Scope.Box, ID: in.ID, RequestedAt: in.RequestedAt, Force: in.Force, Requests: len(log.Requests)}
53 }
54 writeJSON(w, http.StatusOK, out)
55}
56
57func (s *Server) handleAPISystemLayoutSet(w http.ResponseWriter, r *http.Request) {
58 addr, _, ok := s.apiSystemRoller(w, r)
59 if !ok {
60 return
61 }
62 if !s.systemBucket(w) {
63 return
64 }
65 var in struct {
66 Mode string `json:"mode"`
67 Box string `json:"box"`
68 Force bool `json:"force"`
69 }
70 if !readJSONStrictLimit(w, r, &in, 1<<10) {
71 return
72 }
73 if !release.ValidLayoutMode(in.Mode) {
74 jsonError(w, http.StatusBadRequest, "mode must be nested or flat")
75 return
76 }
77 // The label becomes a scope every box compares its own label against;
78 // the same shape the fleets doors accept for a box label.
79 if in.Box != "" && !controlname.ValidLabel(in.Box) {
80 jsonError(w, http.StatusBadRequest, "box must be a box label")
81 return
82 }
83 // A revert is refused while a maintenance worker is attached to a box
84 // in scope (#132's interlock): flat tenants would read the worker's
85 // credential and its private input through /proc the moment they ran
86 // flat again.
87 if in.Mode == release.LayoutModeFlat {
88 if reason := s.layoutRevertBlocked(r.Context(), in.Box); reason != "" {
89 jsonError(w, http.StatusConflict, reason)
90 return
91 }
92 }
93 // A migrate is refused while any box in scope runs below the floor
94 // release (layoutguard.go): a nested row there would never wake.
95 if in.Mode == release.LayoutModeNested {
96 if reason := s.layoutMigrateBlocked(r.Context(), in.Box); reason != "" {
97 jsonError(w, http.StatusConflict, reason)
98 return
99 }
100 }
101 rec, err := release.RecordLayoutIntent(r.Context(), s.cfg.Releases, in.Mode, in.Box, in.Force)
102 if err != nil {
103 jsonError(w, http.StatusBadGateway, err.Error())
104 return
105 }
106 scope := "the fleet"
107 if in.Box != "" {
108 scope = "box " + in.Box
109 }
110 detail := "recorded layout " + in.Mode + " for " + scope
111 if in.Force {
112 detail += " (force: awake instances are stopped for the move at once)"
113 }
114 s.auditSystem(addr, "system-layout", detail)
115 s.bumpSerial(r.Context())
116 writeJSON(w, http.StatusOK, apiSystemLayoutResponse{Mode: rec.Mode, Box: rec.Scope.Box, ID: rec.ID, RequestedAt: rec.RequestedAt, Force: rec.Force})
117}
118