console: instances API carries observed placement — served_by [{box, region}] joined from control/edges (#337)
Each instance in GET .../instances and GET .../instances/{host} now
carries an additive, omitempty served_by list: every box whose
control/edges/<box>.json names the host, with that box's AWS region.
That is the same file set the edges backhaul on, so it is what the
platform itself routes by. A list rather than a scalar because an
everywhere-class route is genuinely served on every box.
The record's existing region field (the REQUESTED placement code) is
untouched; this is the OBSERVED side. Unreadable, unversioned or
mis-keyed objects under the prefix cost only their own contribution.
Closes #337
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PEypt7de2X1G84aQ9rNkCf
3 files changed,
+213
−2
internal/console/apiinstances.go+23 −2internal/console/apiinstances_test.go+107 −0internal/console/instancesplacement.go+83 −0
diff --git a/internal/console/apiinstances.go b/internal/console/apiinstances.go| index 8139c4452..b1420d76d 100644 |
| --- a/internal/console/apiinstances.go |
| +++ b/internal/console/apiinstances.go |
| @@ -160,6 +160,14 @@ type instanceListRecord struct { |
| // record whose health could not be computed still lists, reading |
| // "Setting up" rather than failing the call. |
| Health string `json:"health"` |
| + // ServedBy is where this host is OBSERVED to be served (issue #337): |
| + // every box whose control/edges/<box>.json names it, with that box's AWS |
| + // region — the same files the edges backhaul on, so this is what the |
| + // platform itself routes by, not a guess. A list, not a scalar: an |
| + // everywhere-class route is genuinely served on every box. Distinct from |
| + // Record.Region, which is the REQUESTED placement code ("" = |
| + // unconstrained, "fleet/<name>"), and absent for a host no file names. |
| + ServedBy []servedBy `json:"served_by,omitempty"` |
| } |
| // instanceHealth computes each record host's health word the same way the |
| @@ -374,6 +382,7 @@ func (s *Server) handleAPIInstanceList(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| join := loadBoxJoin(ctx, s.cfg.Releases, time.Now()) |
| + served := loadEdgesJoin(ctx, s.cfg.Releases) |
| params := r.URL.Query() |
| q := parseInstanceQuery(params) |
| // The default stays the whole cap, so a client that sends no limit gets |
| @@ -399,7 +408,8 @@ func (s *Server) handleAPIInstanceList(w http.ResponseWriter, r *http.Request) { |
| continue |
| } |
| matched = append(matched, instanceListRecord{Record: rec, |
| - StagedVersion: join.versions[rec.Host], Health: h, Upstreams: join.upstreams[rec.Host]}) |
| + StagedVersion: join.versions[rec.Host], Health: h, Upstreams: join.upstreams[rec.Host], |
| + ServedBy: served[rec.Host]}) |
| } |
| total := len(matched) |
| // provision.ListRecords returns records host-sorted and nothing above |
| @@ -458,7 +468,18 @@ func (s *Server) handleAPIInstanceGet(w http.ResponseWriter, r *http.Request) { |
| jsonError(w, http.StatusBadGateway, "could not read the instance") |
| return |
| } |
| - writeJSON(w, http.StatusOK, rec) |
| + writeJSON(w, http.StatusOK, instanceGetRecord{Record: *rec, |
| + ServedBy: loadEdgesJoin(ctx, s.cfg.Releases)[host]}) |
| +} |
| + |
| +// instanceGetRecord is the single-record answer: the stored record plus the |
| +// same observed-placement join the list carries (issue #337). Only that — |
| +// the get has never carried staged_version/health, and growing it to the |
| +// list's full row is a separate decision from telling a consumer where one |
| +// instance lives. |
| +type instanceGetRecord struct { |
| + provision.Record |
| + ServedBy []servedBy `json:"served_by,omitempty"` |
| } |
| // handleAPIInstanceCreate is the point of the feature: one create-only |
diff --git a/internal/console/apiinstances_test.go b/internal/console/apiinstances_test.go| index 259ee6431..8870c16df 100644 |
| --- a/internal/console/apiinstances_test.go |
| +++ b/internal/console/apiinstances_test.go |
| @@ -2028,3 +2028,110 @@ func TestAPIInstanceCreateStaticAtOwnApex(t *testing.T) { |
| t.Fatalf("stored record = %+v, want no upstream and no unit", stored) |
| } |
| } |
| + |
| +// TestAPIInstanceListServedBy (issue #337): each instance carries where it |
| +// is OBSERVED to be served — every box whose control/edges/<box>.json names |
| +// the host, with that box's AWS region — as an additive `served_by` list on |
| +// both the list and the get. The record's own `region` (the REQUESTED |
| +// placement code) is untouched. A host no edges file names carries no key |
| +// at all; a garbage or unversioned object under the prefix is skipped, never |
| +// fatal; an everywhere-class host named by two boxes lists both, box-sorted. |
| +func TestAPIInstanceListServedBy(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-inst-served") |
| + enableInstances(t, fx, "titogo", titoManifest()) |
| + u := instancesURL(fx, "bab", "titogo") |
| + for _, h := range []string{"acme.go.tito.io", "beta.go.tito.io", "gamma.go.tito.io"} { |
| + if code, _, out := rawReq(t, http.MethodPost, u, token, |
| + fmt.Sprintf(`{"host":%q,"region":"ie"}`, h)); code != http.StatusCreated { |
| + t.Fatalf("create %s = %d %s", h, code, out) |
| + } |
| + } |
| + type served struct { |
| + Box string `json:"box"` |
| + Region string `json:"region"` |
| + } |
| + type row struct { |
| + Host string `json:"host"` |
| + Region string `json:"region"` |
| + ServedBy json.RawMessage `json:"served_by"` |
| + } |
| + decodeList := func(out string) map[string]row { |
| + var list struct { |
| + Instances []row `json:"instances"` |
| + } |
| + if err := json.Unmarshal([]byte(out), &list); err != nil { |
| + t.Fatalf("list body %s: %v", out, err) |
| + } |
| + got := map[string]row{} |
| + for _, r := range list.Instances { |
| + got[r.Host] = r |
| + } |
| + return got |
| + } |
| + |
| + // No edges file at all: the list serves, no record carries the key. |
| + code, _, out := rawReq(t, http.MethodGet, u, token, "") |
| + if code != http.StatusOK { |
| + t.Fatalf("list with no edges files = %d %s, want 200", code, out) |
| + } |
| + for h, r := range decodeList(out) { |
| + if r.ServedBy != nil { |
| + t.Errorf("%s served_by = %s before any edges file, want the key absent", h, r.ServedBy) |
| + } |
| + } |
| + |
| + put := func(key, body string) { |
| + t.Helper() |
| + if _, err := fx.rel.Put(context.Background(), key, []byte(body), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + put("control/edges/zed-box.json", `{"v":1,"box":"zed-box","eip":"1.2.3.4","region":"eu-west-2","generation":"g","hosts":{"acme.go.tito.io":{"role":"home"}}}`) |
| + put("control/edges/annie.json", `{"v":1,"box":"annie","eip":"5.6.7.8","region":"eu-west-1","generation":"g","hosts":{"acme.go.tito.io":{"role":"home"},"gamma.go.tito.io":{"role":"home"}}}`) |
| + put("control/edges/broken.json", `{not json`) |
| + put("control/edges/old.json", `{"box":"old","hosts":{"beta.go.tito.io":{"role":"home"}}}`) |
| + put("control/edges/nested/x.json", `{"v":1,"box":"nested","hosts":{"beta.go.tito.io":{"role":"home"}}}`) |
| + |
| + code, _, out = rawReq(t, http.MethodGet, u, token, "") |
| + if code != http.StatusOK { |
| + t.Fatalf("list with seeded edges files = %d %s, want 200", code, out) |
| + } |
| + got := decodeList(out) |
| + want := map[string][]served{ |
| + "acme.go.tito.io": {{"annie", "eu-west-1"}, {"zed-box", "eu-west-2"}}, |
| + "gamma.go.tito.io": {{"annie", "eu-west-1"}}, |
| + } |
| + for h, w := range want { |
| + var s []served |
| + if err := json.Unmarshal(got[h].ServedBy, &s); err != nil { |
| + t.Fatalf("%s served_by %s: %v", h, got[h].ServedBy, err) |
| + } |
| + if fmt.Sprint(s) != fmt.Sprint(w) { |
| + t.Errorf("%s served_by = %v, want %v", h, s, w) |
| + } |
| + if got[h].Region != "ie" { |
| + t.Errorf("%s region = %q, want the requested placement code ie untouched", h, got[h].Region) |
| + } |
| + } |
| + if got["beta.go.tito.io"].ServedBy != nil { |
| + t.Errorf("beta served_by = %s, want absent — only an unversioned and a mis-keyed file name it", got["beta.go.tito.io"].ServedBy) |
| + } |
| + |
| + // The single-record get carries the same join. |
| + code, _, out = rawReq(t, http.MethodGet, u+"/acme.go.tito.io", token, "") |
| + if code != http.StatusOK { |
| + t.Fatalf("get = %d %s", code, out) |
| + } |
| + var one row |
| + if err := json.Unmarshal([]byte(out), &one); err != nil { |
| + t.Fatalf("get body %s: %v", out, err) |
| + } |
| + var s []served |
| + if err := json.Unmarshal(one.ServedBy, &s); err != nil || fmt.Sprint(s) != fmt.Sprint(want["acme.go.tito.io"]) { |
| + t.Errorf("get served_by = %s (%v), want %v", one.ServedBy, err, want["acme.go.tito.io"]) |
| + } |
| + code, _, out = rawReq(t, http.MethodGet, u+"/beta.go.tito.io", token, "") |
| + if code != http.StatusOK || strings.Contains(out, "served_by") { |
| + t.Errorf("get beta = %d %s, want 200 with no served_by key", code, out) |
| + } |
| +} |
diff --git a/internal/console/instancesplacement.go b/internal/console/instancesplacement.go| new file mode 100644 |
| index 000000000..9ae896c8e |
| --- /dev/null |
| +++ b/internal/console/instancesplacement.go |
| @@ -0,0 +1,83 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "log" |
| + "sort" |
| + "strings" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// edgesFilePrefix is where every box publishes what it serves — one object |
| +// per box, control/edges/<box>.json, written by internal/host's edges-file |
| +// pass and read by every edge's home map. This is the third reader of that |
| +// prefix (internal/edge, internal/steering) and, like them, declares its own |
| +// view of the wire shape rather than sharing a type: the writer's schema |
| +// grows additively and each reader names only the fields it needs. |
| +const edgesFilePrefix = "control/edges/" |
| + |
| +// servedBy is one box that serves a host, as the instances API reports it |
| +// (issue #337). Region is the box's AWS region as the edges file carries it |
| +// (e.g. "eu-west-1") — the vocabulary a consumer fleet surface already shows |
| +// from its own IMDS read — and omitted when the file predates the field. |
| +type servedBy struct { |
| + Box string `json:"box"` |
| + Region string `json:"region,omitempty"` |
| +} |
| + |
| +// edgesFilePlacementView is the reader's half of the edges-file schema this |
| +// join needs: version, box, region, and the host set. Roles are not |
| +// consulted — a host a box names under ANY role is served there in some |
| +// capacity, and "where is this instance" is the question, not "who is its |
| +// write home" (that is the home map's question, internal/edge). |
| +type edgesFilePlacementView struct { |
| + V int `json:"v"` |
| + Box string `json:"box"` |
| + Region string `json:"region"` |
| + Hosts map[string]json.RawMessage `json:"hosts"` |
| +} |
| + |
| +// loadEdgesJoin folds every readable edges file into host → the boxes that |
| +// serve it, box-sorted so two consoles answer identically. Best-effort in |
| +// the same spirit as loadBoxJoin: a failed list, an unreadable object, a |
| +// parse failure or an unversioned file each cost only the answer that object |
| +// would have contributed — the instances themselves are the record store's |
| +// story and must never fail to list because a placement hint is missing. |
| +// Keys that are not <box>.json directly under the prefix are not read at |
| +// all, the same shape filter the other readers apply. |
| +func loadEdgesJoin(ctx context.Context, s release.Store) map[string][]servedBy { |
| + out := map[string][]servedBy{} |
| + if s == nil { |
| + return out |
| + } |
| + keys, err := s.List(ctx, edgesFilePrefix) |
| + if err != nil { |
| + log.Printf("console: api instances: could not list %s for the placement join: %v", edgesFilePrefix, err) |
| + return out |
| + } |
| + for _, key := range keys { |
| + name, ok := strings.CutPrefix(key, edgesFilePrefix) |
| + if !ok || !strings.HasSuffix(name, ".json") || strings.Contains(name, "/") { |
| + continue |
| + } |
| + body, _, err := s.Get(ctx, key) |
| + if err != nil { |
| + log.Printf("console: api instances: could not read %s for the placement join: %v", key, err) |
| + continue |
| + } |
| + var f edgesFilePlacementView |
| + if err := json.Unmarshal(body, &f); err != nil || f.V < 1 || f.Box == "" { |
| + log.Printf("console: api instances: skipping %s in the placement join: not a versioned edges file", key) |
| + continue |
| + } |
| + for host := range f.Hosts { |
| + out[host] = append(out[host], servedBy{Box: f.Box, Region: f.Region}) |
| + } |
| + } |
| + for host := range out { |
| + sort.Slice(out[host], func(i, j int) bool { return out[host][i].Box < out[host][j].Box }) |
| + } |
| + return out |
| +} |