settings-3 task 9: read-only GET /updates on the control socket
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 files changed,
+425
−4
internal/activator/backend_exec.go+11 −2internal/activator/prepare.go+8 −2internal/activator/updates_control_test.go+126 −0internal/host/agent.go+4 −0internal/instanceupdates/control.go+86 −0internal/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 := ®istry.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: ®istry.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: ®istry.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) |
| + } |
| +} |