carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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.
11package fleetapi
12
13import (
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.
42const 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.
46type 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.
59func 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.
97func 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.
106func (s *store) objectURL(key string) string {
107 u := url.URL{Path: "/store/o/" + key}
108 return s.base + u.EscapedPath()
109}
110
111func (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.
117func (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.
133func (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).
160func (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
181var _ 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.
196func (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.
231func (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.
254func (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).
259func (s *store) ListDirs(ctx context.Context, prefix string) ([]string, error) {
260 return s.listDecode(ctx, "/store/dirs", prefix)
261}
262
263func (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