package host import ( "bytes" "context" "crypto" "crypto/rand" "crypto/rsa" "crypto/sha256" "crypto/x509" "crypto/x509/pkix" "encoding/base64" "encoding/json" "errors" "math/big" "net/http" "net/http/httptest" "os" "strings" "sync" "testing" "time" "github.com/carlosframework/platform/internal/boxid" "github.com/carlosframework/platform/internal/hostactors" "github.com/carlosframework/platform/internal/lease/leasetest" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/status" ) // carlos system box publish-record, through the production wiring: the // real S3Store (openDeployStoreEnv over CARLOS_DEPLOYMENT_BUCKET and // CARLOS_S3_ENDPOINT) against leasetest's S3 server, which enforces // If-None-Match: * itself, behind the dev bucket's box_records policy; // the real IMDS reader over fakeIMDS (boxrecord_test.go); and enrollment's // own verifier over documents a test AWS signs. const ( publishMachineID = "0123456789abcdef0123456789abcdef" publishBucket = "deploy" publishAgentUID = 999 publishAccount = "111122223333" publishRegion = "eu-west-1" publishInstance = "i-0abc" ) // testAWS stands in for AWS's instance-identity signer the way boxid's // own tests do (boxid/internal/signtest, which this package cannot // import): one RSA key, a self-signed certificate for it, and PKCS #1 // v1.5 SHA-256 signatures over identity documents. type testAWS struct { key *rsa.PrivateKey cert *x509.Certificate } func newTestAWS(t testing.TB) *testAWS { t.Helper() key, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatal(err) } tmpl := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "test"}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour)} der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) if err != nil { t.Fatal(err) } cert, err := x509.ParseCertificate(der) if err != nil { t.Fatal(err) } return &testAWS{key: key, cert: cert} } // identityDoc is an identity document as IMDS serves one, with the two // fields that change over an instance's life. func identityDoc(account, region, instance, pendingTime, instanceType string) string { return "{\n \"accountId\" : \"" + account + "\",\n \"instanceId\" : \"" + instance + "\",\n \"instanceType\" : \"" + instanceType + "\",\n \"pendingTime\" : \"" + pendingTime + "\",\n \"region\" : \"" + region + "\"\n}\n" } func (a *testAWS) sign(t testing.TB, doc string) string { t.Helper() sum := sha256.Sum256([]byte(doc)) sig, err := rsa.SignPKCS1v15(rand.Reader, a.key, crypto.SHA256, sum[:]) if err != nil { t.Fatal(err) } return base64.StdEncoding.EncodeToString(sig) } // certs certifies eu-west-1, this box's region, and eu-west-2, so a record // signed in another supported region verifies and is still refused. func (a *testAWS) certs() (map[string]*x509.Certificate, error) { return map[string]*x509.Certificate{"eu-west-1": a.cert, "eu-west-2": a.cert}, nil } func (a *testAWS) record(t testing.TB, box, doc, writtenAt string) boxid.Record { return boxid.Record{V: 1, Box: box, IdentityDocument: base64.StdEncoding.EncodeToString([]byte(doc)), IdentitySignature: a.sign(t, doc), WrittenAt: writtenAt} } // s3Request is one request the fake S3 saw. type s3Request struct{ Method, Key, IfNoneMatch, IfMatch string } // boxRecordsBucket is the fake deployment bucket: leasetest's versioned S3 // server, behind the dev bucket's deny-only box_records policy. Under // control/boxes/ a PUT without If-None-Match: * is AccessDenied // (BoxRecordsAreCreateOnly) and so is any DELETE // (BoxRecordsAreNeverDeleted). Every request is recorded. aws and doc // are the box's AWS and the identity document IMDS serves it now. // // Hooks, each used once: afterGet runs after the first GET has been // answered (a writer arriving between the verb's read and // publishBoxRecord's); beforePut runs before the first PUT reaches the // server (a writer arriving just before the create). commitThenFail lists // statuses for successive PUTs: each such PUT is applied and then answered // with that status instead, as when S3 commits a write whose answer is // lost. type boxRecordsBucket struct { model *leasetest.VersionedBucket mu sync.Mutex seen []s3Request aws *testAWS doc string afterGet func() beforePut func() commitThenFail []int } func (b *boxRecordsBucket) requests() []s3Request { b.mu.Lock() defer b.mu.Unlock() return append([]s3Request(nil), b.seen...) } func (b *boxRecordsBucket) handler() http.Handler { next, _ := leasetest.NewS3Server(b.model) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, key, _ := strings.Cut(strings.TrimPrefix(r.URL.Path, "/"), "/") b.mu.Lock() b.seen = append(b.seen, s3Request{r.Method, key, r.Header.Get("If-None-Match"), r.Header.Get("If-Match")}) var afterGet, beforePut func() fail := 0 switch r.Method { case http.MethodGet: afterGet, b.afterGet = b.afterGet, nil case http.MethodPut: beforePut, b.beforePut = b.beforePut, nil if len(b.commitThenFail) > 0 { fail, b.commitThenFail = b.commitThenFail[0], b.commitThenFail[1:] } } b.mu.Unlock() if strings.HasPrefix(key, boxid.Prefix) { if r.Method == http.MethodDelete || (r.Method == http.MethodPut && r.Header.Get("If-None-Match") != "*") { s3Error(w, http.StatusForbidden, "AccessDenied") return } } if beforePut != nil { beforePut() } if fail != 0 { next.ServeHTTP(httptest.NewRecorder(), r) s3Error(w, fail, http.StatusText(fail)) return } next.ServeHTTP(w, r) if afterGet != nil { afterGet() } }) } func s3Error(w http.ResponseWriter, status int, code string) { w.Header().Set("Content-Type", "application/xml") w.WriteHeader(status) _, _ = w.Write([]byte("" + strings.ReplaceAll(code, " ", "") + "" + code + "")) } // publishFixture points the process's AWS and CARLOS_* environment at a // fresh fake bucket, as host.env would on a box, and makes the box's AWS. func publishFixture(t *testing.T) *boxRecordsBucket { t.Helper() b := &boxRecordsBucket{model: leasetest.NewVersionedBucket(), aws: newTestAWS(t), doc: identityDoc(publishAccount, publishRegion, publishInstance, "2026-10-06T08:00:00Z", "t4g.small")} b.model.AddBucket(publishBucket) server := httptest.NewServer(b.handler()) t.Cleanup(server.Close) for k, v := range map[string]string{ "AWS_ACCESS_KEY_ID": "test-key", "AWS_SECRET_ACCESS_KEY": "test-secret", "AWS_SESSION_TOKEN": "", "AWS_REGION": "eu-west-1", "AWS_EC2_METADATA_DISABLED": "true", "AWS_CONFIG_FILE": "/nonexistent", "AWS_SHARED_CREDENTIALS_FILE": "/nonexistent", "CARLOS_S3_ENDPOINT": server.URL, "CARLOS_DEPLOYMENT_BUCKET": publishBucket, "CARLOS_DEPLOYMENT_DIR": "", "CARLOS_FLEET": "", "CARLOS_FLEET_BOX": "", "CARLOS_BOX_REFERENCE": "", "CARLOS_REGION_HA": "", } { t.Setenv(k, v) } return b } // imdsRead is the real IMDS reader over fakeIMDS serving b's current // document and AWS's signature over it. func (b *boxRecordsBucket) imdsRead(t *testing.T, written time.Time) func(string) (boxid.Record, error) { client := imdsClient(&fakeIMDS{doc: b.doc, sig: b.aws.sign(t, b.doc)}) return func(box string) (boxid.Record, error) { return readIdentityRecord(client, "http://imds.test", box, written) } } // publishRun is the verb's production wiring with the test's seams: the // agent user's uid, the fixed machine id, the fake IMDS and the test // AWS's certificates. func (b *boxRecordsBucket) publishRun(t *testing.T) boxPublishRun { return boxPublishRun{getenv: os.Getenv, geteuid: func() int { return publishAgentUID }, machineID: func() string { return publishMachineID }, open: openDeployStoreEnv, read: b.imdsRead(t, recordNow), certs: b.aws.certs} } func (b *boxRecordsBucket) run(t *testing.T) (string, error) { t.Helper() return b.publishRun(t).publish(context.Background()) } // thisBoxRecord is the record this box would publish now, written at // writtenAt. func (b *boxRecordsBucket) thisBoxRecord(t *testing.T, writtenAt string) boxid.Record { return b.aws.record(t, status.Label(publishMachineID), b.doc, writtenAt) } // assertCreateOnly: no DELETE, no request off this box's key, and every // PUT create-only. Overwrites would also have been denied by the fake's // policy, but the verb must never even ask. func assertCreateOnly(t *testing.T, reqs []s3Request) (puts int) { t.Helper() key := boxid.Key(status.Label(publishMachineID)) for _, r := range reqs { if r.Key != key { t.Errorf("%s %s: publish-record touched another key", r.Method, r.Key) } switch r.Method { case http.MethodPut: puts++ if r.IfNoneMatch != "*" || r.IfMatch != "" { t.Errorf("PUT %s with If-None-Match %q If-Match %q; want create-only", r.Key, r.IfNoneMatch, r.IfMatch) } case http.MethodGet: default: t.Errorf("publish-record sent %s %s", r.Method, r.Key) } } return puts } func inPlaceLine(box string) string { return "the record at control/boxes/" + box + ".json is valid AWS-signed evidence for " + box + " (instance i-0abc), so nothing was written. enrollment still makes its own checks before it accepts the box" } func TestPublishRecordWritesAFreshBoxsRecord(t *testing.T) { b := publishFixture(t) box := status.Label(publishMachineID) line, err := b.run(t) if err != nil { t.Fatal(err) } if want := "published the identity record for " + box + " (instance i-0abc) to control/boxes/" + box + ".json"; line != want { t.Fatalf("line = %q\nwant %q", line, want) } raw, ok := b.model.Current(publishBucket, "control/boxes/"+box+".json") if !ok { t.Fatal("no record at control/boxes/.json") } var rec map[string]any if err := json.Unmarshal(raw, &rec); err != nil { t.Fatal(err) } want := b.thisBoxRecord(t, "2026-10-06T09:00:00Z") if len(rec) != 5 || rec["v"] != float64(1) || rec["box"] != box || rec["identity_document"] != want.IdentityDocument || rec["written_at"] != "2026-10-06T09:00:00Z" { t.Fatalf("record = %v", rec) } // The record the verb wrote is evidence enrollment accepts. certs, _ := b.aws.certs() if _, err := (boxid.Verifier{Account: publishAccount, Certs: certs}).Enroll(raw, boxid.Expect{Box: box, AWSRegion: publishRegion}); err != nil { t.Fatalf("the published record does not enroll: %v", err) } if puts := assertCreateOnly(t, b.requests()); puts != 1 { t.Fatalf("%d PUTs, want one", puts) } } // R3: there is no region HA gate. Run after the loop has published (its // publishBoxRecord, store, label and IMDS read, an hour earlier), the // verb keeps that record and changes nothing. func TestPublishRecordAfterTheLoopIsANoOp(t *testing.T) { b := publishFixture(t) ctx := context.Background() store, err := openDeployStoreEnv(ctx, os.Getenv) if err != nil { t.Fatal(err) } box := metricsBoxLabel(os.Getenv, publishMachineID) loopRead := b.imdsRead(t, recordNow.Add(-time.Hour)) if done, err := publishBoxRecord(ctx, store, box, func() (boxid.Record, error) { return loopRead(box) }); !done || err != nil { t.Fatalf("the loop's publish: done=%v err=%v", done, err) } before, _ := b.model.Current(publishBucket, boxid.Key(box)) seenBefore := len(b.requests()) line, err := b.run(t) if err != nil { t.Fatal(err) } if want := inPlaceLine(box); line != want { t.Fatalf("line = %q\nwant %q", line, want) } if after, _ := b.model.Current(publishBucket, boxid.Key(box)); string(after) != string(before) { t.Fatalf("the loop's record was rewritten:\n%s\n%s", before, after) } if puts := assertCreateOnly(t, b.requests()[seenBefore:]); puts != 0 { t.Fatalf("%d PUTs, want none", puts) } } // Astra, round 2: an older record AWS signed for this same instance, in // this account and region, is evidence enrollment accepts, although its // document's mutable fields have changed since (a stop and start, a // resize). It is kept byte for byte, never replaced, and nothing is // written. func TestPublishRecordKeepsAnOlderValidRecord(t *testing.T) { b := publishFixture(t) box := status.Label(publishMachineID) older := identityDoc(publishAccount, publishRegion, publishInstance, "2026-09-01T00:00:00Z", "t4g.micro") if older == b.doc { t.Fatal("the two documents are the same") } before := mustJSON(t, b.aws.record(t, box, older, "2026-09-01T00:05:00Z")) b.model.Put(publishBucket, boxid.Key(box), before) line, err := b.run(t) if err != nil { t.Fatal(err) } if want := inPlaceLine(box); line != want { t.Fatalf("line = %q\nwant %q", line, want) } if after, _ := b.model.Current(publishBucket, boxid.Key(box)); string(after) != string(before) { t.Fatalf("the older record was replaced:\n%s\n%s", before, after) } if puts := assertCreateOnly(t, b.requests()); puts != 0 { t.Fatalf("%d PUTs, want none", puts) } } // Anything enrollment's verifier refuses is refused, named, and left // alone. func TestPublishRecordRefusesWhatEnrollmentWouldRefuse(t *testing.T) { box := status.Label(publishMachineID) prefix := "refused: the record already under " + box + " is not valid evidence for this box: " type held struct { box, doc string forger bool // signed by another key edit func(*boxid.Record) raw func([]byte) []byte } cases := map[string]struct { h held want string }{ "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"}, "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"}, "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"}, "bad signature": {held{forger: true}, "it does not verify as AWS-signed evidence: signature: crypto/rsa: verification error"}, "missing signature": {held{edit: func(r *boxid.Record) { r.IdentitySignature = "" }}, "it does not verify as AWS-signed evidence: signature: crypto/rsa: verification error"}, "altered document": {held{edit: func(r *boxid.Record) { r.IdentityDocument = base64.StdEncoding.EncodeToString([]byte(strings.Replace(identityDocFor(r), "t4g.small", "m7g.large", 1))) }}, "it does not verify as AWS-signed evidence: signature: crypto/rsa: verification error"}, "different box": {held{box: "bert-sawing"}, `it is for box "bert-sawing"`}, "version 2": {held{edit: func(r *boxid.Record) { r.V = 2 }}, "its version is 2, not 1"}, "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"}, "trailing data": {held{raw: func(b []byte) []byte { return append(b, []byte(`{}`)...) }}, "it cannot be read as an identity record"}, "not json": {held{raw: func([]byte) []byte { return []byte("not json") }}, "it cannot be read as an identity record"}, "document not json": {held{edit: func(r *boxid.Record) { r.IdentityDocument = base64.StdEncoding.EncodeToString([]byte("nope")) }}, "it cannot be read as an identity record"}, } for name, tc := range cases { t.Run(name, func(t *testing.T) { b := publishFixture(t) signer, doc, hbox := b.aws, b.doc, box if tc.h.forger { signer = newTestAWS(t) } if tc.h.doc != "" { doc = tc.h.doc } if tc.h.box != "" { hbox = tc.h.box } rec := signer.record(t, hbox, doc, "2026-09-01T00:05:00Z") if tc.h.edit != nil { tc.h.edit(&rec) } before := mustJSON(t, rec) if tc.h.raw != nil { before = tc.h.raw(before) } b.model.Put(publishBucket, boxid.Key(box), before) line, err := b.run(t) if want := prefix + tc.want + ". nothing was changed"; err == nil || err.Error() != want { t.Fatalf("line=%q err=%v\nwant %q", line, err, want) } if after, _ := b.model.Current(publishBucket, boxid.Key(box)); string(after) != string(before) { t.Fatalf("the record was changed:\n%s\n%s", before, after) } if puts := assertCreateOnly(t, b.requests()); puts != 0 { t.Fatalf("%d PUTs, want none", puts) } }) } } // identityDocFor is r's decoded identity document. func identityDocFor(r *boxid.Record) string { doc, _ := base64.StdEncoding.DecodeString(r.IdentityDocument) return string(doc) } // A record that arrives after the verb's read and before // publishBoxRecord's is held to the same verifier: publishBoxRecord alone // would take any record naming this instance. func TestPublishRecordHoldsALateRecordToTheSameVerifier(t *testing.T) { box := status.Label(publishMachineID) for name, tc := range map[string]struct { forged bool want string }{ "valid, older": {false, inPlaceLine(box)}, "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"}, } { t.Run(name, func(t *testing.T) { b := publishFixture(t) signer := b.aws if tc.forged { signer = newTestAWS(t) } late := mustJSON(t, signer.record(t, box, identityDoc(publishAccount, publishRegion, publishInstance, "2026-09-01T00:00:00Z", "t4g.micro"), "2026-09-01T00:05:00Z")) b.afterGet = func() { b.model.Put(publishBucket, boxid.Key(box), late) } line, err := b.run(t) got := line if err != nil { got = err.Error() } if got != tc.want || (err != nil) != tc.forged { t.Fatalf("line=%q err=%v\nwant %q", line, err, tc.want) } if after, _ := b.model.Current(publishBucket, boxid.Key(box)); string(after) != string(late) { t.Fatalf("the late record was changed:\n%s", after) } if puts := assertCreateOnly(t, b.requests()); puts != 0 { t.Fatalf("%d PUTs, want none", puts) } }) } } // The conditional create is the bucket's: a record another instance // creates just before the verb's write is not overwritten, because the // fake answers the If-None-Match: * PUT with 412. func TestPublishRecordLosesARaceWithoutOverwriting(t *testing.T) { b := publishFixture(t) box := status.Label(publishMachineID) 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")) b.beforePut = func() { b.model.Put(publishBucket, boxid.Key(box), racer) } want := "refused: another record already holds " + box + ": boxid: " + box + " already has a different record: store: compare-and-swap conflict. nothing was changed" if line, err := b.run(t); err == nil || err.Error() != want { t.Fatalf("line=%q err=%v\nwant %q", line, err, want) } if after, _ := b.model.Current(publishBucket, boxid.Key(box)); string(after) != string(racer) { t.Fatalf("the racing record was overwritten:\n%s", after) } if puts := assertCreateOnly(t, b.requests()); puts != 1 { t.Fatalf("%d PUTs, want the one conditional create", puts) } } // S3 commits the create but its answer is lost (500). The SDK retries, // the retry is answered 412, and boxid.Publish finds exactly this run's // bytes. That is this run's record: published, although the store never // saw a successful Put. func TestPublishRecordReportsARetriedCreateAsPublished(t *testing.T) { b := publishFixture(t) box := status.Label(publishMachineID) b.commitThenFail = []int{http.StatusInternalServerError} line, err := b.run(t) if err != nil { t.Fatal(err) } if want := "published the identity record for " + box + " (instance i-0abc) to control/boxes/" + box + ".json"; line != want { t.Fatalf("line = %q\nwant %q", line, want) } got, _ := b.model.Current(publishBucket, boxid.Key(box)) var rec boxid.Record if json.Unmarshal(got, &rec) != nil || rec.WrittenAt != "2026-10-06T09:00:00Z" || rec.IdentityDocument != base64.StdEncoding.EncodeToString([]byte(b.doc)) { t.Fatalf("stored %s", got) } if puts := assertCreateOnly(t, b.requests()); puts != 2 { t.Fatalf("%d PUTs, want the create and its retry", puts) } } // Once a create has reached the bucket, a failure does not claim nothing // was written. Here S3 committed it and answered 400. func TestPublishRecordSaysAnUncertainWriteMayHaveHappened(t *testing.T) { b := publishFixture(t) box := status.Label(publishMachineID) b.commitThenFail = []int{http.StatusBadRequest} _, err := b.run(t) if err == nil || !strings.HasPrefix(err.Error(), "the identity record for "+box+" may or may not have been written: ") || !strings.HasSuffix(err.Error(), ". it is safe to run again, which only ever creates the record and says what is there") { t.Fatalf("err = %v", err) } if _, ok := b.model.Current(publishBucket, boxid.Key(box)); !ok { t.Fatal("the fake did not commit the create") } // The second run finds valid evidence and says so. if line, err := b.run(t); err != nil || line != inPlaceLine(box) { t.Fatalf("second run: line=%q err=%v", line, err) } } // Failures before any create reached the bucket say nothing was written. func TestPublishRecordReportsFailuresBeforeAnyWriteAsRetryable(t *testing.T) { box := status.Label(publishMachineID) for name, tc := range map[string]struct { imdsDown bool doc string // the document IMDS serves, when not the usual one reason string }{ "IMDS down": {imdsDown: true, reason: "IMDS down"}, "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"}, } { t.Run(name, func(t *testing.T) { b := publishFixture(t) if tc.doc != "" { b.doc = tc.doc } run := b.publishRun(t) if tc.imdsDown { run.read = func(string) (boxid.Record, error) { return boxid.Record{}, errors.New("IMDS down") } } _, err := run.publish(context.Background()) want := "the identity record for " + box + " was not published: " + tc.reason + ". nothing was written, so it is safe to run again" if err == nil || err.Error() != want { t.Fatalf("err = %v\nwant %q", err, want) } if puts := assertCreateOnly(t, b.requests()); puts != 0 { t.Fatalf("%d PUTs, want none", puts) } }) } } // refusedEarly runs the verb with every later seam tripwired: it must // refuse before the machine id, the store, IMDS or the certificates are // touched. func refusedEarly(t *testing.T, b *boxRecordsBucket, run boxPublishRun, want string) { t.Helper() touched := []string{} run.machineID = func() string { touched = append(touched, "machine id"); return publishMachineID } run.open = func(context.Context, func(string) string) (release.Store, error) { touched = append(touched, "store") return nil, errors.New("opened") } run.read = func(string) (boxid.Record, error) { touched = append(touched, "IMDS"); return boxid.Record{}, nil } run.certs = func() (map[string]*x509.Certificate, error) { touched = append(touched, "certs"); return nil, nil } _, err := run.publish(context.Background()) if err == nil || err.Error() != want { t.Fatalf("err = %v\nwant %q", err, want) } if len(touched) != 0 || len(b.requests()) != 0 { t.Fatalf("read %v and sent %d requests before refusing", touched, len(b.requests())) } } // Census ruling R1: root is refused before any store, IMDS or host read. func TestPublishRecordRefusesRoot(t *testing.T) { b := publishFixture(t) run := b.publishRun(t) run.geteuid = func() int { return 0 } refusedEarly(t, b, run, "carlos system box publish-record runs as the agent user, not root. run it with systemd-run -p User=carlos.") } func TestPublishRecordRefusesAFleetBox(t *testing.T) { b := publishFixture(t) t.Setenv("CARLOS_FLEET", "acme") t.Setenv("CARLOS_FLEET_BOX", "kass-1") refusedEarly(t, b, b.publishRun(t), boxRecordFleetRefusal) } // A deployment directory cannot refuse an overwrite (DirStore.Put checks, // then renames), so only an S3 bucket will do. func TestPublishRecordRequiresAnS3Bucket(t *testing.T) { b := publishFixture(t) dir := t.TempDir() t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "") t.Setenv("CARLOS_DEPLOYMENT_DIR", dir) refusedEarly(t, b, b.publishRun(t), boxRecordNoBucket) if entries, _ := os.ReadDir(dir); len(entries) != 0 { t.Fatalf("the deployment directory was written: %v", entries) } // Belt and braces: a store that is not S3 is refused even with a // bucket named. t.Setenv("CARLOS_DEPLOYMENT_BUCKET", publishBucket) run := b.publishRun(t) run.open = func(context.Context, func(string) string) (release.Store, error) { return release.NewDirStore(dir), nil } if _, err := run.publish(context.Background()); err == nil || err.Error() != boxRecordNoBucket { t.Fatalf("err = %v", err) } if entries, _ := os.ReadDir(dir); len(entries) != 0 { t.Fatalf("the deployment directory was written: %v", entries) } } // A box its region's group launched publishes under its group reference, // the label boxRecordLoop takes (metricsBoxLabel). func TestPublishRecordUsesTheGroupReference(t *testing.T) { b := publishFixture(t) t.Setenv("CARLOS_BOX_REFERENCE", "group") withGroupIdentity(t, publishAccount, publishInstance, nil) ref := hostactors.GroupBoxReference(publishAccount, publishInstance) if loop := metricsBoxLabel(os.Getenv, publishMachineID); loop != ref { t.Fatalf("the loop's label is %q, want the group reference %q", loop, ref) } line, err := b.run(t) if err != nil { t.Fatal(err) } if want := "published the identity record for " + ref + " (instance i-0abc) to control/boxes/" + ref + ".json"; line != want { t.Fatalf("line = %q\nwant %q", line, want) } raw, ok := b.model.Current(publishBucket, boxid.Key(ref)) var rec boxid.Record if !ok || json.Unmarshal(raw, &rec) != nil || rec.Box != ref { t.Fatalf("record under the group reference: %s", raw) } if _, ok := b.model.Current(publishBucket, boxid.Key(status.Label(publishMachineID))); ok { t.Fatal("a group box published under its machine-id label") } } // The verb's certificates are enrollment's: boxid.EmbeddedCerts covers // the dev region, so a real box's record can verify. func TestPublishRecordProductionCertsCoverTheDevRegion(t *testing.T) { certs, err := boxid.EmbeddedCerts() if err != nil || certs[publishRegion] == nil { t.Fatalf("embedded certs: %v (eu-west-1 present: %v)", err, certs[publishRegion] != nil) } } // Census ruling R4: the store publish-record hands publishBoxRecord sends // the bucket nothing for a delete, a list, an overwrite or any other key. func TestBoxRecordStoreOnlyReadsAndCreatesItsOwnKey(t *testing.T) { b := publishFixture(t) ctx := context.Background() inner, err := openDeployStoreEnv(ctx, os.Getenv) if err != nil { t.Fatal(err) } if _, ok := inner.(*release.S3Store); !ok { t.Fatalf("store is %T, want the S3Store", inner) } s := &boxRecordStore{inner: inner, key: boxid.Key("annie-welding")} if _, err := s.Put(ctx, boxid.Key("annie-welding"), []byte("{}"), `"etag"`); !errors.Is(err, errBoxRecordStore) { t.Errorf("overwrite (replace-only Put): %v", err) } if _, err := s.Put(ctx, boxid.Key("bert-sawing"), []byte("{}"), ""); !errors.Is(err, errBoxRecordStore) { t.Errorf("Put on another box's key: %v", err) } if _, err := s.Put(ctx, "control/serial", []byte("{}"), ""); !errors.Is(err, errBoxRecordStore) { t.Errorf("Put on another key: %v", err) } if _, _, err := s.Get(ctx, boxid.Key("bert-sawing")); !errors.Is(err, errBoxRecordStore) { t.Errorf("Get on another box's key: %v", err) } if err := s.Delete(ctx, boxid.Key("annie-welding")); !errors.Is(err, errBoxRecordStore) { t.Errorf("Delete: %v", err) } if _, err := s.List(ctx, boxid.Prefix); !errors.Is(err, errBoxRecordStore) { t.Errorf("List: %v", err) } if _, err := s.ListDirs(ctx, "control/"); !errors.Is(err, errBoxRecordStore) { t.Errorf("ListDirs: %v", err) } if reqs := b.requests(); len(reqs) != 0 || s.attempted || s.wrote { t.Fatalf("refused calls reached S3: %v (attempted=%v wrote=%v)", reqs, s.attempted, s.wrote) } if _, err := s.Put(ctx, boxid.Key("annie-welding"), []byte("{}"), ""); err != nil || !s.wrote { t.Fatalf("create: err=%v wrote=%v", err, s.wrote) } if _, err := s.Put(ctx, boxid.Key("annie-welding"), []byte("{}"), ""); !errors.Is(err, release.ErrCASConflict) { t.Fatalf("a second create: %v, want the bucket's 412 as ErrCASConflict", err) } if reqs := b.requests(); len(reqs) != 2 || reqs[0].IfNoneMatch != "*" || reqs[1].IfNoneMatch != "*" { t.Fatalf("requests %v, want two create-only PUTs", reqs) } } // boxRecordLoop's gate is unchanged: with CARLOS_REGION_HA off, or on a // fleet box, it returns at once and never reaches the bucket. Past the // gate it would retry each minute, so a broken gate shows as a timeout. func TestBoxRecordLoopGateIsUnchanged(t *testing.T) { for name, env := range map[string]map[string]string{ "region HA off": {"CARLOS_REGION_HA": ""}, "region HA unparseable": {"CARLOS_REGION_HA": "yes"}, "fleet box, region HA on": {"CARLOS_REGION_HA": "1", "CARLOS_FLEET": "acme", "CARLOS_FLEET_BOX": "kass-1"}, } { t.Run(name, func(t *testing.T) { b := publishFixture(t) for k, v := range env { t.Setenv(k, v) } done := make(chan struct{}) go func() { boxRecordLoop(); close(done) }() select { case <-done: case <-time.After(10 * time.Second): t.Fatal("boxRecordLoop did not return: its region HA gate let it run") } if n := len(b.requests()); n != 0 { t.Fatalf("boxRecordLoop sent %d requests with its gate shut", n) } }) } } // Box publication reaches the deployment bucket through openDeployStoreEnv // and requires the *release.S3Store it returns (boxpublish.go's // opened.(*release.S3Store)). The change index must not cost it that type: // the opener hands back the same concrete store with the index enabled, // against the real S3 wiring, and publication still writes only its own // control/boxes/ key (a key outside every change scope, so no index traffic). // Not parallel: publishFixture sets the process environment. func TestBoxPublishThroughDeploymentOpener(t *testing.T) { b := publishFixture(t) ctx := context.Background() opened, err := openDeployStoreEnv(ctx, os.Getenv) if err != nil { t.Fatal(err) } if _, ok := opened.(*release.S3Store); !ok { t.Fatalf("openDeployStoreEnv returned %T; box publication needs the *release.S3Store", opened) } // The opener enabled the index: a scoped write bumps its generation. if _, err := opened.Put(ctx, "accounts/shop/manifest.json", []byte("{}"), ""); err != nil { t.Fatal(err) } idx, _, err := release.ReadChangeIndex(ctx, opened) if err != nil { t.Fatal(err) } if got := idx.Entries["accounts/shop"]; got != 1 { t.Fatalf("accounts/shop generation = %d after one scoped Put, want 1 (entries %v)", got, idx.Entries) } // Publication itself is unchanged: one create-only PUT on its own key. before := len(b.requests()) if _, err := b.run(t); err != nil { t.Fatal(err) } if puts := assertCreateOnly(t, b.requests()[before:]); puts != 1 { t.Fatalf("%d PUTs, want one", puts) } }