carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package host
2
3import (
4 "bytes"
5 "context"
6 "log"
7 "net/http"
8 "net/http/httptest"
9 "path/filepath"
10 "strings"
11 "sync"
12 "testing"
13 "time"
14
15 "github.com/carlosframework/platform/internal/analytics"
16 "github.com/carlosframework/platform/internal/apperr"
17 "github.com/carlosframework/platform/internal/applog"
18 "github.com/carlosframework/platform/internal/edge"
19 "github.com/carlosframework/platform/internal/registry"
20 "github.com/carlosframework/platform/internal/release"
21 "github.com/carlosframework/platform/internal/usage"
22)
23
24// keyLog records every key a shipper touches, so a test can see a key that
25// a directory-backed store would have quietly normalised ("a//b" is "a/b"
26// on disk).
27type keyLog struct {
28 release.Store
29 mu sync.Mutex
30 touched []string
31}
32
33func (k *keyLog) note(key string) {
34 k.mu.Lock()
35 k.touched = append(k.touched, key)
36 k.mu.Unlock()
37}
38
39func (k *keyLog) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
40 k.note("put " + key)
41 return k.Store.Put(ctx, key, body, ifMatch)
42}
43
44func (k *keyLog) Get(ctx context.Context, key string) ([]byte, string, error) {
45 k.note("get " + key)
46 return k.Store.Get(ctx, key)
47}
48
49func (k *keyLog) List(ctx context.Context, prefix string) ([]string, error) {
50 k.note("list " + prefix)
51 return k.Store.List(ctx, prefix)
52}
53
54func (k *keyLog) ListDirs(ctx context.Context, prefix string) ([]string, error) {
55 k.note("listdirs " + prefix)
56 return k.Store.ListDirs(ctx, prefix)
57}
58
59func (k *keyLog) Delete(ctx context.Context, key string) error {
60 k.note("delete " + key)
61 return k.Store.Delete(ctx, key)
62}
63
64func (k *keyLog) all() []string {
65 k.mu.Lock()
66 defer k.mu.Unlock()
67 return append([]string(nil), k.touched...)
68}
69
70// captureShipLog swaps the standard logger's output for the test and forgets
71// which apps unshippable has reported, so its once-only line is observable.
72func captureShipLog(t *testing.T) *bytes.Buffer {
73 t.Helper()
74 unshippableSeen.Range(func(k, _ any) bool { unshippableSeen.Delete(k); return true })
75 var buf bytes.Buffer
76 prev, flags := log.Writer(), log.Flags()
77 log.SetOutput(&buf)
78 t.Cleanup(func() { log.SetOutput(prev); log.SetFlags(flags) })
79 return &buf
80}
81
82// TestErrshipAppLessRouteWritesNothing is the dev-bucket regression end to
83// end. The route is the shape found on leo-climbing: an instance row with
84// an account, no app, and a TCP upstream nobody listens on. Every request
85// is a refused 502 that the edge files under (bab, ""). The errors shipper
86// must write nothing for it (it once wrote apps/bab//errors/...), prune
87// nothing, keep shipping the app beside it, and say so once.
88func TestErrshipAppLessRouteWritesNothing(t *testing.T) {
89 dead := httptest.NewServer(http.NotFoundHandler())
90 deadAddr := dead.Listener.Addr().String()
91 dead.Close() // nothing listens here now: every dial is refused
92
93 reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
94 if err != nil {
95 t.Fatal(err)
96 }
97 for _, rt := range []registry.Route{
98 {Host: "proof.example.com", Kind: "instance", Account: "bab", App: "", Addr: deadAddr, Status: "ready", Channel: "stable"},
99 {Host: "web.example.com", Kind: "service", Account: "bab", App: "web", Addr: deadAddr, Status: "ready", Version: "v1"},
100 } {
101 if err := reg.Upsert(rt); err != nil {
102 t.Fatal(err)
103 }
104 }
105 e, err := edge.New(reg, nil)
106 if err != nil {
107 t.Fatal(err)
108 }
109 sink := apperr.NewSink("leo-climbing", time.Now)
110 e.EnableErrors(sink)
111 srv := httptest.NewServer(e)
112 t.Cleanup(srv.Close)
113 get := func(host string) {
114 t.Helper()
115 req, _ := http.NewRequest("GET", srv.URL+"/", nil)
116 req.Host = host
117 resp, err := http.DefaultClient.Do(req)
118 if err != nil {
119 t.Fatal(err)
120 }
121 resp.Body.Close()
122 if resp.StatusCode != http.StatusBadGateway {
123 t.Fatalf("%s answered %d, want a refused 502", host, resp.StatusCode)
124 }
125 }
126
127 logs := captureShipLog(t)
128 ctx := context.Background()
129 s := &keyLog{Store: release.NewDirStore(t.TempDir())}
130 pending := map[applog.AppKey][]apperr.Record{}
131 for round := 0; round < 2; round++ {
132 get("proof.example.com")
133 get("web.example.com")
134 errshipGather(sink, pending)
135 if _, ok := pending[applog.AppKey{Acct: "bab", App: ""}]; !ok {
136 t.Fatalf("round %d: the edge did not file the app-less route under (bab, \"\"); pending = %v", round, pending)
137 }
138 errshipFlush(ctx, s, pending)
139 if len(pending) != 0 {
140 t.Fatalf("round %d: pending after flush = %v; a refused key must drop, not hold forever", round, pending)
141 }
142 }
143 for _, k := range errshipKnownApps(pending, sink) {
144 chunkPrune(ctx, s, applog.FamilyErrors, "leo-climbing", k, time.Now())
145 }
146
147 var webPuts int
148 for _, op := range s.all() {
149 if strings.Contains(op, "//") {
150 t.Fatalf("touched a key with an empty segment: %s", op)
151 }
152 if strings.HasPrefix(op, "put apps/bab/web/errors/leo-climbing/") {
153 webPuts++
154 }
155 }
156 if webPuts != 2 {
157 t.Fatalf("web chunks written = %d, want 2 (one per round): %v", webPuts, s.all())
158 }
159 if n := strings.Count(logs.String(), `errors: not shipping for account "bab" app ""`); n != 1 {
160 t.Fatalf("refusal logged %d times, want once:\n%s", n, logs.String())
161 }
162}
163
164// TestLogshipAndAnalyticsDropAppLessRecords: the logs and analytics
165// shippers take the same refusal: drop, never re-queue, write nothing.
166func TestLogshipAndAnalyticsDropAppLessRecords(t *testing.T) {
167 logs := captureShipLog(t)
168 ctx := context.Background()
169 s := &keyLog{Store: release.NewDirStore(t.TempDir())}
170 bad := applog.AppKey{Acct: "bab", App: ""}
171
172 pending := map[applog.AppKey][]applog.Record{bad: {{TS: time.Now(), Box: "box1", Seq: 1, Msg: "x"}}}
173 logshipFlush(ctx, s, pending)
174 if len(pending) != 0 {
175 t.Fatalf("logship pending = %v", pending)
176 }
177 chunkPrune(ctx, s, applog.FamilyLogs, "box1", bad, time.Now())
178
179 now := time.Date(2026, 10, 16, 6, 46, 0, 0, time.UTC)
180 am := analytics.NewMeter(t.TempDir(), "box1", func() time.Time { return now })
181 am.Observe(analytics.Obs{Acct: "bab", App: "", Host: "proof.example.com", Method: "GET", Status: 502, Path: "/", When: now})
182 am.Observe(analytics.Obs{Acct: "bab", App: "web", Host: "web.example.com", Method: "GET", Status: 200, ContentType: "text/html", Path: "/", UA: "Mozilla/5.0 Safari", ClientIP: "1.2.3.4", Bytes: 10, When: now})
183 none := func() map[string]usage.EdgeRow { return nil }
184 for i := 0; i < 2; i++ { // a second flush proves the bad delta was not re-queued
185 if err := usageFlush(ctx, s, s, "box1", none, am, now); err != nil {
186 t.Fatal(err)
187 }
188 }
189
190 var webDay bool
191 for _, op := range s.all() {
192 if strings.Contains(op, "//") {
193 t.Fatalf("touched a key with an empty segment: %s", op)
194 }
195 if op == "put apps/bab/web/analytics/box1/days/2026-10-16.json" {
196 webDay = true
197 }
198 }
199 if !webDay {
200 t.Fatalf("the valid app's day file was not written: %v", s.all())
201 }
202 out := logs.String()
203 if strings.Count(out, `logs: not shipping for account "bab" app ""`) != 1 || strings.Count(out, `analytics: not shipping for account "bab" app ""`) != 1 {
204 t.Fatalf("want one refusal line per family:\n%s", out)
205 }
206 if strings.Contains(out, "re-queued") {
207 t.Fatalf("a refused analytics delta was re-queued:\n%s", out)
208 }
209}
210