| 1 | // Package fleetapi is the client half of the fleet store door (spec |
| 2 | // 2026-08-11-fleet-api-surface-design.md, F5; Task 2's server is |
| 3 | // internal/edge/storedoor.go). It implements release.Store over the five |
| 4 | // HTTPS operations the door serves on the edge's reserved tunnel hostname — |
| 5 | // GET/PUT/DELETE /store/o/<key>, GET /store/list, GET /store/dirs — using the |
| 6 | // same two headers a tunnel dial already carries: "Authorization: Bearer |
| 7 | // <token>" and "Carlos-Tunnel-Name: <fleet>/<label>". A box that builds one |
| 8 | // of these needs no AWS credentials on the platform's deployment bucket at |
| 9 | // all: every loop that already speaks release.Store — adoption, fast |
| 10 | // convergence, config, status, metrics, bindings — runs unchanged against it. |
| 11 | package fleetapi |
| 12 | |
| 13 | import ( |
| 14 | "bytes" |
| 15 | "context" |
| 16 | "encoding/json" |
| 17 | "errors" |
| 18 | "fmt" |
| 19 | "io" |
| 20 | "net" |
| 21 | "net/http" |
| 22 | "net/url" |
| 23 | "os" |
| 24 | "strings" |
| 25 | "time" |
| 26 | |
| 27 | "github.com/carlosframework/platform/internal/release" |
| 28 | ) |
| 29 | |
| 30 | // callTimeout bounds connect, TLS handshake, and the wait for the response |
| 31 | // headers to start arriving — the "is the edge even alive and answering" |
| 32 | // phase of a call. It deliberately does NOT bound the rest of the exchange |
| 33 | // (reading/writing the body): Get is the artifact-download path (release |
| 34 | // adoption fetches whole app.tar.gz bodies through this store), and this |
| 35 | // spec's own target box class — home broadband, a Pi's uplink — can take |
| 36 | // well over 30s to pull a large artifact while still making steady |
| 37 | // progress. Putting this on http.Client.Timeout instead (a whole-exchange |
| 38 | // wall clock) would fail every such transfer deterministically, on every |
| 39 | // adoption pass, forever; S3Store has no equivalent cap, so this store must |
| 40 | // not invent a stricter one. A wedged edge that never starts answering still |
| 41 | // fails fast — that's what ResponseHeaderTimeout below is for. |
| 42 | const callTimeout = 30 * time.Second |
| 43 | |
| 44 | // store is the client half of the door: release.Store spoken over base's |
| 45 | // /store/ routes, authenticated by name/token on every request. |
| 46 | type store struct { |
| 47 | base string // origin, no trailing slash — e.g. "https://tunnel.example.com" |
| 48 | name string // Carlos-Tunnel-Name: "<fleet>/<label>" |
| 49 | token string // bearer token; NEVER placed in an error string (see errorf) |
| 50 | client *http.Client |
| 51 | } |
| 52 | |
| 53 | // New builds a release.Store speaking the edge's store door at base |
| 54 | // (CARLOS_TUNNEL_URL's origin) as the fleet channel identity name/token (the |
| 55 | // box's CARLOS_TUNNEL_NAME/_TOKEN). All three are required — there is no |
| 56 | // meaningful partial identity to fall back to, and a caller that got one of |
| 57 | // them empty has a config bug worth failing on at construction rather than |
| 58 | // on the first call. |
| 59 | func New(base, name, token string) (release.Store, error) { |
| 60 | if base == "" { |
| 61 | return nil, errors.New("fleetapi: base is required") |
| 62 | } |
| 63 | if name == "" { |
| 64 | return nil, errors.New("fleetapi: name is required") |
| 65 | } |
| 66 | if token == "" { |
| 67 | return nil, errors.New("fleetapi: token is required") |
| 68 | } |
| 69 | u, err := url.Parse(base) |
| 70 | if err != nil || u.Scheme == "" || u.Host == "" { |
| 71 | return nil, fmt.Errorf("fleetapi: invalid base %q: want an absolute URL", base) |
| 72 | } |
| 73 | return &store{ |
| 74 | base: strings.TrimSuffix(base, "/"), |
| 75 | name: name, |
| 76 | token: token, |
| 77 | // No Client.Timeout (see callTimeout's comment): only the connect/TLS/ |
| 78 | // response-header phase is bounded, so a slow-but-progressing artifact |
| 79 | // transfer can still finish. |
| 80 | client: &http.Client{Transport: &http.Transport{ |
| 81 | Proxy: http.ProxyFromEnvironment, |
| 82 | DialContext: (&net.Dialer{ |
| 83 | Timeout: callTimeout, |
| 84 | KeepAlive: 30 * time.Second, |
| 85 | }).DialContext, |
| 86 | TLSHandshakeTimeout: callTimeout, |
| 87 | ResponseHeaderTimeout: callTimeout, |
| 88 | ExpectContinueTimeout: 1 * time.Second, |
| 89 | }}, |
| 90 | }, nil |
| 91 | } |
| 92 | |
| 93 | // errorf builds a client-side error. Every call site here formats around |
| 94 | // the key/prefix and the response shape only — never s.token — so a leaked |
| 95 | // error string (a log line, a status page) can never hand back the bearer |
| 96 | // credential this store authenticates with. |
| 97 | func errorf(format string, args ...any) error { |
| 98 | return fmt.Errorf("fleetapi: "+format, args...) |
| 99 | } |
| 100 | |
| 101 | // objectURL builds the /store/o/<key> URL, percent-encoding key the same way |
| 102 | // any other path segment is encoded — the door decodes it back with |
| 103 | // net/http's ordinary request parsing (storedoor.go's canonicalKey comment: |
| 104 | // "already percent-decoded by net/http"), so this is the inverse of that, |
| 105 | // not a second canonicalization. |
| 106 | func (s *store) objectURL(key string) string { |
| 107 | u := url.URL{Path: "/store/o/" + key} |
| 108 | return s.base + u.EscapedPath() |
| 109 | } |
| 110 | |
| 111 | func (s *store) listURL(path, prefix string) string { |
| 112 | return s.base + path + "?" + url.Values{"prefix": {prefix}}.Encode() |
| 113 | } |
| 114 | |
| 115 | // newRequest attaches the two headers every door route requires (F1): the |
| 116 | // same bearer token and channel name a tunnel dial sends. |
| 117 | func (s *store) newRequest(ctx context.Context, method, rawURL string, body io.Reader) (*http.Request, error) { |
| 118 | req, err := http.NewRequestWithContext(ctx, method, rawURL, body) |
| 119 | if err != nil { |
| 120 | return nil, errorf("build request: %w", err) |
| 121 | } |
| 122 | req.Header.Set("Authorization", "Bearer "+s.token) |
| 123 | req.Header.Set("Carlos-Tunnel-Name", s.name) |
| 124 | return req, nil |
| 125 | } |
| 126 | |
| 127 | // Get fetches key and its etag. A 404 — which the door hands back |
| 128 | // byte-identically whether the key never existed, belongs to another |
| 129 | // tenant, or the caller's credential is bad (spec: no response distinguishes |
| 130 | // them) — is mapped uniformly onto release.Store's os.ErrNotExist contract: |
| 131 | // this client cannot tell those apart either, by design, and must not |
| 132 | // pretend otherwise. |
| 133 | func (s *store) Get(ctx context.Context, key string) ([]byte, string, error) { |
| 134 | req, err := s.newRequest(ctx, http.MethodGet, s.objectURL(key), nil) |
| 135 | if err != nil { |
| 136 | return nil, "", err |
| 137 | } |
| 138 | resp, err := s.client.Do(req) |
| 139 | if err != nil { |
| 140 | return nil, "", errorf("get %s: %w", key, err) |
| 141 | } |
| 142 | defer resp.Body.Close() |
| 143 | body, err := io.ReadAll(resp.Body) |
| 144 | if err != nil { |
| 145 | return nil, "", errorf("get %s: read body: %w", key, err) |
| 146 | } |
| 147 | switch resp.StatusCode { |
| 148 | case http.StatusOK: |
| 149 | return body, resp.Header.Get("ETag"), nil |
| 150 | case http.StatusNotFound: |
| 151 | return nil, "", fmt.Errorf("fleetapi: %s: %w", key, os.ErrNotExist) |
| 152 | default: |
| 153 | return nil, "", errorf("get %s: unexpected status %d", key, resp.StatusCode) |
| 154 | } |
| 155 | } |
| 156 | |
| 157 | // Open streams one object. Unlike Get it does not buffer the body, which |
| 158 | // is what a 25 MiB platform binary on a Pi needs; the 404 mapping is the |
| 159 | // same as Get's so callers can errors.Is(err, os.ErrNotExist). |
| 160 | func (s *store) Open(ctx context.Context, key string) (io.ReadCloser, string, error) { |
| 161 | req, err := s.newRequest(ctx, http.MethodGet, s.objectURL(key), nil) |
| 162 | if err != nil { |
| 163 | return nil, "", err |
| 164 | } |
| 165 | resp, err := s.client.Do(req) |
| 166 | if err != nil { |
| 167 | return nil, "", errorf("open %s: %w", key, err) |
| 168 | } |
| 169 | switch resp.StatusCode { |
| 170 | case http.StatusOK: |
| 171 | return resp.Body, resp.Header.Get("ETag"), nil |
| 172 | case http.StatusNotFound: |
| 173 | resp.Body.Close() |
| 174 | return nil, "", fmt.Errorf("fleetapi: %s: %w", key, os.ErrNotExist) |
| 175 | default: |
| 176 | resp.Body.Close() |
| 177 | return nil, "", errorf("open %s: unexpected status %d", key, resp.StatusCode) |
| 178 | } |
| 179 | } |
| 180 | |
| 181 | var _ release.Opener = (*store)(nil) |
| 182 | |
| 183 | // No release.PutStreamer here, deliberately: the console never writes |
| 184 | // through a fleet's store door, and streaming would need a server side in |
| 185 | // internal/edge/storedoor.go. release.PutObjectFrom falls back to Put for |
| 186 | // bodies up to PutFromFallbackMax (geoip spec §4.1). |
| 187 | |
| 188 | // Put mirrors the door's CAS mapping exactly (storedoor.go's |
| 189 | // putPrecondition / F4): ifMatch == "" sends If-None-Match: * (create-only, |
| 190 | // the contract's only form of an unconditional-looking write), a non-empty |
| 191 | // ifMatch sends If-Match: <etag> (replace-only). There is no unconditional |
| 192 | // Put on this wire, so this client never attempts one. A lost race (412) |
| 193 | // maps back to release.ErrCASConflict — the same error a direct DirStore or |
| 194 | // S3Store Put would return, so a heartbeat or binding-takeover CAS loop |
| 195 | // behaves identically whether or not it is going through the door. |
| 196 | func (s *store) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| 197 | req, err := s.newRequest(ctx, http.MethodPut, s.objectURL(key), bytes.NewReader(body)) |
| 198 | if err != nil { |
| 199 | return "", err |
| 200 | } |
| 201 | req.ContentLength = int64(len(body)) |
| 202 | if ifMatch == "" { |
| 203 | req.Header.Set("If-None-Match", "*") |
| 204 | } else { |
| 205 | req.Header.Set("If-Match", ifMatch) |
| 206 | } |
| 207 | resp, err := s.client.Do(req) |
| 208 | if err != nil { |
| 209 | return "", errorf("put %s: %w", key, err) |
| 210 | } |
| 211 | defer resp.Body.Close() |
| 212 | respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) |
| 213 | switch resp.StatusCode { |
| 214 | case http.StatusOK: |
| 215 | return resp.Header.Get("ETag"), nil |
| 216 | case http.StatusPreconditionFailed: |
| 217 | return "", fmt.Errorf("fleetapi: %s: %w", key, release.ErrCASConflict) |
| 218 | default: |
| 219 | return "", errorf("put %s: unexpected status %d: %s", key, resp.StatusCode, strings.TrimSpace(string(respBody))) |
| 220 | } |
| 221 | } |
| 222 | |
| 223 | // Delete is idempotent, matching release.Store's contract: the door |
| 224 | // (storedoor.go) always answers 204 for an in-scope key, deleted or not, |
| 225 | // which is where the idempotency lives — NOT in this client folding a 404 |
| 226 | // into success. A 404 here is only ever a refusal (bad token, bad name, an |
| 227 | // out-of-scope key, a malformed key, a misrouted base): it is never "already |
| 228 | // gone", because "already gone" is exactly the case the door answers 204. |
| 229 | // Treating it as success would make a mis-credentialled client report every |
| 230 | // delete as having worked. |
| 231 | func (s *store) Delete(ctx context.Context, key string) error { |
| 232 | req, err := s.newRequest(ctx, http.MethodDelete, s.objectURL(key), nil) |
| 233 | if err != nil { |
| 234 | return err |
| 235 | } |
| 236 | resp, err := s.client.Do(req) |
| 237 | if err != nil { |
| 238 | return errorf("delete %s: %w", key, err) |
| 239 | } |
| 240 | defer resp.Body.Close() |
| 241 | respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) |
| 242 | if resp.StatusCode != http.StatusNoContent { |
| 243 | return errorf("delete %s: unexpected status %d: %s", key, resp.StatusCode, strings.TrimSpace(string(respBody))) |
| 244 | } |
| 245 | return nil |
| 246 | } |
| 247 | |
| 248 | // List is the door's /store/list — see listDecode. Note the spec's callout: |
| 249 | // the door 404s a root listing (List("")) the same as any other out-of-scope |
| 250 | // prefix (aclListScope refuses an empty prefix outright), and this client |
| 251 | // does not special-case it: an empty-prefix caller gets exactly that error, |
| 252 | // same as any other caller would over a direct S3Store asking for a prefix |
| 253 | // its bucket policy refuses. |
| 254 | func (s *store) List(ctx context.Context, prefix string) ([]string, error) { |
| 255 | return s.listDecode(ctx, "/store/list", prefix) |
| 256 | } |
| 257 | |
| 258 | // ListDirs is the door's /store/dirs (the delimiter-aware listing). |
| 259 | func (s *store) ListDirs(ctx context.Context, prefix string) ([]string, error) { |
| 260 | return s.listDecode(ctx, "/store/dirs", prefix) |
| 261 | } |
| 262 | |
| 263 | func (s *store) listDecode(ctx context.Context, path, prefix string) ([]string, error) { |
| 264 | req, err := s.newRequest(ctx, http.MethodGet, s.listURL(path, prefix), nil) |
| 265 | if err != nil { |
| 266 | return nil, err |
| 267 | } |
| 268 | resp, err := s.client.Do(req) |
| 269 | if err != nil { |
| 270 | return nil, errorf("list %s: %w", prefix, err) |
| 271 | } |
| 272 | defer resp.Body.Close() |
| 273 | if resp.StatusCode != http.StatusOK { |
| 274 | respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) |
| 275 | return nil, errorf("list %s: unexpected status %d: %s", prefix, resp.StatusCode, strings.TrimSpace(string(respBody))) |
| 276 | } |
| 277 | // The door never sends null (storedoor.go: "an empty listing is []"), but |
| 278 | // decoding into a nil slice handles either shape the same way a caller |
| 279 | // ranging over the result cares about: zero iterations. |
| 280 | var out []string |
| 281 | if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { |
| 282 | return nil, errorf("list %s: decode response: %w", prefix, err) |
| 283 | } |
| 284 | return out, nil |
| 285 | } |
| 286 | |