carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// The build probe, settable on an app that already exists (discussion #13).
2//
3// `carlos deploy` printed "live" and exited 0 while the previous binary was
4// still serving, and every platform-side signal agreed with it, because they
5// all describe the ADOPTED release rather than the running process: the edge
6// stamps X-Carlos-Version from the route row the moment adoption moves the
7// symlink, whether or not anything restarted.
8//
9// The platform already has the answer to that. An app may declare a BUILD
10// PROBE — a path whose response body names the build actually running — and
11// the deploy watch polls the app's own process instead of the header
12// (deploy.go's watchTarget.Probe). It closes exactly the gap #13 describes.
13//
14// It could only be set at CLAIM time. `SetAppBuildProbe` had precisely one
15// caller, handleAPICreateApp, so every app created before the feature existed
16// — or created without the flag, which is every app anyone forgot it for —
17// could never acquire one. The fix for the bug was unreachable by the apps
18// that had the bug. That is what this door is for.
19//
20// GATE: apiAccountApp proves membership, and the route is registered with an
21// EMPTY role set — no service credential of any role reaches it. This is a
22// policy door in the sense svcroutes_test.go means: the probe is what the
23// deploy watch TRUSTS, so a credential able to rewrite it is a credential
24// able to make its own deploys report success. A person sets it once.
25package console
26
27import (
28 "net/http"
29 "strings"
30 "time"
31)
32
33func (s *Server) handleAPIBuildProbe(w http.ResponseWriter, r *http.Request) {
34 addr, acct, app, ok := s.apiAccountApp(w, r)
35 if !ok {
36 return
37 }
38 p, _ := principalFrom(r.Context())
39 if !s.allowWrite(p, time.Now()) {
40 jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
41 return
42 }
43 var req struct {
44 BuildProbe string `json:"build_probe"`
45 }
46 if !readJSON(w, r, &req) {
47 return
48 }
49 // "" is a legal value: it clears the probe back to header-watching, the
50 // same meaning SetAppBuildProbe's own doc comment gives it. The CLI's
51 // clear sentinel is "-", resolved client-side, so nothing here has to
52 // guess whether an empty body meant "clear" or "not mentioned".
53 probe := strings.TrimSpace(req.BuildProbe)
54 if probe != "" && !validBuildProbe(probe) {
55 jsonError(w, http.StatusBadRequest,
56 `build_probe must be an absolute URL path with no query, e.g. "/healthz"`)
57 return
58 }
59 if err := s.store.SetAppBuildProbeContext(r.Context(), acct.ID, app, probe); err != nil {
60 jsonError(w, http.StatusInternalServerError, "could not save the build probe — try again")
61 return
62 }
63 // Read back and echo what was actually STORED, exactly as the claim door
64 // does: a write that silently failed must be a loud error rather than a
65 // quiet lie, and this is the field whose whole job is to be trusted.
66 _, got, err := s.store.AppFlags(acct.ID, app)
67 if err != nil {
68 jsonError(w, http.StatusInternalServerError, "saved, but could not read the build probe back — check it with carlos apps")
69 return
70 }
71 detail := "cleared the build probe for " + app + " (cli)"
72 if got != "" {
73 detail = "set the build probe for " + app + " to " + got + " (cli)"
74 }
75 s.audit(acct, app, addr, "build-probe", detail)
76 writeJSON(w, http.StatusOK, map[string]any{"build_probe": got})
77}
78