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"
7 "crypto/rand"
8 "crypto/rsa"
9 "crypto/sha256"
10 "crypto/x509"
11 "crypto/x509/pkix"
12 "encoding/base64"
13 "encoding/json"
14 "errors"
15 "math/big"
16 "net/http"
17 "net/http/httptest"
18 "os"
19 "strings"
20 "sync"
21 "testing"
22 "time"
23
24 "github.com/carlosframework/platform/internal/boxid"
25 "github.com/carlosframework/platform/internal/hostactors"
26 "github.com/carlosframework/platform/internal/lease/leasetest"
27 "github.com/carlosframework/platform/internal/release"
28 "github.com/carlosframework/platform/internal/status"
29)
30
31// carlos system box publish-record, through the production wiring: the
32// real S3Store (openDeployStoreEnv over CARLOS_DEPLOYMENT_BUCKET and
33// CARLOS_S3_ENDPOINT) against leasetest's S3 server, which enforces
34// If-None-Match: * itself, behind the dev bucket's box_records policy;
35// the real IMDS reader over fakeIMDS (boxrecord_test.go); and enrollment's
36// own verifier over documents a test AWS signs.
37
38const (
39 publishMachineID = "0123456789abcdef0123456789abcdef"
40 publishBucket = "deploy"
41 publishAgentUID = 999
42 publishAccount = "111122223333"
43 publishRegion = "eu-west-1"
44 publishInstance = "i-0abc"
45)
46
47// testAWS stands in for AWS's instance-identity signer the way boxid's
48// own tests do (boxid/internal/signtest, which this package cannot
49// import): one RSA key, a self-signed certificate for it, and PKCS #1
50// v1.5 SHA-256 signatures over identity documents.
51type testAWS struct {
52 key *rsa.PrivateKey
53 cert *x509.Certificate
54}
55
56func newTestAWS(t testing.TB) *testAWS {
57 t.Helper()
58 key, err := rsa.GenerateKey(rand.Reader, 2048)
59 if err != nil {
60 t.Fatal(err)
61 }
62 tmpl := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "test"},
63 NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour)}
64 der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
65 if err != nil {
66 t.Fatal(err)
67 }
68 cert, err := x509.ParseCertificate(der)
69 if err != nil {
70 t.Fatal(err)
71 }
72 return &testAWS{key: key, cert: cert}
73}
74
75// identityDoc is an identity document as IMDS serves one, with the two
76// fields that change over an instance's life.
77func identityDoc(account, region, instance, pendingTime, instanceType string) string {
78 return "{\n \"accountId\" : \"" + account + "\",\n \"instanceId\" : \"" + instance + "\",\n \"instanceType\" : \"" + instanceType +
79 "\",\n \"pendingTime\" : \"" + pendingTime + "\",\n \"region\" : \"" + region + "\"\n}\n"
80}
81
82func (a *testAWS) sign(t testing.TB, doc string) string {
83 t.Helper()
84 sum := sha256.Sum256([]byte(doc))
85 sig, err := rsa.SignPKCS1v15(rand.Reader, a.key, crypto.SHA256, sum[:])
86 if err != nil {
87 t.Fatal(err)
88 }
89 return base64.StdEncoding.EncodeToString(sig)
90}
91
92// certs certifies eu-west-1, this box's region, and eu-west-2, so a record
93// signed in another supported region verifies and is still refused.
94func (a *testAWS) certs() (map[string]*x509.Certificate, error) {
95 return map[string]*x509.Certificate{"eu-west-1": a.cert, "eu-west-2": a.cert}, nil
96}
97
98func (a *testAWS) record(t testing.TB, box, doc, writtenAt string) boxid.Record {
99 return boxid.Record{V: 1, Box: box, IdentityDocument: base64.StdEncoding.EncodeToString([]byte(doc)),
100 IdentitySignature: a.sign(t, doc), WrittenAt: writtenAt}
101}
102
103// s3Request is one request the fake S3 saw.
104type s3Request struct{ Method, Key, IfNoneMatch, IfMatch string }
105
106// boxRecordsBucket is the fake deployment bucket: leasetest's versioned S3
107// server, behind the dev bucket's deny-only box_records policy. Under
108// control/boxes/ a PUT without If-None-Match: * is AccessDenied
109// (BoxRecordsAreCreateOnly) and so is any DELETE
110// (BoxRecordsAreNeverDeleted). Every request is recorded. aws and doc
111// are the box's AWS and the identity document IMDS serves it now.
112//
113// Hooks, each used once: afterGet runs after the first GET has been
114// answered (a writer arriving between the verb's read and
115// publishBoxRecord's); beforePut runs before the first PUT reaches the
116// server (a writer arriving just before the create). commitThenFail lists
117// statuses for successive PUTs: each such PUT is applied and then answered
118// with that status instead, as when S3 commits a write whose answer is
119// lost.
120type boxRecordsBucket struct {
121 model *leasetest.VersionedBucket
122 mu sync.Mutex
123 seen []s3Request
124 aws *testAWS
125 doc string
126
127 afterGet func()
128 beforePut func()
129 commitThenFail []int
130}
131
132func (b *boxRecordsBucket) requests() []s3Request {
133 b.mu.Lock()
134 defer b.mu.Unlock()
135 return append([]s3Request(nil), b.seen...)
136}
137
138func (b *boxRecordsBucket) handler() http.Handler {
139 next, _ := leasetest.NewS3Server(b.model)
140 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
141 _, key, _ := strings.Cut(strings.TrimPrefix(r.URL.Path, "/"), "/")
142 b.mu.Lock()
143 b.seen = append(b.seen, s3Request{r.Method, key, r.Header.Get("If-None-Match"), r.Header.Get("If-Match")})
144 var afterGet, beforePut func()
145 fail := 0
146 switch r.Method {
147 case http.MethodGet:
148 afterGet, b.afterGet = b.afterGet, nil
149 case http.MethodPut:
150 beforePut, b.beforePut = b.beforePut, nil
151 if len(b.commitThenFail) > 0 {
152 fail, b.commitThenFail = b.commitThenFail[0], b.commitThenFail[1:]
153 }
154 }
155 b.mu.Unlock()
156 if strings.HasPrefix(key, boxid.Prefix) {
157 if r.Method == http.MethodDelete || (r.Method == http.MethodPut && r.Header.Get("If-None-Match") != "*") {
158 s3Error(w, http.StatusForbidden, "AccessDenied")
159 return
160 }
161 }
162 if beforePut != nil {
163 beforePut()
164 }
165 if fail != 0 {
166 next.ServeHTTP(httptest.NewRecorder(), r)
167 s3Error(w, fail, http.StatusText(fail))
168 return
169 }
170 next.ServeHTTP(w, r)
171 if afterGet != nil {
172 afterGet()
173 }
174 })
175}
176
177func s3Error(w http.ResponseWriter, status int, code string) {
178 w.Header().Set("Content-Type", "application/xml")
179 w.WriteHeader(status)
180 _, _ = w.Write([]byte("<Error><Code>" + strings.ReplaceAll(code, " ", "") + "</Code><Message>" + code + "</Message></Error>"))
181}
182
183// publishFixture points the process's AWS and CARLOS_* environment at a
184// fresh fake bucket, as host.env would on a box, and makes the box's AWS.
185func publishFixture(t *testing.T) *boxRecordsBucket {
186 t.Helper()
187 b := &boxRecordsBucket{model: leasetest.NewVersionedBucket(), aws: newTestAWS(t),
188 doc: identityDoc(publishAccount, publishRegion, publishInstance, "2026-10-06T08:00:00Z", "t4g.small")}
189 b.model.AddBucket(publishBucket)
190 server := httptest.NewServer(b.handler())
191 t.Cleanup(server.Close)
192 for k, v := range map[string]string{
193 "AWS_ACCESS_KEY_ID": "test-key", "AWS_SECRET_ACCESS_KEY": "test-secret", "AWS_SESSION_TOKEN": "",
194 "AWS_REGION": "eu-west-1", "AWS_EC2_METADATA_DISABLED": "true",
195 "AWS_CONFIG_FILE": "/nonexistent", "AWS_SHARED_CREDENTIALS_FILE": "/nonexistent",
196 "CARLOS_S3_ENDPOINT": server.URL, "CARLOS_DEPLOYMENT_BUCKET": publishBucket,
197 "CARLOS_DEPLOYMENT_DIR": "", "CARLOS_FLEET": "", "CARLOS_FLEET_BOX": "", "CARLOS_BOX_REFERENCE": "",
198 "CARLOS_REGION_HA": "",
199 } {
200 t.Setenv(k, v)
201 }
202 return b
203}
204
205// imdsRead is the real IMDS reader over fakeIMDS serving b's current
206// document and AWS's signature over it.
207func (b *boxRecordsBucket) imdsRead(t *testing.T, written time.Time) func(string) (boxid.Record, error) {
208 client := imdsClient(&fakeIMDS{doc: b.doc, sig: b.aws.sign(t, b.doc)})
209 return func(box string) (boxid.Record, error) {
210 return readIdentityRecord(client, "http://imds.test", box, written)
211 }
212}
213
214// publishRun is the verb's production wiring with the test's seams: the
215// agent user's uid, the fixed machine id, the fake IMDS and the test
216// AWS's certificates.
217func (b *boxRecordsBucket) publishRun(t *testing.T) boxPublishRun {
218 return boxPublishRun{getenv: os.Getenv, geteuid: func() int { return publishAgentUID },
219 machineID: func() string { return publishMachineID }, open: openDeployStoreEnv, read: b.imdsRead(t, recordNow),
220 certs: b.aws.certs}
221}
222
223func (b *boxRecordsBucket) run(t *testing.T) (string, error) {
224 t.Helper()
225 return b.publishRun(t).publish(context.Background())
226}
227
228// thisBoxRecord is the record this box would publish now, written at
229// writtenAt.
230func (b *boxRecordsBucket) thisBoxRecord(t *testing.T, writtenAt string) boxid.Record {
231 return b.aws.record(t, status.Label(publishMachineID), b.doc, writtenAt)
232}
233
234// assertCreateOnly: no DELETE, no request off this box's key, and every
235// PUT create-only. Overwrites would also have been denied by the fake's
236// policy, but the verb must never even ask.
237func assertCreateOnly(t *testing.T, reqs []s3Request) (puts int) {
238 t.Helper()
239 key := boxid.Key(status.Label(publishMachineID))
240 for _, r := range reqs {
241 if r.Key != key {
242 t.Errorf("%s %s: publish-record touched another key", r.Method, r.Key)
243 }
244 switch r.Method {
245 case http.MethodPut:
246 puts++
247 if r.IfNoneMatch != "*" || r.IfMatch != "" {
248 t.Errorf("PUT %s with If-None-Match %q If-Match %q; want create-only", r.Key, r.IfNoneMatch, r.IfMatch)
249 }
250 case http.MethodGet:
251 default:
252 t.Errorf("publish-record sent %s %s", r.Method, r.Key)
253 }
254 }
255 return puts
256}
257
258func inPlaceLine(box string) string {
259 return "the record at control/boxes/" + box + ".json is valid AWS-signed evidence for " + box +
260 " (instance i-0abc), so nothing was written. enrollment still makes its own checks before it accepts the box"
261}
262
263func TestPublishRecordWritesAFreshBoxsRecord(t *testing.T) {
264 b := publishFixture(t)
265 box := status.Label(publishMachineID)
266 line, err := b.run(t)
267 if err != nil {
268 t.Fatal(err)
269 }
270 if want := "published the identity record for " + box + " (instance i-0abc) to control/boxes/" + box + ".json"; line != want {
271 t.Fatalf("line = %q\nwant %q", line, want)
272 }
273 raw, ok := b.model.Current(publishBucket, "control/boxes/"+box+".json")
274 if !ok {
275 t.Fatal("no record at control/boxes/<box>.json")
276 }
277 var rec map[string]any
278 if err := json.Unmarshal(raw, &rec); err != nil {
279 t.Fatal(err)
280 }
281 want := b.thisBoxRecord(t, "2026-10-06T09:00:00Z")
282 if len(rec) != 5 || rec["v"] != float64(1) || rec["box"] != box || rec["identity_document"] != want.IdentityDocument ||
283 rec["written_at"] != "2026-10-06T09:00:00Z" {
284 t.Fatalf("record = %v", rec)
285 }
286 // The record the verb wrote is evidence enrollment accepts.
287 certs, _ := b.aws.certs()
288 if _, err := (boxid.Verifier{Account: publishAccount, Certs: certs}).Enroll(raw, boxid.Expect{Box: box, AWSRegion: publishRegion}); err != nil {
289 t.Fatalf("the published record does not enroll: %v", err)
290 }
291 if puts := assertCreateOnly(t, b.requests()); puts != 1 {
292 t.Fatalf("%d PUTs, want one", puts)
293 }
294}
295
296// R3: there is no region HA gate. Run after the loop has published (its
297// publishBoxRecord, store, label and IMDS read, an hour earlier), the
298// verb keeps that record and changes nothing.
299func TestPublishRecordAfterTheLoopIsANoOp(t *testing.T) {
300 b := publishFixture(t)
301 ctx := context.Background()
302 store, err := openDeployStoreEnv(ctx, os.Getenv)
303 if err != nil {
304 t.Fatal(err)
305 }
306 box := metricsBoxLabel(os.Getenv, publishMachineID)
307 loopRead := b.imdsRead(t, recordNow.Add(-time.Hour))
308 if done, err := publishBoxRecord(ctx, store, box, func() (boxid.Record, error) { return loopRead(box) }); !done || err != nil {
309 t.Fatalf("the loop's publish: done=%v err=%v", done, err)
310 }
311 before, _ := b.model.Current(publishBucket, boxid.Key(box))
312 seenBefore := len(b.requests())
313 line, err := b.run(t)
314 if err != nil {
315 t.Fatal(err)
316 }
317 if want := inPlaceLine(box); line != want {
318 t.Fatalf("line = %q\nwant %q", line, want)
319 }
320 if after, _ := b.model.Current(publishBucket, boxid.Key(box)); string(after) != string(before) {
321 t.Fatalf("the loop's record was rewritten:\n%s\n%s", before, after)
322 }
323 if puts := assertCreateOnly(t, b.requests()[seenBefore:]); puts != 0 {
324 t.Fatalf("%d PUTs, want none", puts)
325 }
326}
327
328// Astra, round 2: an older record AWS signed for this same instance, in
329// this account and region, is evidence enrollment accepts, although its
330// document's mutable fields have changed since (a stop and start, a
331// resize). It is kept byte for byte, never replaced, and nothing is
332// written.
333func TestPublishRecordKeepsAnOlderValidRecord(t *testing.T) {
334 b := publishFixture(t)
335 box := status.Label(publishMachineID)
336 older := identityDoc(publishAccount, publishRegion, publishInstance, "2026-09-01T00:00:00Z", "t4g.micro")
337 if older == b.doc {
338 t.Fatal("the two documents are the same")
339 }
340 before := mustJSON(t, b.aws.record(t, box, older, "2026-09-01T00:05:00Z"))
341 b.model.Put(publishBucket, boxid.Key(box), before)
342 line, err := b.run(t)
343 if err != nil {
344 t.Fatal(err)
345 }
346 if want := inPlaceLine(box); line != want {
347 t.Fatalf("line = %q\nwant %q", line, want)
348 }
349 if after, _ := b.model.Current(publishBucket, boxid.Key(box)); string(after) != string(before) {
350 t.Fatalf("the older record was replaced:\n%s\n%s", before, after)
351 }
352 if puts := assertCreateOnly(t, b.requests()); puts != 0 {
353 t.Fatalf("%d PUTs, want none", puts)
354 }
355}
356
357// Anything enrollment's verifier refuses is refused, named, and left
358// alone.
359func TestPublishRecordRefusesWhatEnrollmentWouldRefuse(t *testing.T) {
360 box := status.Label(publishMachineID)
361 prefix := "refused: the record already under " + box + " is not valid evidence for this box: "
362 type held struct {
363 box, doc string
364 forger bool // signed by another key
365 edit func(*boxid.Record)
366 raw func([]byte) []byte
367 }
368 cases := map[string]struct {
369 h held
370 want string
371 }{
372 "different account": {held{doc: identityDoc("999999999999", publishRegion, publishInstance, "2026-09-01T00:00:00Z", "t4g.small")}, "it is signed for account 999999999999, and this box is in account 111122223333"},
373 "different region": {held{doc: identityDoc(publishAccount, "eu-west-2", publishInstance, "2026-09-01T00:00:00Z", "t4g.small")}, "it is signed in region eu-west-2, and this box is in eu-west-1"},
374 "another instance": {held{doc: identityDoc(publishAccount, publishRegion, "i-0zzz", "2026-09-01T00:00:00Z", "t4g.small")}, "it is signed for instance i-0zzz, and this box is i-0abc"},
375 "bad signature": {held{forger: true}, "it does not verify as AWS-signed evidence: signature: crypto/rsa: verification error"},
376 "missing signature": {held{edit: func(r *boxid.Record) { r.IdentitySignature = "" }}, "it does not verify as AWS-signed evidence: signature: crypto/rsa: verification error"},
377 "altered document": {held{edit: func(r *boxid.Record) {
378 r.IdentityDocument = base64.StdEncoding.EncodeToString([]byte(strings.Replace(identityDocFor(r), "t4g.small", "m7g.large", 1)))
379 }}, "it does not verify as AWS-signed evidence: signature: crypto/rsa: verification error"},
380 "different box": {held{box: "bert-sawing"}, `it is for box "bert-sawing"`},
381 "version 2": {held{edit: func(r *boxid.Record) { r.V = 2 }}, "its version is 2, not 1"},
382 "unknown field": {held{raw: func(b []byte) []byte { return append(bytes.TrimSuffix(b, []byte("}")), []byte(`,"extra":1}`)...) }}, "it cannot be read as an identity record"},
383 "trailing data": {held{raw: func(b []byte) []byte { return append(b, []byte(`{}`)...) }}, "it cannot be read as an identity record"},
384 "not json": {held{raw: func([]byte) []byte { return []byte("not json") }}, "it cannot be read as an identity record"},
385 "document not json": {held{edit: func(r *boxid.Record) { r.IdentityDocument = base64.StdEncoding.EncodeToString([]byte("nope")) }}, "it cannot be read as an identity record"},
386 }
387 for name, tc := range cases {
388 t.Run(name, func(t *testing.T) {
389 b := publishFixture(t)
390 signer, doc, hbox := b.aws, b.doc, box
391 if tc.h.forger {
392 signer = newTestAWS(t)
393 }
394 if tc.h.doc != "" {
395 doc = tc.h.doc
396 }
397 if tc.h.box != "" {
398 hbox = tc.h.box
399 }
400 rec := signer.record(t, hbox, doc, "2026-09-01T00:05:00Z")
401 if tc.h.edit != nil {
402 tc.h.edit(&rec)
403 }
404 before := mustJSON(t, rec)
405 if tc.h.raw != nil {
406 before = tc.h.raw(before)
407 }
408 b.model.Put(publishBucket, boxid.Key(box), before)
409 line, err := b.run(t)
410 if want := prefix + tc.want + ". nothing was changed"; err == nil || err.Error() != want {
411 t.Fatalf("line=%q err=%v\nwant %q", line, err, want)
412 }
413 if after, _ := b.model.Current(publishBucket, boxid.Key(box)); string(after) != string(before) {
414 t.Fatalf("the record was changed:\n%s\n%s", before, after)
415 }
416 if puts := assertCreateOnly(t, b.requests()); puts != 0 {
417 t.Fatalf("%d PUTs, want none", puts)
418 }
419 })
420 }
421}
422
423// identityDocFor is r's decoded identity document.
424func identityDocFor(r *boxid.Record) string {
425 doc, _ := base64.StdEncoding.DecodeString(r.IdentityDocument)
426 return string(doc)
427}
428
429// A record that arrives after the verb's read and before
430// publishBoxRecord's is held to the same verifier: publishBoxRecord alone
431// would take any record naming this instance.
432func TestPublishRecordHoldsALateRecordToTheSameVerifier(t *testing.T) {
433 box := status.Label(publishMachineID)
434 for name, tc := range map[string]struct {
435 forged bool
436 want string
437 }{
438 "valid, older": {false, inPlaceLine(box)},
439 "same instance, forged signature": {true, "refused: the record already under " + box + " is not valid evidence for this box: it does not verify as AWS-signed evidence: signature: crypto/rsa: verification error. nothing was changed"},
440 } {
441 t.Run(name, func(t *testing.T) {
442 b := publishFixture(t)
443 signer := b.aws
444 if tc.forged {
445 signer = newTestAWS(t)
446 }
447 late := mustJSON(t, signer.record(t, box, identityDoc(publishAccount, publishRegion, publishInstance, "2026-09-01T00:00:00Z", "t4g.micro"), "2026-09-01T00:05:00Z"))
448 b.afterGet = func() { b.model.Put(publishBucket, boxid.Key(box), late) }
449 line, err := b.run(t)
450 got := line
451 if err != nil {
452 got = err.Error()
453 }
454 if got != tc.want || (err != nil) != tc.forged {
455 t.Fatalf("line=%q err=%v\nwant %q", line, err, tc.want)
456 }
457 if after, _ := b.model.Current(publishBucket, boxid.Key(box)); string(after) != string(late) {
458 t.Fatalf("the late record was changed:\n%s", after)
459 }
460 if puts := assertCreateOnly(t, b.requests()); puts != 0 {
461 t.Fatalf("%d PUTs, want none", puts)
462 }
463 })
464 }
465}
466
467// The conditional create is the bucket's: a record another instance
468// creates just before the verb's write is not overwritten, because the
469// fake answers the If-None-Match: * PUT with 412.
470func TestPublishRecordLosesARaceWithoutOverwriting(t *testing.T) {
471 b := publishFixture(t)
472 box := status.Label(publishMachineID)
473 racer := mustJSON(t, b.aws.record(t, box, identityDoc(publishAccount, publishRegion, "i-0zzz", "2026-10-06T08:00:00Z", "t4g.small"), "2026-10-01T00:00:00Z"))
474 b.beforePut = func() { b.model.Put(publishBucket, boxid.Key(box), racer) }
475 want := "refused: another record already holds " + box + ": boxid: " + box + " already has a different record: store: compare-and-swap conflict. nothing was changed"
476 if line, err := b.run(t); err == nil || err.Error() != want {
477 t.Fatalf("line=%q err=%v\nwant %q", line, err, want)
478 }
479 if after, _ := b.model.Current(publishBucket, boxid.Key(box)); string(after) != string(racer) {
480 t.Fatalf("the racing record was overwritten:\n%s", after)
481 }
482 if puts := assertCreateOnly(t, b.requests()); puts != 1 {
483 t.Fatalf("%d PUTs, want the one conditional create", puts)
484 }
485}
486
487// S3 commits the create but its answer is lost (500). The SDK retries,
488// the retry is answered 412, and boxid.Publish finds exactly this run's
489// bytes. That is this run's record: published, although the store never
490// saw a successful Put.
491func TestPublishRecordReportsARetriedCreateAsPublished(t *testing.T) {
492 b := publishFixture(t)
493 box := status.Label(publishMachineID)
494 b.commitThenFail = []int{http.StatusInternalServerError}
495 line, err := b.run(t)
496 if err != nil {
497 t.Fatal(err)
498 }
499 if want := "published the identity record for " + box + " (instance i-0abc) to control/boxes/" + box + ".json"; line != want {
500 t.Fatalf("line = %q\nwant %q", line, want)
501 }
502 got, _ := b.model.Current(publishBucket, boxid.Key(box))
503 var rec boxid.Record
504 if json.Unmarshal(got, &rec) != nil || rec.WrittenAt != "2026-10-06T09:00:00Z" || rec.IdentityDocument != base64.StdEncoding.EncodeToString([]byte(b.doc)) {
505 t.Fatalf("stored %s", got)
506 }
507 if puts := assertCreateOnly(t, b.requests()); puts != 2 {
508 t.Fatalf("%d PUTs, want the create and its retry", puts)
509 }
510}
511
512// Once a create has reached the bucket, a failure does not claim nothing
513// was written. Here S3 committed it and answered 400.
514func TestPublishRecordSaysAnUncertainWriteMayHaveHappened(t *testing.T) {
515 b := publishFixture(t)
516 box := status.Label(publishMachineID)
517 b.commitThenFail = []int{http.StatusBadRequest}
518 _, err := b.run(t)
519 if err == nil || !strings.HasPrefix(err.Error(), "the identity record for "+box+" may or may not have been written: ") ||
520 !strings.HasSuffix(err.Error(), ". it is safe to run again, which only ever creates the record and says what is there") {
521 t.Fatalf("err = %v", err)
522 }
523 if _, ok := b.model.Current(publishBucket, boxid.Key(box)); !ok {
524 t.Fatal("the fake did not commit the create")
525 }
526 // The second run finds valid evidence and says so.
527 if line, err := b.run(t); err != nil || line != inPlaceLine(box) {
528 t.Fatalf("second run: line=%q err=%v", line, err)
529 }
530}
531
532// Failures before any create reached the bucket say nothing was written.
533func TestPublishRecordReportsFailuresBeforeAnyWriteAsRetryable(t *testing.T) {
534 box := status.Label(publishMachineID)
535 for name, tc := range map[string]struct {
536 imdsDown bool
537 doc string // the document IMDS serves, when not the usual one
538 reason string
539 }{
540 "IMDS down": {imdsDown: true, reason: "IMDS down"},
541 "own identity incomplete": {doc: "{\"accountId\" : \"111122223333\", \"region\" : \"eu-west-1\"}\n", reason: "the identity document AWS served this box names no account, region or instance"},
542 } {
543 t.Run(name, func(t *testing.T) {
544 b := publishFixture(t)
545 if tc.doc != "" {
546 b.doc = tc.doc
547 }
548 run := b.publishRun(t)
549 if tc.imdsDown {
550 run.read = func(string) (boxid.Record, error) { return boxid.Record{}, errors.New("IMDS down") }
551 }
552 _, err := run.publish(context.Background())
553 want := "the identity record for " + box + " was not published: " + tc.reason + ". nothing was written, so it is safe to run again"
554 if err == nil || err.Error() != want {
555 t.Fatalf("err = %v\nwant %q", err, want)
556 }
557 if puts := assertCreateOnly(t, b.requests()); puts != 0 {
558 t.Fatalf("%d PUTs, want none", puts)
559 }
560 })
561 }
562}
563
564// refusedEarly runs the verb with every later seam tripwired: it must
565// refuse before the machine id, the store, IMDS or the certificates are
566// touched.
567func refusedEarly(t *testing.T, b *boxRecordsBucket, run boxPublishRun, want string) {
568 t.Helper()
569 touched := []string{}
570 run.machineID = func() string { touched = append(touched, "machine id"); return publishMachineID }
571 run.open = func(context.Context, func(string) string) (release.Store, error) {
572 touched = append(touched, "store")
573 return nil, errors.New("opened")
574 }
575 run.read = func(string) (boxid.Record, error) { touched = append(touched, "IMDS"); return boxid.Record{}, nil }
576 run.certs = func() (map[string]*x509.Certificate, error) { touched = append(touched, "certs"); return nil, nil }
577 _, err := run.publish(context.Background())
578 if err == nil || err.Error() != want {
579 t.Fatalf("err = %v\nwant %q", err, want)
580 }
581 if len(touched) != 0 || len(b.requests()) != 0 {
582 t.Fatalf("read %v and sent %d requests before refusing", touched, len(b.requests()))
583 }
584}
585
586// Census ruling R1: root is refused before any store, IMDS or host read.
587func TestPublishRecordRefusesRoot(t *testing.T) {
588 b := publishFixture(t)
589 run := b.publishRun(t)
590 run.geteuid = func() int { return 0 }
591 refusedEarly(t, b, run, "carlos system box publish-record runs as the agent user, not root. run it with systemd-run -p User=carlos.")
592}
593
594func TestPublishRecordRefusesAFleetBox(t *testing.T) {
595 b := publishFixture(t)
596 t.Setenv("CARLOS_FLEET", "acme")
597 t.Setenv("CARLOS_FLEET_BOX", "kass-1")
598 refusedEarly(t, b, b.publishRun(t), boxRecordFleetRefusal)
599}
600
601// A deployment directory cannot refuse an overwrite (DirStore.Put checks,
602// then renames), so only an S3 bucket will do.
603func TestPublishRecordRequiresAnS3Bucket(t *testing.T) {
604 b := publishFixture(t)
605 dir := t.TempDir()
606 t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "")
607 t.Setenv("CARLOS_DEPLOYMENT_DIR", dir)
608 refusedEarly(t, b, b.publishRun(t), boxRecordNoBucket)
609 if entries, _ := os.ReadDir(dir); len(entries) != 0 {
610 t.Fatalf("the deployment directory was written: %v", entries)
611 }
612 // Belt and braces: a store that is not S3 is refused even with a
613 // bucket named.
614 t.Setenv("CARLOS_DEPLOYMENT_BUCKET", publishBucket)
615 run := b.publishRun(t)
616 run.open = func(context.Context, func(string) string) (release.Store, error) {
617 return release.NewDirStore(dir), nil
618 }
619 if _, err := run.publish(context.Background()); err == nil || err.Error() != boxRecordNoBucket {
620 t.Fatalf("err = %v", err)
621 }
622 if entries, _ := os.ReadDir(dir); len(entries) != 0 {
623 t.Fatalf("the deployment directory was written: %v", entries)
624 }
625}
626
627// A box its region's group launched publishes under its group reference,
628// the label boxRecordLoop takes (metricsBoxLabel).
629func TestPublishRecordUsesTheGroupReference(t *testing.T) {
630 b := publishFixture(t)
631 t.Setenv("CARLOS_BOX_REFERENCE", "group")
632 withGroupIdentity(t, publishAccount, publishInstance, nil)
633 ref := hostactors.GroupBoxReference(publishAccount, publishInstance)
634 if loop := metricsBoxLabel(os.Getenv, publishMachineID); loop != ref {
635 t.Fatalf("the loop's label is %q, want the group reference %q", loop, ref)
636 }
637 line, err := b.run(t)
638 if err != nil {
639 t.Fatal(err)
640 }
641 if want := "published the identity record for " + ref + " (instance i-0abc) to control/boxes/" + ref + ".json"; line != want {
642 t.Fatalf("line = %q\nwant %q", line, want)
643 }
644 raw, ok := b.model.Current(publishBucket, boxid.Key(ref))
645 var rec boxid.Record
646 if !ok || json.Unmarshal(raw, &rec) != nil || rec.Box != ref {
647 t.Fatalf("record under the group reference: %s", raw)
648 }
649 if _, ok := b.model.Current(publishBucket, boxid.Key(status.Label(publishMachineID))); ok {
650 t.Fatal("a group box published under its machine-id label")
651 }
652}
653
654// The verb's certificates are enrollment's: boxid.EmbeddedCerts covers
655// the dev region, so a real box's record can verify.
656func TestPublishRecordProductionCertsCoverTheDevRegion(t *testing.T) {
657 certs, err := boxid.EmbeddedCerts()
658 if err != nil || certs[publishRegion] == nil {
659 t.Fatalf("embedded certs: %v (eu-west-1 present: %v)", err, certs[publishRegion] != nil)
660 }
661}
662
663// Census ruling R4: the store publish-record hands publishBoxRecord sends
664// the bucket nothing for a delete, a list, an overwrite or any other key.
665func TestBoxRecordStoreOnlyReadsAndCreatesItsOwnKey(t *testing.T) {
666 b := publishFixture(t)
667 ctx := context.Background()
668 inner, err := openDeployStoreEnv(ctx, os.Getenv)
669 if err != nil {
670 t.Fatal(err)
671 }
672 if _, ok := inner.(*release.S3Store); !ok {
673 t.Fatalf("store is %T, want the S3Store", inner)
674 }
675 s := &boxRecordStore{inner: inner, key: boxid.Key("annie-welding")}
676 if _, err := s.Put(ctx, boxid.Key("annie-welding"), []byte("{}"), `"etag"`); !errors.Is(err, errBoxRecordStore) {
677 t.Errorf("overwrite (replace-only Put): %v", err)
678 }
679 if _, err := s.Put(ctx, boxid.Key("bert-sawing"), []byte("{}"), ""); !errors.Is(err, errBoxRecordStore) {
680 t.Errorf("Put on another box's key: %v", err)
681 }
682 if _, err := s.Put(ctx, "control/serial", []byte("{}"), ""); !errors.Is(err, errBoxRecordStore) {
683 t.Errorf("Put on another key: %v", err)
684 }
685 if _, _, err := s.Get(ctx, boxid.Key("bert-sawing")); !errors.Is(err, errBoxRecordStore) {
686 t.Errorf("Get on another box's key: %v", err)
687 }
688 if err := s.Delete(ctx, boxid.Key("annie-welding")); !errors.Is(err, errBoxRecordStore) {
689 t.Errorf("Delete: %v", err)
690 }
691 if _, err := s.List(ctx, boxid.Prefix); !errors.Is(err, errBoxRecordStore) {
692 t.Errorf("List: %v", err)
693 }
694 if _, err := s.ListDirs(ctx, "control/"); !errors.Is(err, errBoxRecordStore) {
695 t.Errorf("ListDirs: %v", err)
696 }
697 if reqs := b.requests(); len(reqs) != 0 || s.attempted || s.wrote {
698 t.Fatalf("refused calls reached S3: %v (attempted=%v wrote=%v)", reqs, s.attempted, s.wrote)
699 }
700 if _, err := s.Put(ctx, boxid.Key("annie-welding"), []byte("{}"), ""); err != nil || !s.wrote {
701 t.Fatalf("create: err=%v wrote=%v", err, s.wrote)
702 }
703 if _, err := s.Put(ctx, boxid.Key("annie-welding"), []byte("{}"), ""); !errors.Is(err, release.ErrCASConflict) {
704 t.Fatalf("a second create: %v, want the bucket's 412 as ErrCASConflict", err)
705 }
706 if reqs := b.requests(); len(reqs) != 2 || reqs[0].IfNoneMatch != "*" || reqs[1].IfNoneMatch != "*" {
707 t.Fatalf("requests %v, want two create-only PUTs", reqs)
708 }
709}
710
711// boxRecordLoop's gate is unchanged: with CARLOS_REGION_HA off, or on a
712// fleet box, it returns at once and never reaches the bucket. Past the
713// gate it would retry each minute, so a broken gate shows as a timeout.
714func TestBoxRecordLoopGateIsUnchanged(t *testing.T) {
715 for name, env := range map[string]map[string]string{
716 "region HA off": {"CARLOS_REGION_HA": ""},
717 "region HA unparseable": {"CARLOS_REGION_HA": "yes"},
718 "fleet box, region HA on": {"CARLOS_REGION_HA": "1", "CARLOS_FLEET": "acme", "CARLOS_FLEET_BOX": "kass-1"},
719 } {
720 t.Run(name, func(t *testing.T) {
721 b := publishFixture(t)
722 for k, v := range env {
723 t.Setenv(k, v)
724 }
725 done := make(chan struct{})
726 go func() { boxRecordLoop(); close(done) }()
727 select {
728 case <-done:
729 case <-time.After(10 * time.Second):
730 t.Fatal("boxRecordLoop did not return: its region HA gate let it run")
731 }
732 if n := len(b.requests()); n != 0 {
733 t.Fatalf("boxRecordLoop sent %d requests with its gate shut", n)
734 }
735 })
736 }
737}
738
739// Box publication reaches the deployment bucket through openDeployStoreEnv
740// and requires the *release.S3Store it returns (boxpublish.go's
741// opened.(*release.S3Store)). The change index must not cost it that type:
742// the opener hands back the same concrete store with the index enabled,
743// against the real S3 wiring, and publication still writes only its own
744// control/boxes/ key (a key outside every change scope, so no index traffic).
745// Not parallel: publishFixture sets the process environment.
746func TestBoxPublishThroughDeploymentOpener(t *testing.T) {
747 b := publishFixture(t)
748 ctx := context.Background()
749 opened, err := openDeployStoreEnv(ctx, os.Getenv)
750 if err != nil {
751 t.Fatal(err)
752 }
753 if _, ok := opened.(*release.S3Store); !ok {
754 t.Fatalf("openDeployStoreEnv returned %T; box publication needs the *release.S3Store", opened)
755 }
756 // The opener enabled the index: a scoped write bumps its generation.
757 if _, err := opened.Put(ctx, "accounts/shop/manifest.json", []byte("{}"), ""); err != nil {
758 t.Fatal(err)
759 }
760 idx, _, err := release.ReadChangeIndex(ctx, opened)
761 if err != nil {
762 t.Fatal(err)
763 }
764 if got := idx.Entries["accounts/shop"]; got != 1 {
765 t.Fatalf("accounts/shop generation = %d after one scoped Put, want 1 (entries %v)", got, idx.Entries)
766 }
767 // Publication itself is unchanged: one create-only PUT on its own key.
768 before := len(b.requests())
769 if _, err := b.run(t); err != nil {
770 t.Fatal(err)
771 }
772 if puts := assertCreateOnly(t, b.requests()[before:]); puts != 1 {
773 t.Fatalf("%d PUTs, want one", puts)
774 }
775}
776