carlos / platform Public

settings-3 task 9: read-only GET /updates on the control socket

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev df3def0f744834fb02103405e81407a97fe244fa parent 0913833
6 files changed, +425 −4
  • internal/activator/backend_exec.go +11 −2
  • internal/activator/prepare.go +8 −2
  • internal/activator/updates_control_test.go +126 −0
  • internal/host/agent.go +4 −0
  • internal/instanceupdates/control.go +86 −0
  • internal/instanceupdates/control_test.go +190 −0
diff --git a/internal/activator/backend_exec.go b/internal/activator/backend_exec.go
index 96e66227d..34592be3c 100644
--- a/internal/activator/backend_exec.go
+++ b/internal/activator/backend_exec.go
@@ -24,6 +24,7 @@ import (
"github.com/carlosframework/platform/internal/cgroup"
"github.com/carlosframework/platform/internal/edge"
"github.com/carlosframework/platform/internal/instancectl"
+ "github.com/carlosframework/platform/internal/instanceupdates"
"github.com/carlosframework/platform/internal/lease"
"github.com/carlosframework/platform/internal/moves"
"github.com/carlosframework/platform/internal/registry"
@@ -136,6 +137,9 @@ type ExecBackend struct {
// feature — binds the control socket without those verbs, so an app that
// asks gets 404 rather than a socket that isn't there.
Timers schedule.TimerStore
+ // UpdatesReader answers the control socket's read-only GET /updates
+ // (instance update settings spec §5.1); nil mounts nothing.
+ UpdatesReader func(host string) (instanceupdates.Report, error)
// LayoutFacts supplies the box facts the classification of §8.1 needs
// beyond the row: journals and peers. Zero value: never fenced, never
// conflict. Named apart from the Layout method (the Classifier
@@ -1406,9 +1410,14 @@ func (b *ExecBackend) spawnChildFrom(ctx context.Context, rt registry.Route, soc
return env["CARLOS_ADMIN_TOKEN"], nil
}
var mount func(*http.ServeMux)
- if b.Timers != nil {
+ if b.Timers != nil || b.UpdatesReader != nil {
mount = func(mux *http.ServeMux) {
- schedule.MountGated(mux, rt.Host, b.Timers, tokenReader, time.Now, b.writeFence(rt.Host, n))
+ if b.Timers != nil {
+ schedule.MountGated(mux, rt.Host, b.Timers, tokenReader, time.Now, b.writeFence(rt.Host, n))
+ }
+ if b.UpdatesReader != nil {
+ instanceupdates.Mount(mux, rt.Host, tokenReader, b.UpdatesReader)
+ }
}
}
ctl, err := edge.ListenControl(controlSocket, rt.Host, b.Cache, mount)
diff --git a/internal/activator/prepare.go b/internal/activator/prepare.go
index 16e3d45a8..9d1a0bb5c 100644
--- a/internal/activator/prepare.go
+++ b/internal/activator/prepare.go
@@ -19,6 +19,7 @@ import (
"github.com/carlosframework/platform/internal/edge"
"github.com/carlosframework/platform/internal/instancectl"
"github.com/carlosframework/platform/internal/instancefs"
+ "github.com/carlosframework/platform/internal/instanceupdates"
"github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
@@ -141,9 +142,14 @@ func (b *ExecBackend) listenControl(rt registry.Route, control, envPath string,
return env["CARLOS_ADMIN_TOKEN"], nil
}
var mount func(*http.ServeMux)
- if b.Timers != nil {
+ if b.Timers != nil || b.UpdatesReader != nil {
mount = func(mux *http.ServeMux) {
- schedule.MountGated(mux, rt.Host, b.Timers, tokenReader, time.Now, b.writeFence(rt.Host, gen))
+ if b.Timers != nil {
+ schedule.MountGated(mux, rt.Host, b.Timers, tokenReader, time.Now, b.writeFence(rt.Host, gen))
+ }
+ if b.UpdatesReader != nil {
+ instanceupdates.Mount(mux, rt.Host, tokenReader, b.UpdatesReader)
+ }
}
}
ctl, err := edge.ListenControl(control, rt.Host, b.Cache, mount)
diff --git a/internal/activator/updates_control_test.go b/internal/activator/updates_control_test.go
new file mode 100644
index 000000000..d7b24b2b7
--- /dev/null
+++ b/internal/activator/updates_control_test.go
@@ -0,0 +1,126 @@
+package activator
+
+import (
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "os"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/instancectl"
+ "github.com/carlosframework/platform/internal/instanceupdates"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// updatesGet asks GET /updates on a control socket with bearer ("" sends none).
+func updatesGet(t *testing.T, ctl, bearer string) (int, instanceupdates.Report) {
+ t.Helper()
+ req, err := http.NewRequest("GET", "http://carlos/updates?host=other.example.com", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if bearer != "" {
+ req.Header.Set("Authorization", "Bearer "+bearer)
+ }
+ resp, err := socketClient(ctl).Do(req)
+ if err != nil {
+ t.Fatalf("dial control socket %s: %v", ctl, err)
+ }
+ defer resp.Body.Close()
+ body, _ := io.ReadAll(resp.Body)
+ var rep instanceupdates.Report
+ _ = json.Unmarshal(body, &rep)
+ return resp.StatusCode, rep
+}
+
+func fakeUpdatesReader(host string) (instanceupdates.Report, error) {
+ return instanceupdates.Report{Settings: release.UpdateSettings{Updates: release.UpdatesManual}, Installed: "v1.2.0"}, nil
+}
+
+// With UpdatesReader set, GET /updates is served on the spawned instance's
+// control socket behind the minted token, for the host the spawn was for (a
+// ?host= on the request changes nothing); with Timers left nil the schedule
+// verbs are still absent.
+func TestExecBackendStartMountsUpdatesOnControlSocket(t *testing.T) {
+ b := testExecBackend(t) // b.Timers nil
+ b.UpdatesReader = fakeUpdatesReader
+ ctl, token, stop := startAndReachControlSocket(t, b, "upd.example.com")
+ defer stop()
+
+ code, rep := updatesGet(t, ctl, token)
+ if code != http.StatusOK || rep.Host != "upd.example.com" || rep.Installed != "v1.2.0" || rep.Settings.Updates != release.UpdatesManual {
+ t.Fatalf("GET /updates with the minted token = %d %+v", code, rep)
+ }
+ if code, _ := updatesGet(t, ctl, "not-the-token"); code != http.StatusUnauthorized {
+ t.Fatalf("wrong bearer = %d, want 401", code)
+ }
+ if code, _ := updatesGet(t, ctl, ""); code != http.StatusUnauthorized {
+ t.Fatalf("no bearer = %d, want 401", code)
+ }
+ req, _ := http.NewRequest("GET", "http://carlos/schedule", nil)
+ req.Header.Set("Authorization", "Bearer "+token)
+ resp, err := socketClient(ctl).Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusNotFound {
+ t.Fatalf("GET /schedule with nil Timers = %d, want 404", resp.StatusCode)
+ }
+}
+
+// A reader that errors (a host the registry has no row for) is a 503 on the
+// wire, not an empty 200.
+func TestExecBackendStartUpdatesReaderErrorIs503(t *testing.T) {
+ b := testExecBackend(t)
+ b.UpdatesReader = func(string) (instanceupdates.Report, error) { return instanceupdates.Report{}, errors.New("no route") }
+ ctl, token, stop := startAndReachControlSocket(t, b, "upd503.example.com")
+ defer stop()
+ if code, _ := updatesGet(t, ctl, token); code != http.StatusServiceUnavailable {
+ t.Fatalf("GET /updates = %d, want 503", code)
+ }
+}
+
+// With UpdatesReader nil, even with the schedule verbs mounted, /updates is
+// not served.
+func TestExecBackendStartWithNilUpdatesReaderMountsNoUpdates(t *testing.T) {
+ b := testExecBackend(t)
+ b.Timers = &fakeTimerStore{}
+ ctl, token, stop := startAndReachControlSocket(t, b, "noupd.example.com")
+ defer stop()
+ if code, _ := updatesGet(t, ctl, token); code != http.StatusNotFound {
+ t.Fatalf("GET /updates with nil UpdatesReader = %d, want 404", code)
+ }
+}
+
+// The nested layout's listener (prepareAt) mounts the same verb.
+func TestPreparedControlListenerMountsUpdates(t *testing.T) {
+ b, rt := nestedBackend(t, &fakeControl{}, &fakeUnit{})
+ b.GenContainment = true
+ b.UpdatesReader = fakeUpdatesReader
+ p0 := b.pathsFor(rt)
+ if err := os.MkdirAll(p0.runDir, 0o750); err != nil {
+ t.Fatal(err)
+ }
+ sock := nestedGenSocketIn(p0.runDir, 7)
+ p, ctl, logs, _, err := b.prepareAt(rt, instancectl.RoleRun, sock, 7)
+ if err != nil {
+ t.Fatalf("prepareAt: %v", err)
+ }
+ if logs != nil {
+ defer logs.Close()
+ }
+ if ctl == nil {
+ t.Fatal("prepareAt bound no control listener")
+ }
+ defer ctl.Close()
+ env, err := readEnvFileBeneath(p.dataDir, rt.Host+".env")
+ if err != nil {
+ t.Fatal(err)
+ }
+ code, rep := updatesGet(t, sock+".control", env["CARLOS_ADMIN_TOKEN"])
+ if code != http.StatusOK || rep.Host != rt.Host || rep.Installed != "v1.2.0" {
+ t.Fatalf("GET /updates on the prepared listener = %d %+v", code, rep)
+ }
+}
diff --git a/internal/host/agent.go b/internal/host/agent.go
index 7457ed444..4e3460608 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -185,6 +185,7 @@ import (
"github.com/carlosframework/platform/internal/hostwrite"
"github.com/carlosframework/platform/internal/instancectl"
"github.com/carlosframework/platform/internal/instancefs"
+ "github.com/carlosframework/platform/internal/instanceupdates"
"github.com/carlosframework/platform/internal/layout"
"github.com/carlosframework/platform/internal/lease"
"github.com/carlosframework/platform/internal/provision"
@@ -354,6 +355,9 @@ func runAgent(args []string, buildClaims ClaimFactory) {
// so the row is due-indexed by the next tick with nothing in
// between. reg satisfies schedule.TimerStore directly.
Timers: reg,
+ // The control socket's read-only GET /updates answers from the
+ // same registry copy (instance update settings spec §5.1).
+ UpdatesReader: instanceupdates.RegistryReader(reg),
// Asked inside Acquire, before the lease (amadan #213).
ConfigGate: configGate(),
}
diff --git a/internal/instanceupdates/control.go b/internal/instanceupdates/control.go
new file mode 100644
index 000000000..bb86c5118
--- /dev/null
+++ b/internal/instanceupdates/control.go
@@ -0,0 +1,86 @@
+// Package instanceupdates is the control socket's read-only view of an
+// instance's update settings (instance update settings spec §3.3, §5.1). It
+// is mounted beside schedule.Mount's verbs with the same bearer and the same
+// fixed-host rule. Writes ship with #155 step 6's instance-bound credential,
+// through the console, never through this socket.
+package instanceupdates
+
+import (
+ "crypto/sha256"
+ "crypto/subtle"
+ "encoding/json"
+ "errors"
+ "net/http"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// Report is what GET /updates answers: the box's copy of the settings, what
+// the instance may change, and the installed, serving and desired builds with
+// root's reason they differ.
+type Report struct {
+ Host string `json:"host"`
+ Settings release.UpdateSettings `json:"settings"`
+ MayChange []string `json:"may_change"`
+ Installed string `json:"installed,omitempty"`
+ Serving string `json:"serving,omitempty"`
+ Desired string `json:"desired,omitempty"`
+ Reason string `json:"reason,omitempty"`
+}
+
+// Mount adds GET /updates. host is fixed here and never read from a request;
+// token is read on every call (the env file can change under a running
+// instance), and an empty token refuses everything.
+func Mount(mux *http.ServeMux, host string, token func() (string, error), read func(host string) (Report, error)) {
+ mux.HandleFunc("GET /updates", func(w http.ResponseWriter, r *http.Request) {
+ want, err := token()
+ if err != nil || want == "" {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+ got := sha256.Sum256([]byte(r.Header.Get("Authorization")))
+ exp := sha256.Sum256([]byte("Bearer " + want))
+ if subtle.ConstantTimeCompare(got[:], exp[:]) != 1 {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+ rep, err := read(host)
+ if err != nil {
+ http.Error(w, "update settings unavailable", http.StatusServiceUnavailable)
+ return
+ }
+ rep.Host = host
+ if rep.MayChange == nil {
+ rep.MayChange = []string{}
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(rep)
+ })
+}
+
+// errNoRoute is what RegistryReader answers for a host the registry has no
+// row for: the verb turns it into a 503, never a 200 with an empty report.
+var errNoRoute = errors.New("instanceupdates: no route for host")
+
+// RegistryReader answers from the box's registry copy: the settings column,
+// the installed build and the serving build. Root's desired build and reason
+// are added by plan 4's reader, which wraps this one.
+func RegistryReader(reg interface {
+ Get(host string) (*registry.Route, error)
+}) func(string) (Report, error) {
+ return func(host string) (Report, error) {
+ rt, err := reg.Get(host)
+ if err != nil {
+ return Report{}, err
+ }
+ if rt == nil {
+ return Report{}, errNoRoute
+ }
+ u, err := release.ParseUpdateSettings(rt.UpdateSettings)
+ if err != nil {
+ return Report{}, err
+ }
+ return Report{Settings: u, MayChange: u.Control, Installed: rt.Version, Serving: rt.ServingVersion}, nil
+ }
+}
diff --git a/internal/instanceupdates/control_test.go b/internal/instanceupdates/control_test.go
new file mode 100644
index 000000000..81eda8f26
--- /dev/null
+++ b/internal/instanceupdates/control_test.go
@@ -0,0 +1,190 @@
+package instanceupdates
+
+import (
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func serve(t *testing.T, token string) *httptest.Server {
+ t.Helper()
+ mux := http.NewServeMux()
+ Mount(mux, "a.example.com", func() (string, error) { return token, nil }, func(host string) (Report, error) {
+ return Report{Host: host, Settings: release.UpdateSettings{Updates: release.UpdatesManual}, MayChange: []string{"pin"}, Installed: "v1"}, nil
+ })
+ srv := httptest.NewServer(mux)
+ t.Cleanup(srv.Close)
+ return srv
+}
+
+func get(t *testing.T, url, bearer string) (*http.Response, Report) {
+ t.Helper()
+ req, _ := http.NewRequest(http.MethodGet, url, nil)
+ if bearer != "" {
+ req.Header.Set("Authorization", "Bearer "+bearer)
+ }
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer res.Body.Close()
+ var rep Report
+ _ = json.NewDecoder(res.Body).Decode(&rep)
+ return res, rep
+}
+
+func TestUpdatesNeedsTheBearer(t *testing.T) {
+ srv := serve(t, "secret")
+ if res, _ := get(t, srv.URL+"/updates", ""); res.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("no bearer: %d", res.StatusCode)
+ }
+ if res, _ := get(t, srv.URL+"/updates", "wrong"); res.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("wrong bearer: %d", res.StatusCode)
+ }
+ res, rep := get(t, srv.URL+"/updates", "secret")
+ if res.StatusCode != http.StatusOK || rep.Host != "a.example.com" || rep.Settings.Updates != release.UpdatesManual {
+ t.Fatalf("GET: %d %+v", res.StatusCode, rep)
+ }
+}
+
+func TestUpdatesIsReadOnlyAndFixedHost(t *testing.T) {
+ srv := serve(t, "secret")
+ req, _ := http.NewRequest(http.MethodPut, srv.URL+"/updates", nil)
+ req.Header.Set("Authorization", "Bearer secret")
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ res.Body.Close()
+ if res.StatusCode != http.StatusMethodNotAllowed {
+ t.Fatalf("PUT: %d, want 405", res.StatusCode)
+ }
+ if _, rep := get(t, srv.URL+"/updates?host=other.example.com", "secret"); rep.Host != "a.example.com" {
+ t.Fatal("the host came from the request")
+ }
+}
+
+func TestAnEmptyTokenRefusesEverything(t *testing.T) {
+ srv := serve(t, "")
+ if res, _ := get(t, srv.URL+"/updates", ""); res.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("empty token: %d", res.StatusCode)
+ }
+}
+
+// The token is read on every call: a rewritten env file takes effect on the
+// next request with no restart.
+func TestTheTokenIsReadOnEveryCall(t *testing.T) {
+ cur := "one"
+ mux := http.NewServeMux()
+ Mount(mux, "a.example.com", func() (string, error) { return cur, nil }, func(string) (Report, error) { return Report{}, nil })
+ srv := httptest.NewServer(mux)
+ t.Cleanup(srv.Close)
+ if res, _ := get(t, srv.URL+"/updates", "one"); res.StatusCode != http.StatusOK {
+ t.Fatalf("first token: %d", res.StatusCode)
+ }
+ cur = "two"
+ if res, _ := get(t, srv.URL+"/updates", "one"); res.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("old token after rewrite: %d", res.StatusCode)
+ }
+ if res, _ := get(t, srv.URL+"/updates", "two"); res.StatusCode != http.StatusOK {
+ t.Fatalf("new token: %d", res.StatusCode)
+ }
+}
+
+func TestATokenErrorOrAReaderErrorIsRefused(t *testing.T) {
+ mux := http.NewServeMux()
+ Mount(mux, "a.example.com", func() (string, error) { return "", errors.New("no env") }, func(string) (Report, error) { return Report{}, nil })
+ srv := httptest.NewServer(mux)
+ t.Cleanup(srv.Close)
+ if res, _ := get(t, srv.URL+"/updates", "x"); res.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("token error: %d", res.StatusCode)
+ }
+
+ mux2 := http.NewServeMux()
+ Mount(mux2, "a.example.com", func() (string, error) { return "s", nil }, func(string) (Report, error) { return Report{}, errors.New("boom") })
+ srv2 := httptest.NewServer(mux2)
+ t.Cleanup(srv2.Close)
+ if res, _ := get(t, srv2.URL+"/updates", "s"); res.StatusCode != http.StatusServiceUnavailable {
+ t.Fatalf("reader error: %d, want 503", res.StatusCode)
+ }
+}
+
+func TestMayChangeIsAnArrayNeverNull(t *testing.T) {
+ mux := http.NewServeMux()
+ Mount(mux, "a.example.com", func() (string, error) { return "s", nil }, func(string) (Report, error) { return Report{}, nil })
+ srv := httptest.NewServer(mux)
+ t.Cleanup(srv.Close)
+ req, _ := http.NewRequest(http.MethodGet, srv.URL+"/updates", nil)
+ req.Header.Set("Authorization", "Bearer s")
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer res.Body.Close()
+ var raw map[string]json.RawMessage
+ if err := json.NewDecoder(res.Body).Decode(&raw); err != nil {
+ t.Fatal(err)
+ }
+ if string(raw["may_change"]) != "[]" {
+ t.Fatalf("may_change = %s, want []", raw["may_change"])
+ }
+}
+
+type fakeReg struct {
+ rt *registry.Route
+ err error
+}
+
+func (f fakeReg) Get(string) (*registry.Route, error) { return f.rt, f.err }
+
+func TestRegistryReaderFillsFromTheRoute(t *testing.T) {
+ rt := &registry.Route{
+ Host: "a.example.com", Version: "v1.2.0", ServingVersion: "v1.1.0",
+ UpdateSettings: `{"updates":"manual","channel":"stable","pin":"v1.1.0","control":["pin"]}`,
+ }
+ rep, err := RegistryReader(fakeReg{rt: rt})("a.example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if rep.Installed != "v1.2.0" || rep.Serving != "v1.1.0" || rep.Settings.Updates != release.UpdatesManual ||
+ rep.Settings.Pin != "v1.1.0" || len(rep.MayChange) != 1 || rep.MayChange[0] != "pin" {
+ t.Fatalf("report: %+v", rep)
+ }
+ if rep.Desired != "" || rep.Reason != "" {
+ t.Fatalf("desired/reason must stay empty until plan 4: %+v", rep)
+ }
+}
+
+func TestRegistryReaderReadsAnEmptyColumnAsAuto(t *testing.T) {
+ rep, err := RegistryReader(fakeReg{rt: &registry.Route{Host: "a.example.com", Version: "v1"}})("a.example.com")
+ if err != nil || rep.Settings.Updates != release.UpdatesAuto || rep.Installed != "v1" {
+ t.Fatalf("%+v, %v", rep, err)
+ }
+}
+
+// A host the registry does not know, or cannot read, is an error: the verb
+// answers 503, never a 200 with an empty report.
+func TestRegistryReaderErrorsForNoRouteOrAReadError(t *testing.T) {
+ if _, err := RegistryReader(fakeReg{})("gone.example.com"); err == nil {
+ t.Fatal("nil route, nil error: want an error")
+ }
+ if _, err := RegistryReader(fakeReg{err: errors.New("db")})("a.example.com"); err == nil {
+ t.Fatal("read error: want an error")
+ }
+ if _, err := RegistryReader(fakeReg{rt: &registry.Route{UpdateSettings: `{"updates":"bogus"}`}})("a.example.com"); err == nil {
+ t.Fatal("corrupt column: want an error")
+ }
+
+ mux := http.NewServeMux()
+ Mount(mux, "gone.example.com", func() (string, error) { return "s", nil }, RegistryReader(fakeReg{}))
+ srv := httptest.NewServer(mux)
+ t.Cleanup(srv.Close)
+ if res, _ := get(t, srv.URL+"/updates", "s"); res.StatusCode != http.StatusServiceUnavailable {
+ t.Fatalf("unknown host over the wire: %d, want 503", res.StatusCode)
+ }
+}