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 "crypto/ecdh"
7 "crypto/rand"
8 "encoding/base64"
9 "encoding/json"
10 "errors"
11 "fmt"
12 "log"
13 "os"
14 "strings"
15 "sync"
16 "testing"
17 "time"
18
19 "github.com/carlosframework/platform/internal/appconfig"
20 "github.com/carlosframework/platform/internal/registry"
21 "github.com/carlosframework/platform/internal/release"
22)
23
24// Issue #213: the box-side guard. A box takes an instance's lease, or mints
25// its record, only if it can provide that instance's config. These tests
26// drive the guard through its Gate seam, the one function the activator and
27// the reconcile pass both call.
28
29type guardEnv struct {
30 t *testing.T
31 store *guardStore
32 priv *ecdh.PrivateKey
33 other *ecdh.PrivateKey
34 clock *guardClock
35 // dataDir holds leftover rendered files the tests plant to show the guard
36 // ignores them.
37 dataDir string
38}
39
40type guardClock struct {
41 mu sync.Mutex
42 t time.Time
43}
44
45func (c *guardClock) now() time.Time { c.mu.Lock(); defer c.mu.Unlock(); return c.t }
46func (c *guardClock) advance(d time.Duration) {
47 c.mu.Lock()
48 c.t = c.t.Add(d)
49 c.mu.Unlock()
50}
51
52// guardStore counts Gets and can be made to fail every Get.
53type guardStore struct {
54 release.Store
55 mu sync.Mutex
56 gets int
57 failGet error
58}
59
60func (s *guardStore) Get(ctx context.Context, key string) ([]byte, string, error) {
61 s.mu.Lock()
62 s.gets++
63 err := s.failGet
64 s.mu.Unlock()
65 if err != nil {
66 return nil, "", err
67 }
68 return s.Store.Get(ctx, key)
69}
70
71func (s *guardStore) getCount() int { s.mu.Lock(); defer s.mu.Unlock(); return s.gets }
72func (s *guardStore) setFail(err error) {
73 s.mu.Lock()
74 s.failGet = err
75 s.mu.Unlock()
76}
77
78func newGuardEnv(t *testing.T) *guardEnv {
79 t.Helper()
80 priv, err := ecdh.P256().GenerateKey(rand.Reader)
81 if err != nil {
82 t.Fatal(err)
83 }
84 other, err := ecdh.P256().GenerateKey(rand.Reader)
85 if err != nil {
86 t.Fatal(err)
87 }
88 return &guardEnv{
89 t: t,
90 store: &guardStore{Store: release.NewDirStore(t.TempDir())},
91 priv: priv, other: other,
92 clock: &guardClock{t: time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)},
93 dataDir: t.TempDir(),
94 }
95}
96
97func (e *guardEnv) seal(plain string) appconfig.Var {
98 e.t.Helper()
99 env, err := appconfig.SealUnbound(e.priv.PublicKey(), []byte(plain))
100 if err != nil {
101 e.t.Fatal(err)
102 }
103 return appconfig.Var{Kind: "secret", Envelope: env}
104}
105
106func (e *guardEnv) putConfig(acct, app string, vars map[string]appconfig.Var) {
107 e.t.Helper()
108 body, err := json.Marshal(appconfig.Manifest{Schema: 1, App: app, Vars: vars})
109 if err != nil {
110 e.t.Fatal(err)
111 }
112 if _, err := e.store.Store.Put(context.Background(), appconfig.Key(acct, app), body, ""); err != nil {
113 e.t.Fatal(err)
114 }
115}
116
117// guard builds the guard with the given key in the environment ("" = unset)
118// and this env's store.
119func (e *guardEnv) guard(keyB64 string) *configGuard {
120 g := newConfigGuard(func(k string) string {
121 switch k {
122 case "CARLOS_SECRETS_KEY":
123 return keyB64
124 }
125 return ""
126 }, func(context.Context) (release.Store, error) { return e.store, nil })
127 g.now = e.clock.now
128 return g
129}
130
131func keyB64(k *ecdh.PrivateKey) string { return base64.StdEncoding.EncodeToString(k.Bytes()) }
132
133func guardRoute(host string) registry.Route {
134 return registry.Route{Host: host, Kind: "instance", App: "shop", Account: "bab",
135 Socket: "/run/carlos/" + host + ".sock", Hibernate: true, Provisioned: true}
136}
137
138func reasonOfGate(t *testing.T, err error) string {
139 t.Helper()
140 var r *appconfig.ConfigRefusal
141 if !errors.As(err, &r) {
142 t.Fatalf("err = %v, want *appconfig.ConfigRefusal", err)
143 }
144 return r.Reason
145}
146
147func guardCaptureLog(t *testing.T) *bytes.Buffer {
148 t.Helper()
149 var buf bytes.Buffer
150 old := log.Writer()
151 log.SetOutput(&buf)
152 t.Cleanup(func() { log.SetOutput(old) })
153 return &buf
154}
155
156func TestNeedsConfigIsTheShapeTheActivatorSpawns(t *testing.T) {
157 cases := []struct {
158 name string
159 rt registry.Route
160 want bool
161 }{
162 {"socket instance", guardRoute("a.example.com"), true},
163 {"non-hibernating socket instance", registry.Route{Host: "a", Kind: "instance", App: "shop", Socket: "/s"}, true},
164 {"sidecar worker", registry.Route{Host: "a", Kind: "sidecar", App: "shop", Hibernate: true}, true},
165 {"unit-backed by column", registry.Route{Host: "a", Kind: "instance", App: "shop", Socket: "/s", Backing: registry.BackingUnit}, false},
166 {"unit-backed sidecar", registry.Route{Host: "a", Kind: "sidecar", App: "shop", Backing: registry.BackingUnit}, false},
167 // Dispatch is on Backing alone (SplitBackend.pick): a socket-backed
168 // instance that merely NAMES a unit still reaches ExecBackend.Acquire.
169 {"unit-named, socket-backed, empty Backing", registry.Route{Host: "a", Kind: "instance", App: "shop", Socket: "/s", Unit: "x.service", Hibernate: true}, true},
170 {"unit-named, addr-backed", registry.Route{Host: "a", Kind: "instance", App: "shop", Addr: "127.0.0.1:1", Unit: "x.service"}, false},
171 {"addr-backed", registry.Route{Host: "a", Kind: "instance", App: "shop", Addr: "127.0.0.1:9"}, false},
172 {"static", registry.Route{Host: "a", Kind: "static", App: "shop"}, false},
173 {"service", registry.Route{Host: "a", Kind: "service", App: "shop", Addr: "127.0.0.1:9"}, false},
174 {"platform", registry.Route{Host: "a", Kind: "instance", App: "console", Socket: "/s", Platform: true}, false},
175 {"no app", registry.Route{Host: "a", Kind: "instance", Socket: "/s"}, false},
176 }
177 for _, c := range cases {
178 if got := needsBoxConfig(c.rt); got != c.want {
179 t.Errorf("%s: needsBoxConfig = %v, want %v", c.name, got, c.want)
180 }
181 }
182}
183
184// A deployment with no sealed config anywhere (dev) is unaffected: no key, no
185// manifest, no refusal, nothing published.
186func TestGuardNoSealedConfigAdmitsKeylessBox(t *testing.T) {
187 e := newGuardEnv(t)
188 g := e.guard("")
189 if err := g.Gate(context.Background(), guardRoute("a.example.com")); err != nil {
190 t.Fatalf("keyless box, nothing sealed: %v", err)
191 }
192 if list, total := g.Refusals(); len(list) != 0 || total != 0 {
193 t.Errorf("refusals = %v (%d), want none", list, total)
194 }
195}
196
197func TestGuardKeylessBoxRefusesSealedInstanceAndPublishesIt(t *testing.T) {
198 e := newGuardEnv(t)
199 e.putConfig("bab", "shop", map[string]appconfig.Var{"DB": e.seal("pw")})
200 logs := guardCaptureLog(t)
201 g := e.guard("")
202
203 err := g.Gate(context.Background(), guardRoute("kpmg.example.com"))
204 if got := reasonOfGate(t, err); got != appconfig.ReasonNoKey {
205 t.Fatalf("reason = %q, want no_key", got)
206 }
207 list, total := g.Refusals()
208 if total != 1 || len(list) != 1 {
209 t.Fatalf("refusals = %v (%d)", list, total)
210 }
211 r := list[0]
212 if r.Host != "kpmg.example.com" || r.Reason != "no_key" || r.Count != 1 || !r.Since.Equal(e.clock.now()) {
213 t.Errorf("refusal = %+v", r)
214 }
215 if n := strings.Count(logs.String(), "kpmg.example.com"); n != 1 {
216 t.Errorf("log mentions the host %d times, want once:\n%s", n, logs.String())
217 }
218}
219
220func TestGuardKeyedBoxTakesIt(t *testing.T) {
221 e := newGuardEnv(t)
222 e.putConfig("bab", "shop", map[string]appconfig.Var{"DB": e.seal("pw")})
223 g := e.guard(keyB64(e.priv))
224 if err := g.Gate(context.Background(), guardRoute("a.example.com")); err != nil {
225 t.Fatalf("keyed box: %v", err)
226 }
227 if _, total := g.Refusals(); total != 0 {
228 t.Errorf("a keyed box publishes %d refusals", total)
229 }
230}
231
232func TestGuardWrongAndBrokenKeysRefuse(t *testing.T) {
233 e := newGuardEnv(t)
234 e.putConfig("bab", "shop", map[string]appconfig.Var{"DB": e.seal("pw")})
235 for name, tc := range map[string]struct {
236 key string
237 want string
238 }{
239 "wrong key": {keyB64(e.other), appconfig.ReasonCannotOpen},
240 "not base64": {"%%%not-a-key", appconfig.ReasonKeyUnparseable},
241 "short": {base64.StdEncoding.EncodeToString([]byte("abc")), appconfig.ReasonKeyUnparseable},
242 "blank": {" ", appconfig.ReasonNoKey},
243 } {
244 t.Run(name, func(t *testing.T) {
245 g := e.guard(tc.key)
246 err := g.Gate(context.Background(), guardRoute("a.example.com"))
247 if got := reasonOfGate(t, err); got != tc.want {
248 t.Errorf("reason = %q, want %q", got, tc.want)
249 }
250 })
251 }
252}
253
254// Fail closed: a store the box cannot read is not "no sealed config".
255func TestGuardStoreErrorRefusesAndRetriesAfterTheTTL(t *testing.T) {
256 e := newGuardEnv(t)
257 e.putConfig("bab", "shop", map[string]appconfig.Var{"DB": e.seal("pw")})
258 g := e.guard(keyB64(e.priv))
259 ctx := context.Background()
260
261 e.store.setFail(errors.New("s3: 503"))
262 err := g.Gate(ctx, guardRoute("a.example.com"))
263 if got := reasonOfGate(t, err); got != appconfig.ReasonStoreError {
264 t.Fatalf("reason = %q, want store_error", got)
265 }
266
267 // Within the TTL a repeat is answered from the verdict, without another
268 // bucket read, and still refuses even though the store has recovered.
269 e.store.setFail(nil)
270 before := e.store.getCount()
271 if err := g.Gate(ctx, guardRoute("a.example.com")); err == nil {
272 t.Fatal("refusal forgotten inside the TTL")
273 }
274 if got := e.store.getCount(); got != before {
275 t.Errorf("a cached refusal read the store (%d -> %d)", before, got)
276 }
277 if list, _ := g.Refusals(); len(list) != 1 || list[0].Count != 2 {
278 t.Errorf("refusals = %+v, want one with count 2", list)
279 }
280
281 // After the TTL it asks again and, the store being back, admits and
282 // stops publishing the refusal.
283 e.clock.advance(configGuardRefuseTTL + time.Second)
284 if err := g.Gate(ctx, guardRoute("a.example.com")); err != nil {
285 t.Fatalf("after the TTL with the store back: %v", err)
286 }
287 if _, total := g.Refusals(); total != 0 {
288 t.Errorf("a recovered host still published (%d)", total)
289 }
290}
291
292func TestGuardLogsOncePerHostAndAgainWhenTheReasonChanges(t *testing.T) {
293 e := newGuardEnv(t)
294 e.putConfig("bab", "shop", map[string]appconfig.Var{"DB": e.seal("pw")})
295 g := e.guard("")
296 logs := guardCaptureLog(t)
297 ctx := context.Background()
298
299 for i := 0; i < 5; i++ {
300 _ = g.Gate(ctx, guardRoute("a.example.com"))
301 e.clock.advance(configGuardRefuseTTL + time.Second) // defeat the verdict cache
302 }
303 if n := strings.Count(logs.String(), "a.example.com"); n != 1 {
304 t.Fatalf("5 refusals logged %d lines, want 1:\n%s", n, logs.String())
305 }
306 _ = g.Gate(ctx, guardRoute("b.example.com"))
307 if n := strings.Count(logs.String(), "b.example.com"); n != 1 {
308 t.Errorf("a second host logged %d lines, want 1", n)
309 }
310 // The reason changes (a store outage now hides the answer): said again.
311 e.store.setFail(errors.New("s3: down"))
312 _ = g.Gate(ctx, guardRoute("a.example.com"))
313 if n := strings.Count(logs.String(), "a.example.com"); n != 2 {
314 t.Errorf("a changed reason logged %d lines for the host, want 2:\n%s", n, logs.String())
315 }
316}
317
318// Routes the box does not spawn never reach the store.
319func TestGuardIgnoresRoutesItDoesNotSpawn(t *testing.T) {
320 e := newGuardEnv(t)
321 e.putConfig("bab", "shop", map[string]appconfig.Var{"DB": e.seal("pw")})
322 g := e.guard("")
323 for _, rt := range []registry.Route{
324 {Host: "s", Kind: "static", App: "shop"},
325 {Host: "u", Kind: "instance", App: "shop", Socket: "/s", Backing: registry.BackingUnit},
326 {Host: "p", Kind: "instance", App: "shop", Socket: "/s", Platform: true},
327 } {
328 if err := g.Gate(context.Background(), rt); err != nil {
329 t.Errorf("%s: %v", rt.Host, err)
330 }
331 }
332 if e.store.getCount() != 0 {
333 t.Errorf("read the store %d times for routes it does not spawn", e.store.getCount())
334 }
335}
336
337// Config lives in the deployment store, and CARLOS_HIBERNATE_BUCKET enables
338// leases without one. A box with no store configured therefore cannot know
339// that nothing is sealed, and refuses placed exec instances, unless the
340// operator has said explicitly that this deployment has no sealed config.
341func TestGuardNoDeployStoreFailsClosedUnlessOptedOut(t *testing.T) {
342 e := newGuardEnv(t)
343 build := func(env map[string]string) *configGuard {
344 g := newConfigGuard(func(k string) string { return env[k] },
345 func(context.Context) (release.Store, error) { return nil, errNoDeployStore })
346 g.now = e.clock.now
347 return g
348 }
349
350 g := build(nil)
351 err := g.Gate(context.Background(), guardRoute("a.example.com"))
352 if got := reasonOfGate(t, err); got != appconfig.ReasonNoSource {
353 t.Fatalf("no store, no opt-out: reason = %q, want no_config_source", got)
354 }
355 if list, _ := g.Refusals(); len(list) != 1 || list[0].Reason != "no_config_source" {
356 t.Errorf("refusals = %+v", list)
357 }
358
359 for _, v := range []string{"1", "true", " TRUE "} {
360 g := build(map[string]string{"CARLOS_NO_SEALED_CONFIG": v})
361 if err := g.Gate(context.Background(), guardRoute("a.example.com")); err != nil {
362 t.Errorf("CARLOS_NO_SEALED_CONFIG=%q: %v", v, err)
363 }
364 }
365 for _, v := range []string{"", "0", "no", "yes please"} {
366 g := build(map[string]string{"CARLOS_NO_SEALED_CONFIG": v})
367 if err := g.Gate(context.Background(), guardRoute("a.example.com")); err == nil {
368 t.Errorf("CARLOS_NO_SEALED_CONFIG=%q opted out", v)
369 }
370 }
371
372 // The opt-out is only about having NO store. With one, config is read.
373 e.putConfig("bab", "shop", map[string]appconfig.Var{"DB": e.seal("pw")})
374 withStore := newConfigGuard(func(k string) string {
375 if k == "CARLOS_NO_SEALED_CONFIG" {
376 return "1"
377 }
378 return ""
379 }, func(context.Context) (release.Store, error) { return e.store, nil })
380 withStore.now = e.clock.now
381 if got := reasonOfGate(t, withStore.Gate(context.Background(), guardRoute("a.example.com"))); got != appconfig.ReasonNoKey {
382 t.Errorf("opt-out with a real store and sealed config: reason = %q, want no_key", got)
383 }
384
385 // A store that is configured but will not open is "cannot tell".
386 broken := newConfigGuard(func(string) string { return "" },
387 func(context.Context) (release.Store, error) { return nil, fmt.Errorf("aws: no credentials") })
388 broken.now = e.clock.now
389 if got := reasonOfGate(t, broken.Gate(context.Background(), guardRoute("a.example.com"))); got != appconfig.ReasonStoreError {
390 t.Errorf("unopenable store reason = %q, want store_error", got)
391 }
392}
393
394// A route whose Unit is named but whose Backing is empty is exec-managed and
395// gated like any other.
396func TestGuardGatesAUnitNamedExecInstance(t *testing.T) {
397 e := newGuardEnv(t)
398 e.putConfig("bab", "shop", map[string]appconfig.Var{"DB": e.seal("pw")})
399 g := e.guard("")
400 rt := guardRoute("a.example.com")
401 rt.Unit = "x.service"
402 if got := reasonOfGate(t, g.Gate(context.Background(), rt)); got != appconfig.ReasonNoKey {
403 t.Errorf("reason = %q, want no_key", got)
404 }
405 rt.Backing = registry.BackingUnit
406 if err := g.Gate(context.Background(), rt); err != nil {
407 t.Errorf("a true unit-backed route was gated: %v", err)
408 }
409}
410
411// There is no last-known-good fallback. A rendered <host>.config.env left on
412// disk proves nothing about its values: names alone pass for "A=old", for empty
413// values, or for credentials rendered for another environment. A fresh start
414// needs the whole merged config to render now.
415func TestGuardIgnoresLeftoverRenderedFiles(t *testing.T) {
416 foreign := func(e *guardEnv) string {
417 v, err := appconfig.SealUnbound(e.other.PublicKey(), []byte("z"))
418 if err != nil {
419 t.Fatal(err)
420 }
421 return v
422 }
423 cases := map[string]struct {
424 file string
425 env string // the route's bound environment
426 }{
427 "wrong values": {"A=old\nC=wrong\n", ""},
428 "empty values": {"A=\nC=\n", ""},
429 "every name present": {"# header\nA=x\nC=y\nP=1\n", ""},
430 "environment rebound": {"A=prod-secret\nC=prod-secret\n", "staging"},
431 }
432 for name, tc := range cases {
433 t.Run(name, func(t *testing.T) {
434 e := newGuardEnv(t)
435 e.putConfig("bab", "shop", map[string]appconfig.Var{
436 "A": {Kind: "secret", Envelope: foreign(e)}, // sealed for another key
437 "C": e.seal("ok"),
438 "P": {Kind: "env", Value: "1"},
439 })
440 if tc.env != "" {
441 body, _ := json.Marshal(appconfig.Manifest{Schema: 1, App: "shop", Vars: map[string]appconfig.Var{"S": e.seal("staging")}})
442 if _, err := e.store.Store.Put(context.Background(), appconfig.EnvironmentKey("bab", "shop", tc.env), body, ""); err != nil {
443 t.Fatal(err)
444 }
445 }
446 g := e.guard(keyB64(e.priv))
447 rt := guardRoute("a.example.com")
448 rt.Environment = tc.env
449 if err := os.WriteFile(appconfig.ConfigEnvPath(e.dataDir, rt), []byte(tc.file), 0o600); err != nil {
450 t.Fatal(err)
451 }
452 if got := reasonOfGate(t, g.Gate(context.Background(), rt)); got != appconfig.ReasonCannotOpen {
453 t.Fatalf("reason = %q, want cannot_open: a leftover file must not admit a mixed-key config", got)
454 }
455 })
456 }
457}
458
459// The bypass is loud: logged at startup and once per admitted host, and
460// published in the private status block, and only while it is in effect.
461func TestGuardBypassIsLoud(t *testing.T) {
462 e := newGuardEnv(t)
463 noStore := func(context.Context) (release.Store, error) { return nil, errNoDeployStore }
464 env := func(k string) string {
465 if k == "CARLOS_NO_SEALED_CONFIG" {
466 return "1"
467 }
468 return ""
469 }
470 g := newConfigGuard(env, noStore)
471 g.now = e.clock.now
472 logs := guardCaptureLog(t)
473
474 if got := g.Status(); got != "bypassed (CARLOS_NO_SEALED_CONFIG)" {
475 t.Errorf("status = %q", got)
476 }
477 g.Announce()
478 if !strings.Contains(logs.String(), "BYPASSED") {
479 t.Errorf("startup line missing:\n%s", logs.String())
480 }
481 for i := 0; i < 3; i++ {
482 if err := g.Gate(context.Background(), guardRoute("a.example.com")); err != nil {
483 t.Fatal(err)
484 }
485 }
486 _ = g.Gate(context.Background(), guardRoute("b.example.com"))
487 if n := strings.Count(logs.String(), "BYPASS: admitting a.example.com"); n != 1 {
488 t.Errorf("a.example.com logged %d times, want once:\n%s", n, logs.String())
489 }
490 if n := strings.Count(logs.String(), "BYPASS: admitting b.example.com"); n != 1 {
491 t.Errorf("b.example.com logged %d times, want once", n)
492 }
493
494 // The flag beside a real store is ignored, and says so, and is not
495 // reported as a bypass.
496 withStore := newConfigGuard(env, func(context.Context) (release.Store, error) { return e.store, nil })
497 logs2 := guardCaptureLog(t)
498 if got := withStore.Status(); got != "" {
499 t.Errorf("flag with a store reported %q", got)
500 }
501 withStore.Announce()
502 if !strings.Contains(logs2.String(), "ignored") || strings.Contains(logs2.String(), "BYPASSED") {
503 t.Errorf("flag-with-store line wrong:\n%s", logs2.String())
504 }
505
506 // No flag: quiet, and nothing published.
507 plain := newConfigGuard(func(string) string { return "" }, noStore)
508 logs3 := guardCaptureLog(t)
509 plain.Announce()
510 if plain.Status() != "" || logs3.Len() != 0 {
511 t.Errorf("a box without the flag said something: %q / %q", plain.Status(), logs3.String())
512 }
513}
514
515// The opt-out never covers a store that is configured and failing: the box
516// cannot tell, and refuses, flag or not.
517func TestGuardOptOutDoesNotCoverAConfiguredStoreError(t *testing.T) {
518 e := newGuardEnv(t)
519 env := func(k string) string {
520 if k == "CARLOS_NO_SEALED_CONFIG" {
521 return "1"
522 }
523 return ""
524 }
525 // Opening fails.
526 broken := newConfigGuard(env, func(context.Context) (release.Store, error) { return nil, fmt.Errorf("aws: no credentials") })
527 broken.now = e.clock.now
528 if got := reasonOfGate(t, broken.Gate(context.Background(), guardRoute("a.example.com"))); got != appconfig.ReasonStoreError {
529 t.Errorf("open failure with the opt-out: reason = %q, want store_error", got)
530 }
531 if broken.Status() != "" {
532 t.Errorf("an open failure was reported as a bypass: %q", broken.Status())
533 }
534 // Opens, then reads fail.
535 e.putConfig("bab", "shop", map[string]appconfig.Var{"DB": e.seal("pw")})
536 e.store.setFail(errors.New("s3: 503"))
537 flaky := newConfigGuard(env, func(context.Context) (release.Store, error) { return e.store, nil })
538 flaky.now = e.clock.now
539 if got := reasonOfGate(t, flaky.Gate(context.Background(), guardRoute("a.example.com"))); got != appconfig.ReasonStoreError {
540 t.Errorf("read failure with the opt-out: reason = %q, want store_error", got)
541 }
542}
543
544func TestGuardRefusalsAreSortedCappedAndPruned(t *testing.T) {
545 e := newGuardEnv(t)
546 e.putConfig("bab", "shop", map[string]appconfig.Var{"DB": e.seal("pw")})
547 g := e.guard("")
548 ctx := context.Background()
549 n := configGuardListCap + 5
550 for i := 0; i < n; i++ {
551 _ = g.Gate(ctx, guardRoute(fmt.Sprintf("h%03d.example.com", i)))
552 e.clock.advance(time.Second)
553 }
554 list, total := g.Refusals()
555 if total != n || len(list) != configGuardListCap {
556 t.Fatalf("list %d total %d, want %d and %d", len(list), total, configGuardListCap, n)
557 }
558 // Newest first, so the cap drops the oldest.
559 if list[0].Host != fmt.Sprintf("h%03d.example.com", n-1) {
560 t.Errorf("first = %s, want the newest", list[0].Host)
561 }
562
563 // A refusal nobody has asked about for a day is no longer current.
564 e.clock.advance(configGuardRefusalMaxAge + time.Hour)
565 if list, total := g.Refusals(); len(list) != 0 || total != 0 {
566 t.Errorf("stale refusals survived: %d/%d", len(list), total)
567 }
568}
569
570// Nothing secret in what is published: reason words and hosts only.
571func TestGuardPublishesNoSecretMaterial(t *testing.T) {
572 e := newGuardEnv(t)
573 v := e.seal("hunter2")
574 e.putConfig("bab", "shop", map[string]appconfig.Var{"DB": v})
575 g := e.guard(keyB64(e.other))
576 logs := guardCaptureLog(t)
577 _ = g.Gate(context.Background(), guardRoute("a.example.com"))
578 list, _ := g.Refusals()
579 raw, _ := json.Marshal(list)
580 for _, s := range []string{string(raw), logs.String()} {
581 for _, bad := range []string{v.Envelope, "hunter2", keyB64(e.other), keyB64(e.priv)} {
582 if strings.Contains(s, bad) {
583 t.Errorf("secret material %q in %s", bad, s)
584 }
585 }
586 }
587}
588
589// The agent wires one armed guard into the reconcile config and the status
590// probe, and a box with none armed behaves exactly as before.
591func TestArmedGuardReachesReconcileConfigAndStatus(t *testing.T) {
592 armConfigGuard(nil)
593 t.Cleanup(func() { armConfigGuard(nil) })
594 if cfg := provisionTickConfig(func(string) string { return "" }); cfg.ConfigGate != nil {
595 t.Error("no armed guard, but the reconcile config carries a gate")
596 }
597 if list, total := configRefusalProbe(); list != nil || total != 0 {
598 t.Errorf("no armed guard, but the probe reports %v/%d", list, total)
599 }
600
601 e := newGuardEnv(t)
602 e.putConfig("bab", "shop", map[string]appconfig.Var{"DB": e.seal("pw")})
603 g := e.guard("")
604 armConfigGuard(g)
605 cfg := provisionTickConfig(func(string) string { return "" })
606 if cfg.ConfigGate == nil {
607 t.Fatal("armed guard not wired into the reconcile config")
608 }
609 if err := cfg.ConfigGate(context.Background(), guardRoute("a.example.com")); err == nil {
610 t.Error("the reconcile gate admitted a sealed instance on a keyless box")
611 }
612 if list, total := configRefusalProbe(); len(list) != 1 || total != 1 {
613 t.Errorf("probe = %v/%d, want the one refusal", list, total)
614 }
615}
616