carlos / platform Public

fleetapi: release.Store over the edge store door

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell a87565537c5b5d97f549a4d3b17d723116ee28a5 parent a22971e
2 files changed, +572 −0
  • internal/fleetapi/fleetapi.go +234 −0
  • internal/fleetapi/fleetapi_test.go +338 −0
diff --git a/internal/fleetapi/fleetapi.go b/internal/fleetapi/fleetapi.go
new file mode 100644
index 000000000..dd1baddb6
--- /dev/null
+++ b/internal/fleetapi/fleetapi.go
@@ -0,0 +1,234 @@
+// Package fleetapi is the client half of the fleet store door (spec
+// 2026-08-11-fleet-api-surface-design.md, F5; Task 2's server is
+// internal/edge/storedoor.go). It implements release.Store over the five
+// HTTPS operations the door serves on the edge's reserved tunnel hostname —
+// GET/PUT/DELETE /store/o/<key>, GET /store/list, GET /store/dirs — using the
+// same two headers a tunnel dial already carries: "Authorization: Bearer
+// <token>" and "Carlos-Tunnel-Name: <fleet>/<label>". A box that builds one
+// of these needs no AWS credentials on the platform's deployment bucket at
+// all: every loop that already speaks release.Store — adoption, fast
+// convergence, config, status, metrics, bindings — runs unchanged against it.
+package fleetapi
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// callTimeout bounds every single HTTP call this store makes (spec: 30s per
+// call). It is set on the *http.Client itself rather than derived from the
+// caller's context, so a call this box makes with no context deadline of its
+// own still cannot hang forever against a wedged edge.
+const callTimeout = 30 * time.Second
+
+// store is the client half of the door: release.Store spoken over base's
+// /store/ routes, authenticated by name/token on every request.
+type store struct {
+ base string // origin, no trailing slash — e.g. "https://tunnel.example.com"
+ name string // Carlos-Tunnel-Name: "<fleet>/<label>"
+ token string // bearer token; NEVER placed in an error string (see errorf)
+ client *http.Client
+}
+
+// New builds a release.Store speaking the edge's store door at base
+// (CARLOS_TUNNEL_URL's origin) as the fleet channel identity name/token (the
+// box's CARLOS_TUNNEL_NAME/_TOKEN). All three are required — there is no
+// meaningful partial identity to fall back to, and a caller that got one of
+// them empty has a config bug worth failing on at construction rather than
+// on the first call.
+func New(base, name, token string) (release.Store, error) {
+ if base == "" {
+ return nil, errors.New("fleetapi: base is required")
+ }
+ if name == "" {
+ return nil, errors.New("fleetapi: name is required")
+ }
+ if token == "" {
+ return nil, errors.New("fleetapi: token is required")
+ }
+ u, err := url.Parse(base)
+ if err != nil || u.Scheme == "" || u.Host == "" {
+ return nil, fmt.Errorf("fleetapi: invalid base %q: want an absolute URL", base)
+ }
+ return &store{
+ base: strings.TrimSuffix(base, "/"),
+ name: name,
+ token: token,
+ client: &http.Client{Timeout: callTimeout},
+ }, nil
+}
+
+// errorf builds a client-side error. Every call site here formats around
+// the key/prefix and the response shape only — never s.token — so a leaked
+// error string (a log line, a status page) can never hand back the bearer
+// credential this store authenticates with.
+func errorf(format string, args ...any) error {
+ return fmt.Errorf("fleetapi: "+format, args...)
+}
+
+// objectURL builds the /store/o/<key> URL, percent-encoding key the same way
+// any other path segment is encoded — the door decodes it back with
+// net/http's ordinary request parsing (storedoor.go's canonicalKey comment:
+// "already percent-decoded by net/http"), so this is the inverse of that,
+// not a second canonicalization.
+func (s *store) objectURL(key string) string {
+ u := url.URL{Path: "/store/o/" + key}
+ return s.base + u.EscapedPath()
+}
+
+func (s *store) listURL(path, prefix string) string {
+ return s.base + path + "?" + url.Values{"prefix": {prefix}}.Encode()
+}
+
+// newRequest attaches the two headers every door route requires (F1): the
+// same bearer token and channel name a tunnel dial sends.
+func (s *store) newRequest(ctx context.Context, method, rawURL string, body io.Reader) (*http.Request, error) {
+ req, err := http.NewRequestWithContext(ctx, method, rawURL, body)
+ if err != nil {
+ return nil, errorf("build request: %w", err)
+ }
+ req.Header.Set("Authorization", "Bearer "+s.token)
+ req.Header.Set("Carlos-Tunnel-Name", s.name)
+ return req, nil
+}
+
+// Get fetches key and its etag. A 404 — which the door hands back
+// byte-identically whether the key never existed, belongs to another
+// tenant, or the caller's credential is bad (spec: no response distinguishes
+// them) — is mapped uniformly onto release.Store's os.ErrNotExist contract:
+// this client cannot tell those apart either, by design, and must not
+// pretend otherwise.
+func (s *store) Get(ctx context.Context, key string) ([]byte, string, error) {
+ req, err := s.newRequest(ctx, http.MethodGet, s.objectURL(key), nil)
+ if err != nil {
+ return nil, "", err
+ }
+ resp, err := s.client.Do(req)
+ if err != nil {
+ return nil, "", errorf("get %s: %w", key, err)
+ }
+ defer resp.Body.Close()
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, "", errorf("get %s: read body: %w", key, err)
+ }
+ switch resp.StatusCode {
+ case http.StatusOK:
+ return body, resp.Header.Get("ETag"), nil
+ case http.StatusNotFound:
+ return nil, "", fmt.Errorf("fleetapi: %s: %w", key, os.ErrNotExist)
+ default:
+ return nil, "", errorf("get %s: unexpected status %d", key, resp.StatusCode)
+ }
+}
+
+// Put mirrors the door's CAS mapping exactly (storedoor.go's
+// putPrecondition / F4): ifMatch == "" sends If-None-Match: * (create-only,
+// the contract's only form of an unconditional-looking write), a non-empty
+// ifMatch sends If-Match: <etag> (replace-only). There is no unconditional
+// Put on this wire, so this client never attempts one. A lost race (412)
+// maps back to release.ErrCASConflict — the same error a direct DirStore or
+// S3Store Put would return, so a heartbeat or binding-takeover CAS loop
+// behaves identically whether or not it is going through the door.
+func (s *store) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
+ req, err := s.newRequest(ctx, http.MethodPut, s.objectURL(key), bytes.NewReader(body))
+ if err != nil {
+ return "", err
+ }
+ req.ContentLength = int64(len(body))
+ if ifMatch == "" {
+ req.Header.Set("If-None-Match", "*")
+ } else {
+ req.Header.Set("If-Match", ifMatch)
+ }
+ resp, err := s.client.Do(req)
+ if err != nil {
+ return "", errorf("put %s: %w", key, err)
+ }
+ defer resp.Body.Close()
+ respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
+ switch resp.StatusCode {
+ case http.StatusOK:
+ return resp.Header.Get("ETag"), nil
+ case http.StatusPreconditionFailed:
+ return "", fmt.Errorf("fleetapi: %s: %w", key, release.ErrCASConflict)
+ default:
+ return "", errorf("put %s: unexpected status %d: %s", key, resp.StatusCode, strings.TrimSpace(string(respBody)))
+ }
+}
+
+// Delete is idempotent, matching release.Store's contract (and the door's:
+// storedoor.go always answers 204 for an in-scope key, deleted or not). A
+// 404 is folded into success too: the door's opacity means "out of scope"
+// and "already gone" are the SAME response by design (spec's central
+// security invariant), and the Store contract only ever asks Delete to be
+// idempotent — it has no notion of "delete refused", so there is nothing
+// more honest to report here than success.
+func (s *store) Delete(ctx context.Context, key string) error {
+ req, err := s.newRequest(ctx, http.MethodDelete, s.objectURL(key), nil)
+ if err != nil {
+ return err
+ }
+ resp, err := s.client.Do(req)
+ if err != nil {
+ return errorf("delete %s: %w", key, err)
+ }
+ defer resp.Body.Close()
+ io.Copy(io.Discard, resp.Body) //nolint:errcheck
+ switch resp.StatusCode {
+ case http.StatusNoContent, http.StatusNotFound:
+ return nil
+ default:
+ return errorf("delete %s: unexpected status %d", key, resp.StatusCode)
+ }
+}
+
+// List is the door's /store/list — see listDecode. Note the spec's callout:
+// the door 404s a root listing (List("")) the same as any other out-of-scope
+// prefix (aclListScope refuses an empty prefix outright), and this client
+// does not special-case it: an empty-prefix caller gets exactly that error,
+// same as any other caller would over a direct S3Store asking for a prefix
+// its bucket policy refuses.
+func (s *store) List(ctx context.Context, prefix string) ([]string, error) {
+ return s.listDecode(ctx, "/store/list", prefix)
+}
+
+// ListDirs is the door's /store/dirs (the delimiter-aware listing).
+func (s *store) ListDirs(ctx context.Context, prefix string) ([]string, error) {
+ return s.listDecode(ctx, "/store/dirs", prefix)
+}
+
+func (s *store) listDecode(ctx context.Context, path, prefix string) ([]string, error) {
+ req, err := s.newRequest(ctx, http.MethodGet, s.listURL(path, prefix), nil)
+ if err != nil {
+ return nil, err
+ }
+ resp, err := s.client.Do(req)
+ if err != nil {
+ return nil, errorf("list %s: %w", prefix, err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
+ return nil, errorf("list %s: unexpected status %d: %s", prefix, resp.StatusCode, strings.TrimSpace(string(respBody)))
+ }
+ // The door never sends null (storedoor.go: "an empty listing is []"), but
+ // decoding into a nil slice handles either shape the same way a caller
+ // ranging over the result cares about: zero iterations.
+ var out []string
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
+ return nil, errorf("list %s: decode response: %w", prefix, err)
+ }
+ return out, nil
+}
diff --git a/internal/fleetapi/fleetapi_test.go b/internal/fleetapi/fleetapi_test.go
new file mode 100644
index 000000000..1584a0bcc
--- /dev/null
+++ b/internal/fleetapi/fleetapi_test.go
@@ -0,0 +1,338 @@
+package fleetapi_test
+
+// Round-trip tests against the REAL store door (internal/edge/storedoor.go,
+// Task 2) over a real net.Listener + release.DirStore — no hand-rolled fake
+// speaks for the wire here. The door is reached by wiring a full edge.Edge
+// the same way production does (registry fleet/box rows, EnableStatic,
+// EnableTunnels, Reload) and pointing the hub's reserved hostname at the
+// listener's own address, so an ordinary HTTP client naturally sends the
+// Host header the door's routing (edge.go: r.Host == hub.Host(), port
+// stripped) requires — no Host-header override needed, the same shape a real
+// box dialing CARLOS_TUNNEL_URL would produce.
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "net"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/edge"
+ "github.com/carlosframework/platform/internal/fleetapi"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+const (
+ testSqid = "ab12"
+ testFleet = "pi"
+ testLabel = "box-a"
+ testToken = "box-a-bearer-token-abc123"
+)
+
+func sha256Hex(s string) string {
+ sum := sha256.Sum256([]byte(s))
+ return hex.EncodeToString(sum[:])
+}
+
+// testDoor wires a real edge.Edge with the store door armed, over a plain
+// net.Listener whose bound IP becomes the hub's reserved hostname — the
+// production shape (EnableStatic + EnableTunnels + Reload), minus TLS, which
+// the door itself has no opinion about (edge.go terminates that separately).
+type testDoor struct {
+ base string
+ store *release.DirStore
+}
+
+func newTestDoor(t *testing.T) testDoor {
+ t.Helper()
+ reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatalf("registry.Open: %v", err)
+ }
+ if err := reg.UpsertFleet(registry.Fleet{Name: testFleet, Account: testSqid, Bucket: "pi-data"}); err != nil {
+ t.Fatalf("UpsertFleet: %v", err)
+ }
+ if err := reg.AddFleetBox(registry.FleetBox{
+ Fleet: testFleet, Label: testLabel, TokenHash: sha256Hex(testToken), Status: "active",
+ }); err != nil {
+ t.Fatalf("AddFleetBox: %v", err)
+ }
+
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatalf("listen: %v", err)
+ }
+ t.Cleanup(func() { ln.Close() })
+ host := ln.Addr().(*net.TCPAddr).IP.String() // "127.0.0.1" — edge.go strips the port before comparing
+
+ e, err := edge.New(reg, nil)
+ if err != nil {
+ t.Fatalf("edge.New: %v", err)
+ }
+ dirStore := release.NewDirStore(t.TempDir())
+ e.EnableStatic(dirStore, nil, filepath.Join(t.TempDir(), "cache"))
+ e.EnableTunnels(edge.NewTunnelHub(host, nil))
+ if err := e.Reload(); err != nil {
+ t.Fatalf("Reload: %v", err)
+ }
+
+ srv := &http.Server{Handler: e}
+ go srv.Serve(ln) //nolint:errcheck
+ t.Cleanup(func() { srv.Close() })
+
+ return testDoor{base: "http://" + ln.Addr().String(), store: dirStore}
+}
+
+func (d testDoor) client(t *testing.T, name, token string) release.Store {
+ t.Helper()
+ s, err := fleetapi.New(d.base, name, token)
+ if err != nil {
+ t.Fatalf("fleetapi.New: %v", err)
+ }
+ return s
+}
+
+func (d testDoor) box(t *testing.T) release.Store {
+ return d.client(t, testFleet+"/"+testLabel, testToken)
+}
+
+func seed(t *testing.T, s *release.DirStore, key, body string) string {
+ t.Helper()
+ etag, err := s.Put(context.Background(), key, []byte(body), "")
+ if err != nil {
+ t.Fatalf("seed %s: %v", key, err)
+ }
+ return etag
+}
+
+// ---- New() argument validation --------------------------------------------
+
+func TestNewRefusesEmptyArgs(t *testing.T) {
+ cases := []struct{ base, name, token string }{
+ {"", "pi/box-a", "tok"},
+ {"https://tunnel.example.com", "", "tok"},
+ {"https://tunnel.example.com", "pi/box-a", ""},
+ {"not-a-url", "pi/box-a", "tok"},
+ }
+ for _, c := range cases {
+ if s, err := fleetapi.New(c.base, c.name, c.token); err == nil {
+ t.Errorf("New(%q, %q, %q) = %v, nil; want an error", c.base, c.name, c.token, s)
+ }
+ }
+}
+
+// ---- Get / Put CAS round trip ----------------------------------------------
+
+func TestGetPutCASRoundTrip(t *testing.T) {
+ d := newTestDoor(t)
+ s := d.box(t)
+ ctx := context.Background()
+ key := "fleets/" + testFleet + "/status/boxes/" + testLabel + ".json"
+
+ // Create-only (ifMatch == "" -> If-None-Match: *).
+ etag1, err := s.Put(ctx, key, []byte(`{"n":1}`), "")
+ if err != nil {
+ t.Fatalf("create-only Put: %v", err)
+ }
+ if etag1 == "" {
+ t.Fatal("create-only Put returned no etag")
+ }
+
+ // Round-trip Get.
+ body, gotEtag, err := s.Get(ctx, key)
+ if err != nil {
+ t.Fatalf("Get: %v", err)
+ }
+ if string(body) != `{"n":1}` {
+ t.Errorf("body = %q, want the stored object", body)
+ }
+ if gotEtag != etag1 {
+ t.Errorf("etag = %q, want %q (etags are opaque, passed through unmodified)", gotEtag, etag1)
+ }
+
+ // Create-only again over an existing object: CAS conflict.
+ if _, err := s.Put(ctx, key, []byte("again"), ""); !errors.Is(err, release.ErrCASConflict) {
+ t.Errorf("create-only Put over existing object: err = %v, want release.ErrCASConflict", err)
+ }
+
+ // Replace with a stale etag: CAS conflict.
+ if _, err := s.Put(ctx, key, []byte("stale"), "0000000000000000000000000000000000000000000000000000000000000000"); !errors.Is(err, release.ErrCASConflict) {
+ t.Errorf("replace with stale etag: err = %v, want release.ErrCASConflict", err)
+ }
+
+ // Replace with the fresh etag: succeeds, new etag differs.
+ etag2, err := s.Put(ctx, key, []byte(`{"n":2}`), gotEtag)
+ if err != nil {
+ t.Fatalf("replace Put: %v", err)
+ }
+ if etag2 == "" || etag2 == etag1 {
+ t.Errorf("replace etag = %q, want a new etag (was %q)", etag2, etag1)
+ }
+ body, _, err = s.Get(ctx, key)
+ if err != nil || string(body) != `{"n":2}` {
+ t.Fatalf("stored body = %q, %v; want {\"n\":2}", body, err)
+ }
+}
+
+// ---- Get missing --------------------------------------------------------
+
+func TestGetMissingIsNotExist(t *testing.T) {
+ d := newTestDoor(t)
+ s := d.box(t)
+ _, _, err := s.Get(context.Background(), "fleets/"+testFleet+"/status/boxes/nosuchbox.json")
+ if !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("Get missing in-scope key: err = %v, want errors.Is(err, os.ErrNotExist)", err)
+ }
+}
+
+// ---- List / ListDirs ------------------------------------------------------
+
+func TestListAndListDirsRoundTrip(t *testing.T) {
+ d := newTestDoor(t)
+ seed(t, d.store, "apps/"+testSqid+"/web/channels/stable.json", "{}")
+ seed(t, d.store, "apps/"+testSqid+"/web/releases/v1/manifest.json", "{}")
+ seed(t, d.store, "apps/zz99/web/channels/stable.json", "someone else's")
+ s := d.box(t)
+ ctx := context.Background()
+
+ keys, err := s.List(ctx, "apps/")
+ if err != nil {
+ t.Fatalf("List: %v", err)
+ }
+ want := []string{"apps/" + testSqid + "/web/channels/stable.json", "apps/" + testSqid + "/web/releases/v1/manifest.json"}
+ if !equalUnordered(keys, want) {
+ t.Errorf("List(apps/) = %v, want %v (own account only)", keys, want)
+ }
+
+ dirs, err := s.ListDirs(ctx, "apps/")
+ if err != nil {
+ t.Fatalf("ListDirs: %v", err)
+ }
+ if !equalUnordered(dirs, []string{"apps/" + testSqid + "/web/"}) {
+ t.Errorf("ListDirs(apps/) = %v, want [apps/%s/web/]", dirs, testSqid)
+ }
+
+ // An empty listing is [] on the wire, never null, and this client must
+ // decode that into a zero-length (possibly nil) slice a caller can range
+ // over without a nil check.
+ empty, err := s.List(ctx, "apps/"+testSqid+"/web/nosuchapp/")
+ if err != nil {
+ t.Fatalf("List (empty): %v", err)
+ }
+ if len(empty) != 0 {
+ t.Errorf("List of an empty prefix = %v, want empty", empty)
+ }
+
+ // The door 404s a root listing (aclListScope refuses prefix == "") —
+ // this client does not special-case it, so List("") surfaces as a plain
+ // error, not success and not the not-found mapping Get uses.
+ if _, err := s.List(ctx, ""); err == nil {
+ t.Error("List(\"\") = nil error, want the door's refusal to surface")
+ } else if errors.Is(err, os.ErrNotExist) {
+ t.Errorf("List(\"\") = %v; List has no not-found mapping (only Get does)", err)
+ }
+}
+
+func equalUnordered(got, want []string) bool {
+ if len(got) != len(want) {
+ return false
+ }
+ seen := map[string]int{}
+ for _, g := range got {
+ seen[g]++
+ }
+ for _, w := range want {
+ if seen[w] == 0 {
+ return false
+ }
+ seen[w]--
+ }
+ return true
+}
+
+// ---- Delete -----------------------------------------------------------
+
+func TestDeleteIsIdempotent(t *testing.T) {
+ d := newTestDoor(t)
+ key := "fleets/" + testFleet + "/metrics/boxes/" + testLabel + "/raw/1699.json"
+ seed(t, d.store, key, "{}")
+ s := d.box(t)
+ ctx := context.Background()
+
+ for i := 0; i < 2; i++ {
+ if err := s.Delete(ctx, key); err != nil {
+ t.Fatalf("Delete (attempt %d): %v", i+1, err)
+ }
+ }
+ if _, _, err := s.Get(ctx, key); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("Get after Delete: err = %v, want os.ErrNotExist", err)
+ }
+}
+
+// ---- Auth failure is opaque, matching the door -----------------------
+
+// TestAuthFailureSurfacesAsNotFound pins the door's central opacity
+// invariant on the client side: a bad token answers 404, indistinguishable
+// from a missing key, so this client's Get must map it the same way — never
+// a distinct "auth failed" error a caller could branch on to learn it has a
+// bad credential versus a bad key.
+func TestAuthFailureSurfacesAsNotFound(t *testing.T) {
+ d := newTestDoor(t)
+ key := "fleets/" + testFleet + "/status/boxes/" + testLabel + ".json"
+ seed(t, d.store, key, "{}")
+
+ wrongToken := d.client(t, testFleet+"/"+testLabel, "not-the-real-token")
+ if _, _, err := wrongToken.Get(context.Background(), key); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("Get with wrong token: err = %v, want os.ErrNotExist (opaque, like a missing key)", err)
+ }
+
+ unknownFleet := d.client(t, "nosuch/box", testToken)
+ if _, _, err := unknownFleet.Get(context.Background(), key); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("Get from an unregistered fleet: err = %v, want os.ErrNotExist", err)
+ }
+}
+
+// ---- token never leaks into an error string ----------------------------
+
+// TestTokenNeverInErrorStrings exercises every failure path this client can
+// produce against a live door and checks none of them echo the bearer
+// token — the credential itself, not just its hash — into the returned
+// error's text.
+func TestTokenNeverInErrorStrings(t *testing.T) {
+ d := newTestDoor(t)
+ key := "fleets/" + testFleet + "/status/boxes/" + testLabel + ".json"
+ seed(t, d.store, key, "{}")
+ ctx := context.Background()
+
+ wrongToken := d.client(t, testFleet+"/"+testLabel, "not-the-real-token")
+ _, _, getErr := wrongToken.Get(ctx, key)
+
+ s := d.box(t)
+ _, putErr := s.Put(ctx, key, []byte("x"), "") // CAS conflict: already exists
+ _, _, missingErr := s.Get(ctx, "fleets/"+testFleet+"/status/boxes/nosuchbox.json")
+ _, listErr := s.List(ctx, "")
+
+ for name, err := range map[string]error{
+ "wrong-token get": getErr,
+ "cas conflict put": putErr,
+ "missing get": missingErr,
+ "refused list": listErr,
+ } {
+ if err == nil {
+ t.Errorf("%s: no error to check (expected one)", name)
+ continue
+ }
+ if strings.Contains(err.Error(), testToken) {
+ t.Errorf("%s: error %q contains the bearer token", name, err.Error())
+ }
+ if strings.Contains(err.Error(), "not-the-real-token") {
+ t.Errorf("%s: error %q contains the wrong-token value", name, err.Error())
+ }
+ }
+}