// The build probe, settable on an app that already exists (discussion #13). // // `carlos deploy` printed "live" and exited 0 while the previous binary was // still serving, and every platform-side signal agreed with it, because they // all describe the ADOPTED release rather than the running process: the edge // stamps X-Carlos-Version from the route row the moment adoption moves the // symlink, whether or not anything restarted. // // The platform already has the answer to that. An app may declare a BUILD // PROBE — a path whose response body names the build actually running — and // the deploy watch polls the app's own process instead of the header // (deploy.go's watchTarget.Probe). It closes exactly the gap #13 describes. // // It could only be set at CLAIM time. `SetAppBuildProbe` had precisely one // caller, handleAPICreateApp, so every app created before the feature existed // — or created without the flag, which is every app anyone forgot it for — // could never acquire one. The fix for the bug was unreachable by the apps // that had the bug. That is what this door is for. // // GATE: apiAccountApp proves membership, and the route is registered with an // EMPTY role set — no service credential of any role reaches it. This is a // policy door in the sense svcroutes_test.go means: the probe is what the // deploy watch TRUSTS, so a credential able to rewrite it is a credential // able to make its own deploys report success. A person sets it once. package console import ( "net/http" "strings" "time" ) func (s *Server) handleAPIBuildProbe(w http.ResponseWriter, r *http.Request) { addr, acct, app, ok := s.apiAccountApp(w, r) if !ok { return } p, _ := principalFrom(r.Context()) if !s.allowWrite(p, time.Now()) { jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") return } var req struct { BuildProbe string `json:"build_probe"` } if !readJSON(w, r, &req) { return } // "" is a legal value: it clears the probe back to header-watching, the // same meaning SetAppBuildProbe's own doc comment gives it. The CLI's // clear sentinel is "-", resolved client-side, so nothing here has to // guess whether an empty body meant "clear" or "not mentioned". probe := strings.TrimSpace(req.BuildProbe) if probe != "" && !validBuildProbe(probe) { jsonError(w, http.StatusBadRequest, `build_probe must be an absolute URL path with no query, e.g. "/healthz"`) return } if err := s.store.SetAppBuildProbeContext(r.Context(), acct.ID, app, probe); err != nil { jsonError(w, http.StatusInternalServerError, "could not save the build probe — try again") return } // Read back and echo what was actually STORED, exactly as the claim door // does: a write that silently failed must be a loud error rather than a // quiet lie, and this is the field whose whole job is to be trusted. _, got, err := s.store.AppFlags(acct.ID, app) if err != nil { jsonError(w, http.StatusInternalServerError, "saved, but could not read the build probe back — check it with carlos apps") return } detail := "cleared the build probe for " + app + " (cli)" if got != "" { detail = "set the build probe for " + app + " to " + got + " (cli)" } s.audit(acct, app, addr, "build-probe", detail) writeJSON(w, http.StatusOK, map[string]any{"build_probe": got}) }