| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "crypto/ed25519" |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | |
| 12 | "github.com/carlosframework/platform/internal/hostactors" |
| 13 | "github.com/carlosframework/platform/internal/hostbox" |
| 14 | "github.com/carlosframework/platform/internal/hostclaim" |
| 15 | "github.com/carlosframework/platform/internal/hostfloor" |
| 16 | "github.com/carlosframework/platform/internal/release" |
| 17 | ) |
| 18 | |
| 19 | // bindingRosterFixture is a signed one-box roster in a release.DirStore, with |
| 20 | // a published floor and the matching local floor policy, read through the |
| 21 | // production roster adapter. Roster writes go through the real |
| 22 | // hostactors.Directory writers unless a step says otherwise. |
| 23 | type bindingRosterFixture struct { |
| 24 | t *testing.T |
| 25 | store release.Store |
| 26 | signer release.Signer |
| 27 | dir hostactors.Directory |
| 28 | floorDir string |
| 29 | box string |
| 30 | churn int |
| 31 | } |
| 32 | |
| 33 | const bindingFixtureBucket = "deployment" |
| 34 | |
| 35 | func newBindingRosterFixture(t *testing.T, box, region string) *bindingRosterFixture { |
| 36 | t.Helper() |
| 37 | key := ed25519.NewKeyFromSeed(make([]byte, ed25519.SeedSize)) |
| 38 | s := release.NewDirStore(t.TempDir()) |
| 39 | f := &bindingRosterFixture{t: t, store: s, signer: release.RootSigner(key), dir: hostactors.Directory{Store: s, PublicKey: key.Public().(ed25519.PublicKey), Bucket: bindingFixtureBucket}, floorDir: t.TempDir(), box: box} |
| 40 | if err := hostfloor.Publish(t.Context(), s, f.signer, bindingFixtureBucket, hostfloor.Floor{Min: 1, MinRelease: "v0.1.0"}); err != nil { |
| 41 | t.Fatalf("newBindingRosterFixture: %v", err) |
| 42 | } |
| 43 | raw, _, err := s.Get(t.Context(), hostfloor.Key) |
| 44 | if err != nil { |
| 45 | t.Fatalf("newBindingRosterFixture: %v", err) |
| 46 | } |
| 47 | auth, _, err := s.Get(t.Context(), hostfloor.AuthKey) |
| 48 | if err != nil { |
| 49 | t.Fatalf("newBindingRosterFixture: %v", err) |
| 50 | } |
| 51 | body, _ := json.Marshal(hostfloor.Policy{Schema: 1, DeploymentBucket: bindingFixtureBucket, Mode: "enforced", Floor: raw, Authorization: auth}) |
| 52 | if err := os.WriteFile(filepath.Join(f.floorDir, "policy.json"), body, 0600); err != nil { |
| 53 | t.Fatalf("newBindingRosterFixture: %v", err) |
| 54 | } |
| 55 | if err := f.dir.Initialize(t.Context(), f.signer); err != nil { |
| 56 | t.Fatalf("newBindingRosterFixture: %v", err) |
| 57 | } |
| 58 | o := f.read() |
| 59 | next := hostactors.Clone(o.Roster) |
| 60 | next.RequestLimitNS, next.Publication, next.FloorSHA256 = int64(hostbox.RequestLimit), "published", hostfloor.Digest(raw) |
| 61 | // A region HA group, so enrollmentAttempt can run the real writer. |
| 62 | next.Regions = map[string]hostactors.Region{region: {Namespaces: []string{"platform"}, Pools: []string{"default"}, Account: "111122223333", AWSRegion: "eu-west-1", GroupName: "group", GroupARN: "arn:aws:autoscaling:eu-west-1:111122223333:autoScalingGroup:x:autoScalingGroupName/group"}} |
| 63 | next.Boxes[box] = hostactors.Box{BoxID: box, Enrollment: strings.Repeat("a", 32), Namespace: "platform", Label: box, Region: region, Pool: "default", Cloud: hostactors.Cloud{Provider: "aws", Account: "111122223333", Region: "eu-west-1", InstanceID: "i-0abc"}, EvidenceSHA256: strings.Repeat("c", 64), State: "active", LossIDs: []string{}} |
| 64 | if _, err := f.dir.Replace(t.Context(), f.signer, o, next); err != nil { |
| 65 | t.Fatalf("newBindingRosterFixture: %v", err) |
| 66 | } |
| 67 | return f |
| 68 | } |
| 69 | |
| 70 | func (f *bindingRosterFixture) read() hostactors.Observed { |
| 71 | f.t.Helper() |
| 72 | o, err := f.dir.Read(f.t.Context()) |
| 73 | if err != nil { |
| 74 | f.t.Fatalf("read: %v", err) |
| 75 | } |
| 76 | return o |
| 77 | } |
| 78 | |
| 79 | func (f *bindingRosterFixture) row() hostactors.Box { return f.read().Roster.Boxes[f.box] } |
| 80 | |
| 81 | // identity is the box's production identity: signed cloud evidence for the |
| 82 | // row's instance and productionClaimRoster over this roster. |
| 83 | func (f *bindingRosterFixture) identity() hostbox.IdentityProvider { |
| 84 | f.t.Helper() |
| 85 | row := f.row() |
| 86 | identity := handoverRecoveryIdentity(f.t, hostbox.Actor{Box: f.box, Enrollment: row.Enrollment, Region: row.Region, ControlBucket: bindingFixtureBucket, Revision: 1}) |
| 87 | identity.Roster = productionClaimRoster{Directory: f.dir, DataBucket: identity.Bucket, FloorDir: f.floorDir} |
| 88 | return identity |
| 89 | } |
| 90 | |
| 91 | func (f *bindingRosterFixture) binding() string { |
| 92 | f.t.Helper() |
| 93 | b, ok := hostactors.BoxBinding(f.read().Roster, f.box) |
| 94 | if !ok { |
| 95 | f.t.Fatal("box not in roster") |
| 96 | } |
| 97 | return b |
| 98 | } |
| 99 | |
| 100 | // holdChurn is one console create's roster traffic: a real hold Reserve, then |
| 101 | // the claim it names moves on and ReleaseFinishedHold drops the hold. The |
| 102 | // roster write counter moves by two; this box's binding does not. |
| 103 | func (f *bindingRosterFixture) holdChurn() { |
| 104 | f.t.Helper() |
| 105 | before := f.read().Roster.Revision |
| 106 | f.releaseHold(f.reserveHold()) |
| 107 | after := f.read() |
| 108 | if after.Roster.Revision != before+2 || len(after.Roster.Holds) != 0 { |
| 109 | f.t.Fatalf("hold churn: revision %d -> %d, holds %d", before, after.Roster.Revision, len(after.Roster.Holds)) |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | // reserveHold is a create's real hold Reserve naming this box, left held. |
| 114 | func (f *bindingRosterFixture) reserveHold() hostactors.Reservation { |
| 115 | f.t.Helper() |
| 116 | ctx := f.t.Context() |
| 117 | f.churn++ |
| 118 | host := fmt.Sprintf("churn-%d.example.com", f.churn) |
| 119 | before := f.read() |
| 120 | claim, err := hostclaim.Read(ctx, f.store, host) |
| 121 | if err != nil { |
| 122 | f.t.Fatalf("reserveHold: %v", err) |
| 123 | } |
| 124 | actor := "box:" + f.box |
| 125 | enrollment := before.Roster.Boxes[f.box].Enrollment |
| 126 | h := hostactors.Reservation{ID: fmt.Sprintf("hold_%d", f.churn), Host: host, OpID: "create", ExpectedClaimETag: claim.ETag, FloorSHA256: before.Roster.FloorSHA256, Actors: map[string]string{actor: enrollment}, Releases: map[string]hostactors.Report{actor: {Enrollment: enrollment}}} |
| 127 | if _, err := f.dir.Reserve(ctx, f.signer, before, h, false); err != nil { |
| 128 | f.t.Fatalf("reserveHold: %v", err) |
| 129 | } |
| 130 | return h |
| 131 | } |
| 132 | |
| 133 | // releaseHold moves the hold's claim on, then ReleaseFinishedHold drops it. |
| 134 | func (f *bindingRosterFixture) releaseHold(h hostactors.Reservation) { |
| 135 | f.t.Helper() |
| 136 | ctx := f.t.Context() |
| 137 | claim, err := hostclaim.Read(ctx, f.store, h.Host) |
| 138 | if err != nil { |
| 139 | f.t.Fatalf("releaseHold: %v", err) |
| 140 | } |
| 141 | if _, err := hostclaim.Update(ctx, f.store, claim, func(*hostclaim.Claim) error { return nil }); err != nil { |
| 142 | f.t.Fatalf("releaseHold: %v", err) |
| 143 | } |
| 144 | if err := f.dir.ReleaseFinishedHold(ctx, f.signer, h.ID); err != nil { |
| 145 | f.t.Fatalf("releaseHold: %v", err) |
| 146 | } |
| 147 | } |
| 148 | |
| 149 | // bindCensusKey binds key's public half as the box's census key through the |
| 150 | // real BindCensusKey writer, with the box's proof of possession. |
| 151 | func (f *bindingRosterFixture) bindCensusKey(key ed25519.PrivateKey) { |
| 152 | f.t.Helper() |
| 153 | binding := hostactors.CensusKeyBinding{Bucket: bindingFixtureBucket, Box: f.box, Enrollment: f.row().Enrollment, PublicKey: key.Public().(ed25519.PublicKey)} |
| 154 | raw, err := json.Marshal(binding) |
| 155 | if err != nil { |
| 156 | f.t.Fatal(err) |
| 157 | } |
| 158 | if _, err := f.dir.BindCensusKey(f.t.Context(), f.signer, f.read(), binding, ed25519.Sign(key, raw)); err != nil { |
| 159 | f.t.Fatal("bind census key:", err) |
| 160 | } |
| 161 | } |
| 162 | |
| 163 | // quarantine is a real Directory.Quarantine of the box. |
| 164 | func (f *bindingRosterFixture) quarantine(loss string) { |
| 165 | f.t.Helper() |
| 166 | o := f.read() |
| 167 | if _, err := f.dir.Quarantine(f.t.Context(), f.signer, o, f.box, o.Roster.Boxes[f.box].Enrollment, loss); err != nil { |
| 168 | f.t.Fatalf("quarantine: %v", err) |
| 169 | } |
| 170 | } |
| 171 | |
| 172 | // readmit returns the quarantined row to active by a DIRECT SIGNED WRITE. |
| 173 | // Directory.Replace admits a quarantined -> active change on a published |
| 174 | // roster only with a fresh box-signed census report (hostactors |
| 175 | // checkMembership), which this fixture has no box to produce. The written |
| 176 | // roster is exactly what hostresolve's readmission writes: the row's state |
| 177 | // alone changes, its quarantine revision and loss ids stay, and Revision |
| 178 | // moves by one. |
| 179 | func (f *bindingRosterFixture) readmit() { |
| 180 | f.t.Helper() |
| 181 | o := f.read() |
| 182 | next := hostactors.Clone(o.Roster) |
| 183 | b := next.Boxes[f.box] |
| 184 | if b.State != "quarantined" { |
| 185 | f.t.Fatalf("readmit: state %q", b.State) |
| 186 | } |
| 187 | b.State = "active" |
| 188 | next.Boxes[f.box] = b |
| 189 | next.Revision++ |
| 190 | wire, err := release.SignRootEnvelope(f.signer, next) |
| 191 | if err != nil { |
| 192 | f.t.Fatalf("readmit: %v", err) |
| 193 | } |
| 194 | if _, err := f.store.Put(f.t.Context(), hostactors.Key, wire, o.ETag); err != nil { |
| 195 | f.t.Fatalf("readmit: %v", err) |
| 196 | } |
| 197 | } |
| 198 | |
| 199 | // activationChurn is one platform promote's roster traffic: a real activation |
| 200 | // Reserve (with a prepared system publication) and CancelActivation. |
| 201 | func (f *bindingRosterFixture) activationChurn() { |
| 202 | f.t.Helper() |
| 203 | ctx := f.t.Context() |
| 204 | f.churn++ |
| 205 | id := fmt.Sprintf("activation_%d", f.churn) |
| 206 | platform := release.AdoptPlatform() |
| 207 | rel := release.SystemRelease{Version: "v2.0.0", ReleaseTag: "v2.0.0", MovesProtocol: 1, Artifacts: map[string]release.SystemArtifact{platform: {Key: release.SystemArtifactKey("v2.0.0", platform), SHA256: strings.Repeat("a", 64)}}} |
| 208 | pub, err := release.PrepareSystemPromotion(ctx, f.store, f.signer, "router", "stable", rel, release.SystemPromoteOptions{Actor: "operator"}, id) |
| 209 | if err != nil { |
| 210 | f.t.Fatalf("activationChurn: %v", err) |
| 211 | } |
| 212 | before := f.read() |
| 213 | actor := "box:" + f.box |
| 214 | enrollment := before.Roster.Boxes[f.box].Enrollment |
| 215 | h := hostactors.Reservation{ID: id, Publication: &pub, RequestSHA256: strings.Repeat("e", 64), UpdateMode: "promote", FloorSHA256: before.Roster.FloorSHA256, Actors: map[string]string{actor: enrollment}, Releases: map[string]hostactors.Report{actor: {Enrollment: enrollment}}, Hashes: map[string][]string{actor: {"a", "b", "c"}}} |
| 216 | if _, err := f.dir.Reserve(ctx, f.signer, before, h, true); err != nil { |
| 217 | f.t.Fatalf("activationChurn: %v", err) |
| 218 | } |
| 219 | if err := f.dir.CancelActivation(ctx, f.signer, id); err != nil { |
| 220 | f.t.Fatalf("activationChurn: %v", err) |
| 221 | } |
| 222 | if after := f.read(); after.Roster.Revision != before.Roster.Revision+2 || len(after.Roster.Activations) != 0 { |
| 223 | f.t.Fatalf("activation churn: revision %d -> %d", before.Roster.Revision, after.Roster.Revision) |
| 224 | } |
| 225 | } |
| 226 | |
| 227 | // enrollmentAttempt is region HA autoscaling's real BeginEnrollmentAttempt |
| 228 | // for another instance in the box's region. |
| 229 | func (f *bindingRosterFixture) enrollmentAttempt() { |
| 230 | f.t.Helper() |
| 231 | f.churn++ |
| 232 | o := f.read() |
| 233 | ref := fmt.Sprintf("replacement-%d", f.churn) |
| 234 | if _, _, err := f.dir.BeginEnrollmentAttempt(f.t.Context(), f.signer, o, hostactors.EnrollmentRequest{Reference: ref, Region: o.Roster.Boxes[f.box].Region}, []byte("evidence-"+ref), "i-0"+ref); err != nil { |
| 235 | f.t.Fatalf("enrollmentAttempt: %v", err) |
| 236 | } |
| 237 | } |
| 238 | |
| 239 | // roleChange is the operator's real SetBoxRole on this box. |
| 240 | func (f *bindingRosterFixture) roleChange(role string) { |
| 241 | f.t.Helper() |
| 242 | if _, err := f.dir.SetBoxRole(f.t.Context(), f.signer, f.read(), f.box, role); err != nil { |
| 243 | f.t.Fatalf("roleChange: %v", err) |
| 244 | } |
| 245 | } |
| 246 | |
| 247 | // sixWrites is the design's L2 sequence: hold Reserve and ReleaseFinishedHold, |
| 248 | // activation Reserve and CancelActivation, BeginEnrollmentAttempt and |
| 249 | // SetBoxRole. The roster write counter moves by exactly six and this box's |
| 250 | // binding does not move at all. |
| 251 | func (f *bindingRosterFixture) sixWrites() { |
| 252 | f.t.Helper() |
| 253 | before, binding := f.read().Roster.Revision, f.binding() |
| 254 | f.holdChurn() |
| 255 | f.activationChurn() |
| 256 | f.enrollmentAttempt() |
| 257 | role := hostactors.RoleLegacy |
| 258 | if f.row().Role == role { |
| 259 | role = hostactors.RoleDefault |
| 260 | } |
| 261 | f.roleChange(role) |
| 262 | if after := f.read().Roster.Revision; after != before+6 { |
| 263 | f.t.Fatalf("six roster writes moved the revision %d -> %d", before, after) |
| 264 | } |
| 265 | if f.binding() != binding { |
| 266 | f.t.Fatal("unrelated roster writes moved the box binding") |
| 267 | } |
| 268 | } |
| 269 | |