carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package activator
2
3import (
4 "context"
5 "errors"
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10 "time"
11
12 "github.com/carlosframework/platform/internal/instancekey"
13 "github.com/carlosframework/platform/internal/registry"
14)
15
16// bucketKey is ExecBackend.ConfiguredKey for a bucket whose configuration
17// renders key ("" for none), counting its reads; down makes every read
18// fail.
19type bucketKey struct {
20 key string
21 down bool
22 reads int
23}
24
25func (b *bucketKey) read(context.Context, registry.Route) (string, error) {
26 b.reads++
27 if b.down {
28 return "", errors.New("bucket unreachable")
29 }
30 if b.key == "" {
31 return "", nil
32 }
33 return instancekey.Digest(b.key), nil
34}
35
36// Final review I2 (Astra's P2), ruling 5: nothing is minted until the
37// row's configuration is delivered. The files say what this box has
38// delivered; the bucket says what the configuration sets. A key the bucket
39// sets and the box has not delivered defers the mint; a bucket that cannot
40// be read defers it the same way; a configuration that sets no key lets
41// the mint proceed. The bucket is asked only when a mint is next: a key
42// already delivered, in either file, costs no read.
43func TestEnsureInstanceKeyAsksTheBucketBeforeItMints(t *testing.T) {
44 tok := "CARLOS_ADMIN_TOKEN=" + strings.Repeat("c", 64) + "\n"
45 for _, tc := range []struct {
46 name string
47 own, config string
48 bucket *bucketKey
49 want InstanceKeyOutcome
50 deferred bool
51 reads int
52 }{
53 {"the configuration sets a key the box has not delivered: deferred", tok, "", &bucketKey{key: "key-A"}, InstanceKeyFailed, true, 1},
54 {"the bucket cannot be read: deferred", tok, "", &bucketKey{down: true}, InstanceKeyFailed, true, 1},
55 {"the configuration sets no key: minted", tok, "", &bucketKey{}, InstanceKeyMinted, false, 1},
56 {"no configuration source: minted, nothing asked", tok, "", nil, InstanceKeyMinted, false, 0},
57 {"the configured key is delivered: from config, nothing asked", tok, InstanceKeyVar + "=key-A\n", &bucketKey{key: "key-A"}, InstanceKeyFromConfig, false, 0},
58 {"an own key: present, nothing asked", tok + InstanceKeyVar + "=" + strings.Repeat("K", 43) + "=\n", "", &bucketKey{key: "key-A"}, InstanceKeyPresent, false, 0},
59 } {
60 t.Run(tc.name, func(t *testing.T) {
61 b := &ExecBackend{DataDir: t.TempDir()}
62 if tc.bucket != nil {
63 b.ConfiguredKey = tc.bucket.read
64 }
65 rt := registry.Route{Host: "shop.example.com", Kind: "instance"}
66 own := filepath.Join(b.DataDir, rt.Host+".env")
67 if err := os.WriteFile(own, []byte(tc.own), 0o600); err != nil {
68 t.Fatal(err)
69 }
70 if tc.config != "" {
71 if err := os.WriteFile(filepath.Join(b.DataDir, rt.Host+".config.env"), []byte(tc.config), 0o600); err != nil {
72 t.Fatal(err)
73 }
74 }
75 for _, call := range []string{"launch", "backfill"} {
76 var got InstanceKeyOutcome
77 var err error
78 if call == "launch" {
79 got, err = b.ensureInstanceKeyFor(t.Context(), rt, b.pathsFor(rt))
80 } else {
81 if err := os.WriteFile(own, []byte(tc.own), 0o600); err != nil {
82 t.Fatal(err)
83 }
84 if tc.bucket != nil {
85 tc.bucket.reads = 0
86 }
87 got, err = b.BackfillInstanceKey(t.Context(), rt)
88 }
89 if got != tc.want || errors.Is(err, ErrInstanceKeyDeferred) != tc.deferred || (err != nil) != tc.deferred {
90 t.Fatalf("%s = %v, %v; want %v, deferred %v", call, got, err, tc.want, tc.deferred)
91 }
92 raw, _ := os.ReadFile(own)
93 if minted := len(keyLines(raw)) > len(keyLines([]byte(tc.own))); minted != (tc.want == InstanceKeyMinted) {
94 t.Fatalf("%s: a key line was appended: %v, want %v", call, minted, tc.want == InstanceKeyMinted)
95 }
96 if tc.bucket != nil && tc.bucket.reads != tc.reads {
97 t.Fatalf("%s: the bucket was read %d times, want %d", call, tc.bucket.reads, tc.reads)
98 }
99 }
100 })
101 }
102}
103
104// The mint's bucket read is bounded by configReadTimeout and by the
105// launch's own context, whichever ends first.
106func TestEnsureInstanceKeyBoundsTheBucketRead(t *testing.T) {
107 b := &ExecBackend{DataDir: t.TempDir()}
108 var deadline time.Time
109 b.ConfiguredKey = func(ctx context.Context, _ registry.Route) (string, error) {
110 deadline, _ = ctx.Deadline()
111 return "", ctx.Err()
112 }
113 rt := registry.Route{Host: "shop.example.com", Kind: "instance"}
114 if err := os.WriteFile(filepath.Join(b.DataDir, rt.Host+".env"), []byte("A=1\n"), 0o600); err != nil {
115 t.Fatal(err)
116 }
117 if _, err := b.ensureInstanceKeyFor(context.Background(), rt, b.pathsFor(rt)); err != nil {
118 t.Fatal(err)
119 }
120 if until := time.Until(deadline); until <= 0 || until > configReadTimeout {
121 t.Fatalf("the read's deadline is %v away, want within %v", until, configReadTimeout)
122 }
123 if err := os.WriteFile(filepath.Join(b.DataDir, rt.Host+".env"), []byte("A=1\n"), 0o600); err != nil {
124 t.Fatal(err) // keyless again, so the backfill reaches the read
125 }
126 ctx, cancel := context.WithCancel(context.Background())
127 cancel()
128 if _, err := b.BackfillInstanceKey(ctx, rt); !errors.Is(err, ErrInstanceKeyDeferred) || !errors.Is(err, context.Canceled) {
129 t.Fatalf("a cancelled read = %v, want deferred by the cancellation", err)
130 }
131}
132
133// A first start whose configuration is still on its way, end to end: the
134// start is deferred and no app runs; nothing is minted into <host>.env;
135// once the config sync has delivered <host>.config.env with the configured
136// key, the start runs and the app seals with that key, the configured one,
137// never a minted one.
138func TestFirstStartWaitsForTheConfiguredKey(t *testing.T) {
139 b := testExecBackend(t)
140 bucket := &bucketKey{key: "key-A"}
141 b.ConfiguredKey = bucket.read
142 rt := registry.Route{Host: "shop.example.com", Kind: "instance", Socket: noControlSocket(t)}
143 own := filepath.Join(b.DataDir, rt.Host+".env")
144 tok := "CARLOS_ADMIN_TOKEN=" + strings.Repeat("c", 64) + "\n"
145 if err := os.WriteFile(own, []byte(tok), 0o600); err != nil {
146 t.Fatal(err)
147 }
148 out := stageHelperApp(t, b, rt.Host, "v1")
149 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
150 defer cancel()
151 proc, _, err := b.Start(ctx, rt)
152 if proc != nil {
153 proc.Stop(context.Background())
154 }
155 if !errors.Is(err, ErrInstanceKeyDeferred) || !strings.Contains(err.Error(), rt.Host) {
156 t.Fatalf("Start before delivery = %v, want ErrInstanceKeyDeferred naming the host", err)
157 }
158 if got := outcomeOf(out); got != "" {
159 t.Fatalf("an app ran before delivery: %q", got)
160 }
161 if raw, _ := os.ReadFile(own); string(raw) != tok {
162 t.Fatalf("the own file changed before delivery: %d bytes", len(raw))
163 }
164 // The config sync delivers the file.
165 if err := os.WriteFile(filepath.Join(b.DataDir, rt.Host+".config.env"), []byte(InstanceKeyVar+"=key-A\n"), 0o600); err != nil {
166 t.Fatal(err)
167 }
168 // The helper exits once it has recorded its outcome, so Start's health
169 // wait never sees a live socket: the outcome is the proof, as in the
170 // replica tests, and the error only must not be the deferral.
171 ctx2, cancel2 := recordedCtx(2*time.Second, out) // ctx was spent waiting for the first outcome
172 defer cancel2()
173 proc, _, err = b.Start(ctx2, rt)
174 if proc != nil {
175 defer proc.Stop(context.Background())
176 }
177 if errors.Is(err, ErrInstanceKeyDeferred) {
178 t.Fatalf("Start after delivery still deferred: %v", err)
179 }
180 if got := outcomeOf(out); got != "key "+instancekey.Digest("key-A") {
181 t.Fatalf("the app's outcome = %q, want the configured key", got)
182 }
183 if raw, _ := os.ReadFile(own); string(raw) != tok {
184 t.Fatal("a key was minted beside the configured one")
185 }
186}
187
188// The re-review's finding: the bucket read runs outside adminTokenMu, so a
189// bucket that is slow or blocked for one row's first wake never holds the
190// lock every other row's mint waits on. Host A's read blocks until the
191// test releases it; host B mints meanwhile; A then mints too.
192func TestASlowBucketReadForOneHostDoesNotBlockAnothersMint(t *testing.T) {
193 b := &ExecBackend{DataDir: t.TempDir()}
194 release := make(chan struct{})
195 entered := make(chan struct{})
196 b.ConfiguredKey = func(ctx context.Context, rt registry.Route) (string, error) {
197 if rt.Host == "a.example.com" {
198 close(entered)
199 select {
200 case <-release:
201 case <-ctx.Done():
202 return "", ctx.Err()
203 }
204 }
205 return "", nil
206 }
207 a := registry.Route{Host: "a.example.com", Kind: "instance"}
208 bb := registry.Route{Host: "b.example.com", Kind: "instance"}
209 for _, rt := range []registry.Route{a, bb} {
210 if err := os.WriteFile(filepath.Join(b.DataDir, rt.Host+".env"), []byte("X=1\n"), 0o600); err != nil {
211 t.Fatal(err)
212 }
213 }
214 aDone := make(chan error, 1)
215 go func() {
216 _, err := b.ensureInstanceKeyFor(context.Background(), a, b.pathsFor(a))
217 aDone <- err
218 }()
219 select {
220 case <-entered: // A is inside its bucket read before B starts
221 case <-time.After(2 * time.Second):
222 t.Fatal("host A never reached its bucket read")
223 }
224 bDone := make(chan error, 1)
225 go func() {
226 _, err := b.ensureInstanceKeyFor(context.Background(), bb, b.pathsFor(bb))
227 bDone <- err
228 }()
229 select {
230 case err := <-bDone:
231 if err != nil {
232 t.Fatalf("host B's mint: %v", err)
233 }
234 case <-time.After(2 * time.Second):
235 t.Fatal("host B's mint waited on host A's bucket read")
236 }
237 select {
238 case <-aDone:
239 t.Fatal("host A minted before its bucket answered")
240 default:
241 }
242 close(release)
243 select {
244 case err := <-aDone:
245 if err != nil {
246 t.Fatalf("host A's mint after the bucket answered: %v", err)
247 }
248 case <-time.After(2 * time.Second):
249 t.Fatal("host A never minted")
250 }
251 for _, rt := range []registry.Route{a, bb} {
252 raw, _ := os.ReadFile(filepath.Join(b.DataDir, rt.Host+".env"))
253 if len(keyLines(raw)) != 1 {
254 t.Fatalf("%s: %d key lines, want one", rt.Host, len(keyLines(raw)))
255 }
256 }
257}
258
259// A key that appears between the two phases is never overwritten: the
260// bucket read happens outside the lock, so the files it looked at may
261// change before the decision. The locked read decides. Here the bucket
262// reader itself, standing in for a concurrent writer, puts a key into the
263// own file, or delivers the configuration with one, before it answers.
264func TestAKeyThatAppearsDuringTheBucketReadIsKept(t *testing.T) {
265 key := strings.Repeat("K", 43) + "="
266 for _, tc := range []struct {
267 name string
268 plant func(dataDir, host string)
269 want InstanceKeyOutcome
270 }{
271 {"into the own file", func(d, h string) {
272 f, err := os.OpenFile(filepath.Join(d, h+".env"), os.O_APPEND|os.O_WRONLY, 0)
273 if err != nil {
274 t.Fatal(err)
275 }
276 f.WriteString(InstanceKeyVar + "=" + key + "\n")
277 f.Close()
278 }, InstanceKeyPresent},
279 {"delivered in the configuration", func(d, h string) {
280 if err := os.WriteFile(filepath.Join(d, h+".config.env"), []byte(InstanceKeyVar+"="+key+"\n"), 0o600); err != nil {
281 t.Fatal(err)
282 }
283 }, InstanceKeyFromConfig},
284 } {
285 t.Run(tc.name, func(t *testing.T) {
286 b := &ExecBackend{DataDir: t.TempDir()}
287 rt := registry.Route{Host: "shop.example.com", Kind: "instance"}
288 own := filepath.Join(b.DataDir, rt.Host+".env")
289 if err := os.WriteFile(own, []byte("X=1\n"), 0o600); err != nil {
290 t.Fatal(err)
291 }
292 reads := 0
293 b.ConfiguredKey = func(context.Context, registry.Route) (string, error) {
294 reads++
295 tc.plant(b.DataDir, rt.Host)
296 return "", nil // the bucket says: no configured key, mint away
297 }
298 got, err := b.ensureInstanceKeyFor(context.Background(), rt, b.pathsFor(rt))
299 if err != nil || got != tc.want || reads != 1 {
300 t.Fatalf("ensure = %v, %v after %d reads; want %v", got, err, reads, tc.want)
301 }
302 raw, _ := os.ReadFile(own)
303 ownKeys := keyLines(raw)
304 if tc.want == InstanceKeyPresent && (len(ownKeys) != 1 || ownKeys[0] != key) {
305 t.Fatalf("own file keys %d, want the one that appeared, kept", len(ownKeys))
306 }
307 if tc.want == InstanceKeyFromConfig && len(ownKeys) != 0 {
308 t.Fatal("a key was minted beside the one the configuration delivered")
309 }
310 })
311 }
312}
313
314// The other side of the two phases: a mint that looks next only under the
315// lock has no bucket verdict, and defers rather than mint on nothing. The
316// first read saw a key in the own file (or, on the backfill, no file at
317// all), so the bucket was not asked; by the locked read the key is gone
318// (or a keyless file has appeared). Nothing is minted, the bucket is
319// still unasked, and the next attempt asks it.
320func TestAMintThatLooksNextOnlyUnderTheLockDefers(t *testing.T) {
321 key := strings.Repeat("K", 43) + "="
322 for _, tc := range []struct {
323 name string
324 before string // the own file at the first read; "" is no file
325 after string // the own file at the locked read
326 create bool
327 }{
328 {"a key seen by the first read is gone", "X=1\n" + InstanceKeyVar + "=" + key + "\n", "X=1\n", true},
329 {"a keyless file appears after the backfill's first read", "", "X=1\n", false},
330 } {
331 t.Run(tc.name, func(t *testing.T) {
332 b := &ExecBackend{DataDir: t.TempDir()}
333 bucket := &bucketKey{}
334 b.ConfiguredKey = bucket.read
335 rt := registry.Route{Host: "shop.example.com", Kind: "instance"}
336 own := filepath.Join(b.DataDir, rt.Host+".env")
337 if tc.before != "" {
338 if err := os.WriteFile(own, []byte(tc.before), 0o600); err != nil {
339 t.Fatal(err)
340 }
341 }
342 instanceKeyHooks.betweenPhases = func(string) {
343 if err := os.WriteFile(own, []byte(tc.after), 0o600); err != nil {
344 t.Error(err)
345 }
346 }
347 t.Cleanup(func() { instanceKeyHooks.betweenPhases = nil })
348 got, err := b.ensureInstanceKeyAt(context.Background(), rt, b.pathsFor(rt), tc.create, "minted")
349 if got != InstanceKeyFailed || !errors.Is(err, ErrInstanceKeyDeferred) {
350 t.Fatalf("ensure = %v, %v; want deferred", got, err)
351 }
352 if bucket.reads != 0 {
353 t.Fatalf("the bucket was read %d times, want 0", bucket.reads)
354 }
355 raw, _ := os.ReadFile(own)
356 if string(raw) != tc.after {
357 t.Fatalf("own file changed by the deferred ensure (%d bytes, want %d)", len(raw), len(tc.after))
358 }
359 })
360 }
361}
362