carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package host
2
3import (
4 "bytes"
5 "context"
6 "database/sql"
7 "encoding/json"
8 "errors"
9 "io"
10 "net"
11 "net/http"
12 "time"
13
14 "github.com/carlosframework/platform/internal/hostbox"
15 "github.com/carlosframework/platform/internal/hostclaim"
16)
17
18func rootJournalClient(socket string) hostbox.JournalCall {
19 return func(ctx context.Context, q hostbox.JournalRequest) (hostbox.JournalResponse, error) {
20 var out hostbox.JournalResponse
21 body, err := json.Marshal(q)
22 if err != nil {
23 return out, err
24 }
25 transport := &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
26 return (&net.Dialer{}).DialContext(ctx, "unix", socket)
27 }}
28 defer transport.CloseIdleConnections()
29 client := &http.Client{Transport: transport, Timeout: 30 * time.Second}
30 // Retries carry the same ID and byte-identical body. No request regenerates
31 // its identity after an uncertain response.
32 uncertain := false
33 for attempt := 0; attempt < 2; attempt++ {
34 req, e := http.NewRequestWithContext(ctx, http.MethodPost, "http://instance-control/claim/journal", bytes.NewReader(body))
35 if e != nil {
36 return out, e
37 }
38 response, e := client.Do(req)
39 if e != nil {
40 err = e
41 uncertain = true
42 continue
43 }
44 if response.StatusCode != http.StatusOK {
45 response.Body.Close()
46 switch response.StatusCode {
47 case http.StatusBadRequest, http.StatusForbidden, http.StatusConflict:
48 // After a lost reply the first attempt may have committed; a
49 // later refusal (Authenticate, authority or claim checks run
50 // before replay) says nothing about it.
51 if uncertain {
52 return out, errors.Join(err, hostbox.ErrJournalUncertain)
53 }
54 return hostbox.JournalResponse{V: 1, ID: q.ID, Error: "refused"}, nil
55 default:
56 // A 5xx or an unrecognized response says nothing about commit outcome.
57 err = hostbox.ErrJournalUncertain
58 uncertain = true
59 continue
60 }
61 }
62 decoder := json.NewDecoder(io.LimitReader(response.Body, 4<<20))
63 decoder.DisallowUnknownFields()
64 err = decoder.Decode(&out)
65 if err == nil && decoder.Decode(new(any)) != io.EOF {
66 err = hostbox.ErrJournalUncertain
67 }
68 response.Body.Close()
69 if err != nil {
70 uncertain = true
71 continue
72 }
73 if uncertain && out.Error != "" {
74 return out, hostbox.ErrJournalUncertain
75 }
76 return out, nil
77 }
78 return out, err
79 }
80}
81
82func (h ClaimRootHTTP) serveJournal(w http.ResponseWriter, r *http.Request, peer StopPeer) {
83 var q hostbox.JournalRequest
84 decoder := json.NewDecoder(io.LimitReader(r.Body, 4<<20))
85 decoder.DisallowUnknownFields()
86 if decoder.Decode(&q) != nil || decoder.Decode(new(any)) != io.EOF || h.Journals == nil {
87 w.WriteHeader(http.StatusBadRequest)
88 return
89 }
90 if hostbox.ServingParkOperation(q.Op) || q.ServingPark != nil || q.V == 4 {
91 h.serveServingPark(w, r, q)
92 return
93 }
94 if q.Op == "fence-view" {
95 h.serveFenceView(w, r, q)
96 return
97 }
98 if q.Op == "inventory-write" && h.Stop.Coordinator.Guard.Root != nil {
99 out, err := h.commitRootInventory(r.Context(), q)
100 if err != nil {
101 w.WriteHeader(http.StatusConflict)
102 return
103 }
104 w.Header().Set("Content-Type", "application/json")
105 _ = json.NewEncoder(w).Encode(out)
106 return
107 }
108 owner := h.Reconcile.Box + "/" + h.Reconcile.Enrollment
109 if out, found, err := h.Journals.Replay(r.Context(), owner, q); err != nil {
110 // Only a proven mismatch (other owner or bytes under a stored ID) is a
111 // conflict, and an invalid request is a plain bad request. A failed
112 // receipt lookup or an unreadable receipt says nothing about whether the
113 // request committed, so it stays retryable.
114 switch {
115 case errors.Is(err, hostbox.ErrChanged):
116 w.WriteHeader(http.StatusConflict)
117 case errors.Is(err, hostbox.ErrTypedJournal):
118 w.WriteHeader(http.StatusBadRequest)
119 default:
120 w.WriteHeader(http.StatusServiceUnavailable)
121 }
122 return
123 } else if found {
124 w.Header().Set("Content-Type", "application/json")
125 _ = json.NewEncoder(w).Encode(out)
126 return
127 }
128 if h.RequireNewWork != nil && !hostbox.JournalReadOrSettle(q.Op) {
129 if err := h.RequireNewWork(r.Context()); err != nil {
130 w.WriteHeader(http.StatusForbidden)
131 return
132 }
133 }
134 target := q.Target
135 switch q.Op {
136 case "create", "prelaunch":
137 target = q.Launch.Target
138 case "intend", "reserve", "started", "settled":
139 target = q.Reservation.Target
140 case "inventory-write":
141 target.Host = q.Inventory.Host
142 target.Incarnation = q.Inventory.Incarnation
143 target.Stamp = q.Inventory.InstallationStamp
144 target.Box = q.Inventory.Box
145 target.Enrollment = q.Inventory.Enrollment
146 }
147 read := q.Op == "read" || q.Op == "session" || q.Op == "has-host" || q.Op == "has-effects" || q.Op == "inventory-read" || q.Op == "inventory-exact" || q.Op == "inventory-version"
148 if target.Host == "" {
149 w.WriteHeader(http.StatusForbidden)
150 return
151 }
152 if !read {
153 if target.Box != h.Reconcile.Box || target.Enrollment != h.Reconcile.Enrollment {
154 w.WriteHeader(http.StatusForbidden)
155 return
156 }
157 if q.Op != "inventory-write" && (target.BootID != peer.BootID || target.ControllerID != peer.ControllerID) {
158 w.WriteHeader(http.StatusForbidden)
159 return
160 }
161 if (q.Op == "create" || q.Op == "prelaunch") && (q.Launch.ControllerPID != peer.PID || q.Launch.ProcessStart != peer.ProcessStart) {
162 w.WriteHeader(http.StatusForbidden)
163 return
164 }
165 // Completion can drain already issued work after admission or the claim
166 // changes. It still needs this live controller and an exact stored item;
167 // none of these operations creates a reservation or producer.
168 settlement := q.Op == "close-writers" || q.Op == "complete" || q.Op == "settled" || q.Op == "started"
169 if !settlement {
170 current, err := hostclaim.Read(r.Context(), h.Stop.Coordinator.Evidence, target.Host)
171 if err != nil {
172 // An unreadable claim proves nothing about its state.
173 w.WriteHeader(http.StatusServiceUnavailable)
174 return
175 }
176 if current.Claim == nil || current.Claim.Holder == nil {
177 w.WriteHeader(http.StatusConflict)
178 return
179 }
180 holder := current.Claim.Holder
181 if (q.Op != "inventory-write" && current.Claim.Op.ID != target.Op) || holder.Incarnation != target.Incarnation || holder.Stamp != target.Stamp || (q.Op != "inventory-write" && holder.WriterBox != target.Box) || (q.Op != "inventory-write" && holder.Session != target.Session) {
182 w.WriteHeader(http.StatusConflict)
183 return
184 }
185 }
186 }
187 if err := validateClaimJournalContainment(r.Context(), q, h.Journals.Launches); err != nil {
188 w.WriteHeader(http.StatusForbidden)
189 return
190 }
191 if q.Op == "inventory-write" {
192 if h.Reconcile.Registry == nil {
193 w.WriteHeader(http.StatusForbidden)
194 return
195 }
196 row, err := h.Reconcile.Registry.Get(q.Inventory.Host)
197 if err != nil {
198 w.WriteHeader(http.StatusServiceUnavailable)
199 return
200 }
201 if hostbox.ValidateAgentInventory(q.Inventory, row, h.Roots["data"]) != nil {
202 w.WriteHeader(http.StatusForbidden)
203 return
204 }
205 }
206 var out hostbox.JournalResponse
207 var err error
208 execute := func() error { var e error; out, e = h.Journals.Execute(r.Context(), owner, q); return e }
209 if q.Op == "close-writers" {
210 err = h.Stop.Coordinator.Guard.WithLocal(r.Context(), target.Host, execute)
211 } else {
212 err = execute()
213 }
214 if err != nil {
215 switch {
216 case errors.Is(err, sql.ErrNoRows):
217 out.Error = "missing"
218 case errors.Is(err, hostbox.ErrChanged):
219 out.Error = "changed"
220 case errors.Is(err, hostbox.ErrUnsupported):
221 out.Error = "unsupported"
222 case errors.Is(err, hostbox.ErrTypedInventory), errors.Is(err, hostbox.ErrTypedJournal):
223 out.Error = "refused"
224 default:
225 out.Error = "uncertain"
226 }
227 }
228 w.Header().Set("Content-Type", "application/json")
229 _ = json.NewEncoder(w).Encode(out)
230}
231