carlos / platform Public

Merge pull request #341 from carlosframework/instances-served-by

instances API: served_by — observed per-instance placement from control/edges (#337)
Paul Campbell pushed by paul@keymail.dev 9ee32f6434ed6ea86bc15ce67423ff0d1ff4e900 parent c205415parent b739775
3 files changed, +213 −2
  • internal/console/apiinstances.go +23 −2
  • internal/console/apiinstances_test.go +107 −0
  • internal/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
+}