| 1 | package hostops |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/ed25519" |
| 6 | "encoding/json" |
| 7 | "errors" |
| 8 | "os" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | "time" |
| 12 | |
| 13 | "github.com/carlosframework/platform/internal/hostactors" |
| 14 | "github.com/carlosframework/platform/internal/hostbox" |
| 15 | "github.com/carlosframework/platform/internal/hostclaim" |
| 16 | "github.com/carlosframework/platform/internal/hostevidence" |
| 17 | "github.com/carlosframework/platform/internal/release" |
| 18 | ) |
| 19 | |
| 20 | func resetTransportFixture(t *testing.T) (WakeResetTransport, WakeResetRequest, hostclaim.Observed, ed25519.PrivateKey) { |
| 21 | t.Helper() |
| 22 | w, o, clock := wakeResetFixture(t) |
| 23 | key := ed25519.NewKeyFromSeed(make([]byte, 32)) |
| 24 | boxkey := ed25519.NewKeyFromSeed([]byte(strings.Repeat("b", 32))) |
| 25 | h := o.Claim.Holder |
| 26 | h.WakeRetry.Attempt.Box = "box" |
| 27 | h.WakeRetry.Attempt.Enrollment = strings.Repeat("a", 32) |
| 28 | h.WakeRetry.Attempt.BootID = "boot" |
| 29 | h.WakeRetry.Attempt.ControllerID = "controller" |
| 30 | h.WakeRetry.Attempt.Session = h.Session |
| 31 | raw, _ := json.Marshal(o.Claim) |
| 32 | etag, err := w.raw.Put(t.Context(), "control/hosts/"+o.Host+".json", raw, o.ETag) |
| 33 | if err != nil { |
| 34 | t.Fatal(err) |
| 35 | } |
| 36 | o.ETag = etag |
| 37 | r := hostactors.Roster{V: 1, DeploymentBucket: "bucket", Revision: 1, RequestLimitNS: int64(hostbox.RequestLimit), Boxes: map[string]hostactors.Box{"box": {BoxID: "box", Enrollment: strings.Repeat("a", 32), Namespace: "platform", Label: "box", Region: "ie", Pool: "default", Cloud: hostactors.Cloud{Provider: "aws", Account: "123", Region: "eu-west-1", InstanceID: "i-box"}, EvidenceSHA256: strings.Repeat("c", 64), State: "active", CensusPublicKey: boxkey.Public().(ed25519.PublicKey)}}, Consoles: map[string]hostactors.Console{"console": {InstanceID: "console", Enrollment: strings.Repeat("d", 32), Namespaces: []string{"platform"}, DeploymentBucket: "bucket", Compute: &hostactors.ComputeBinding{Box: "box", Enrollment: strings.Repeat("a", 32)}}}, Holds: map[string]hostactors.Reservation{}, Activations: map[string]hostactors.Reservation{}} |
| 38 | wire, err := release.SignRootEnvelope(release.RootSigner(key), r) |
| 39 | if err != nil { |
| 40 | t.Fatal(err) |
| 41 | } |
| 42 | if _, err = w.raw.Put(t.Context(), hostactors.Key, wire, ""); err != nil { |
| 43 | t.Fatal(err) |
| 44 | } |
| 45 | transport := WakeResetTransport{Store: w.raw, Directory: hostactors.Directory{Store: w.raw, PublicKey: key.Public().(ed25519.PublicKey), Bucket: "bucket"}, Now: clock} |
| 46 | req := WakeResetRequest{V: 1, ID: strings.Repeat("e", 64), Bucket: "bucket", Console: "console", ConsoleEnrollment: strings.Repeat("d", 32), Namespace: "platform", Host: o.Host, ClaimETag: o.ETag, ClaimGen: o.Claim.Gen, Incarnation: h.Incarnation, Stamp: h.Stamp, Session: h.Session, Operation: o.Claim.Op.ID, Phase: o.Claim.Op.Phase, Episode: h.WakeRetry.EpisodeID, Attempt: *h.WakeRetry.Attempt, IssuedAt: clock(), Expires: clock().Add(time.Minute)} |
| 47 | owner := RegistrationOwner{Console: req.Console, Enrollment: req.ConsoleEnrollment, Controller: "controller"} |
| 48 | _, err = w.RegisterStart(t.Context(), h.Account, h.App, "app", "reset", Registration{Kind: RegistrationWakeRetryReset, Host: o.Host, Owner: owner, AppID: "app", Epoch: 1, Claim: SavedObservation(o), Incarnation: h.Incarnation, CreatedAt: clock()}, clock()) |
| 49 | if err != nil { |
| 50 | t.Fatal(err) |
| 51 | } |
| 52 | req.Admission = WakeResetAdmission{Account: h.Account, App: h.App, AppID: "app", Registration: "reset", Epoch: 1, Owner: owner} |
| 53 | req.ID = wakeResetID(req) |
| 54 | return transport, req, o, boxkey |
| 55 | } |
| 56 | |
| 57 | func TestPlan9ResetRequestRejectsChangedAuthorityAndClaim(t *testing.T) { |
| 58 | tr, r, o, _ := resetTransportFixture(t) |
| 59 | if err := tr.Authorize(t.Context(), r); err != nil { |
| 60 | t.Fatal(err) |
| 61 | } |
| 62 | for _, change := range []func(*WakeResetRequest){func(r *WakeResetRequest) { r.ConsoleEnrollment = "other" }, func(r *WakeResetRequest) { r.ClaimETag = "other" }, func(r *WakeResetRequest) { r.Incarnation = "other" }, func(r *WakeResetRequest) { r.Attempt.ID = "other" }, func(r *WakeResetRequest) { r.Expires = r.IssuedAt }, func(r *WakeResetRequest) { r.Namespace = "other" }} { |
| 63 | copy := r |
| 64 | change(©) |
| 65 | if tr.Authorize(t.Context(), copy) == nil { |
| 66 | t.Fatal("accepted changed binding") |
| 67 | } |
| 68 | } |
| 69 | o.Claim.Holder.WakeRetry.Attempt.ID = "replacement" |
| 70 | raw, _ := json.Marshal(o.Claim) |
| 71 | if _, err := tr.Store.Put(t.Context(), "control/hosts/"+o.Host+".json", raw, o.ETag); err != nil { |
| 72 | t.Fatal(err) |
| 73 | } |
| 74 | if tr.Authorize(t.Context(), r) == nil { |
| 75 | t.Fatal("accepted replaced attempt") |
| 76 | } |
| 77 | } |
| 78 | |
| 79 | func TestPlan9ResetLostResponseIsUncertain(t *testing.T) { |
| 80 | tr, r, _, _ := resetTransportFixture(t) |
| 81 | if err := tr.VerifyReceipt(t.Context(), r); !errors.Is(err, ErrWakeResetUncertain) { |
| 82 | t.Fatalf("absence = %v", err) |
| 83 | } |
| 84 | ctx, cancel := context.WithCancel(t.Context()) |
| 85 | cancel() |
| 86 | if err := tr.Wait(ctx, r, time.Millisecond); !errors.Is(err, ErrWakeResetUncertain) { |
| 87 | t.Fatalf("cancel = %v", err) |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | func publishResetProof(t *testing.T, tr WakeResetTransport, r WakeResetRequest, key ed25519.PrivateKey) string { |
| 92 | t.Helper() |
| 93 | k := "control/hosts/" + r.Host + "/serving-evidence/box/proof.json" |
| 94 | proof := StopReceipt{Region: "ie", V: 1, ID: "proof", Host: r.Host, Incarnation: r.Incarnation, InstallationStamp: r.Stamp, WriterSession: r.Session, OperationID: r.Operation, Phase: r.Phase, Box: r.Attempt.Box, Enrollment: r.Attempt.Enrollment, BootID: r.Attempt.BootID, ControllerID: r.Attempt.ControllerID, ClaimETag: r.ClaimETag, ClaimGen: r.ClaimGen, Kind: "stopped", EvidenceAt: tr.Now(), FenceID: "fence", DirectoryRevision: "1", RequestLimitNS: int64(hostbox.RequestLimit), InventorySHA256: strings.Repeat("1", 64), LocalJournalSHA256: strings.Repeat("2", 64), ProducerDrainDigest: strings.Repeat("3", 64), CgroupIdentities: []string{"group"}} |
| 95 | raw, _ := json.Marshal(proof) |
| 96 | if err := hostevidence.Publish(t.Context(), tr.Store, key, k, raw); err != nil { |
| 97 | t.Fatal(err) |
| 98 | } |
| 99 | return k |
| 100 | } |
| 101 | func TestPlan9ResetReceiptNeedsRootSignatureAndExactTuple(t *testing.T) { |
| 102 | tr, r, _, key := resetTransportFixture(t) |
| 103 | k := publishResetProof(t, tr, r, key) |
| 104 | if err := tr.VerifyReceipt(t.Context(), r); err != nil { |
| 105 | t.Fatal(err) |
| 106 | } |
| 107 | changed := r |
| 108 | changed.Attempt.ControllerID = "other" |
| 109 | if tr.VerifyReceipt(t.Context(), changed) == nil { |
| 110 | t.Fatal("accepted other controller") |
| 111 | } |
| 112 | raw, etag, err := tr.Store.Get(t.Context(), k) |
| 113 | if err != nil { |
| 114 | t.Fatal(err) |
| 115 | } |
| 116 | raw = append(raw, ' ') |
| 117 | if _, err = tr.Store.Put(t.Context(), k, raw, etag); err != nil { |
| 118 | t.Fatal(err) |
| 119 | } |
| 120 | if tr.VerifyReceipt(t.Context(), r) == nil { |
| 121 | t.Fatal("accepted forged proof") |
| 122 | } |
| 123 | } |
| 124 | |
| 125 | func signedResetRequest(t *testing.T, tr WakeResetTransport, r WakeResetRequest) { |
| 126 | t.Helper() |
| 127 | key := ed25519.NewKeyFromSeed(make([]byte, 32)) |
| 128 | wire, err := release.SignRootEnvelope(release.RootSigner(key), r) |
| 129 | if err != nil { |
| 130 | t.Fatal(err) |
| 131 | } |
| 132 | if _, err = tr.Store.Put(t.Context(), r.Key(), wire, ""); err != nil { |
| 133 | t.Fatal(err) |
| 134 | } |
| 135 | } |
| 136 | func TestPlan9ResetWorkerReplayCancellationAndExpiry(t *testing.T) { |
| 137 | for _, mode := range []string{"dispatch", "cancel", "expiry", "invalid-signature", "completed", "lost-response"} { |
| 138 | t.Run(mode, func(t *testing.T) { |
| 139 | tr, r, _, key := resetTransportFixture(t) |
| 140 | if mode == "expiry" { |
| 141 | r.IssuedAt = tr.Now().Add(-2 * time.Minute) |
| 142 | r.Expires = tr.Now().Add(-time.Minute) |
| 143 | } |
| 144 | signedResetRequest(t, tr, r) |
| 145 | if mode == "cancel" { |
| 146 | raw, _ := json.Marshal(r) |
| 147 | wire, err := release.SignRootEnvelope(release.RootSigner(ed25519.NewKeyFromSeed(make([]byte, 32))), WakeResetCancellation{V: 1, RequestSHA256: evidenceDigest(raw)}) |
| 148 | if err != nil { |
| 149 | t.Fatal(err) |
| 150 | } |
| 151 | if _, err = tr.Store.Put(t.Context(), r.Key()+".cancel", wire, ""); err != nil { |
| 152 | t.Fatal(err) |
| 153 | } |
| 154 | } |
| 155 | if mode == "invalid-signature" { |
| 156 | raw, tag, _ := tr.Store.Get(t.Context(), r.Key()) |
| 157 | raw[0] = '[' |
| 158 | if _, err := tr.Store.Put(t.Context(), r.Key(), raw, tag); err != nil { |
| 159 | t.Fatal(err) |
| 160 | } |
| 161 | } |
| 162 | if mode == "completed" { |
| 163 | publishResetProof(t, tr, r, key) |
| 164 | } |
| 165 | calls := 0 |
| 166 | stop := func(ctx context.Context, o hostclaim.Observed, a hostclaim.WakeRetryAttempt) error { |
| 167 | calls++ |
| 168 | if !r.matches(o) || a != r.Attempt { |
| 169 | t.Fatal("wrong attempt") |
| 170 | } |
| 171 | publishResetProof(t, tr, r, key) |
| 172 | if mode == "lost-response" { |
| 173 | return errors.New("connection lost") |
| 174 | } |
| 175 | return nil |
| 176 | } |
| 177 | err := tr.Pass(t.Context(), "box", stop) |
| 178 | want := 0 |
| 179 | if mode == "dispatch" || mode == "lost-response" { |
| 180 | want = 1 |
| 181 | } |
| 182 | if calls != want { |
| 183 | t.Fatalf("calls=%d want=%d err=%v", calls, want, err) |
| 184 | } |
| 185 | if mode == "invalid-signature" && err == nil { |
| 186 | t.Fatal("accepted unsigned request") |
| 187 | } |
| 188 | if mode == "lost-response" && !errors.Is(err, ErrWakeResetUncertain) { |
| 189 | t.Fatalf("lost stop = %v", err) |
| 190 | } |
| 191 | if mode == "dispatch" || mode == "lost-response" || mode == "completed" { |
| 192 | if err := tr.Pass(t.Context(), "box", stop); err != nil { |
| 193 | t.Fatal(err) |
| 194 | } |
| 195 | if calls != want { |
| 196 | t.Fatal("replayed completed stop") |
| 197 | } |
| 198 | if err := tr.VerifyReceipt(t.Context(), r); err != nil { |
| 199 | t.Fatal(err) |
| 200 | } |
| 201 | } |
| 202 | }) |
| 203 | } |
| 204 | } |
| 205 | |
| 206 | func TestPlan9ResetTypedWriterRequiresLiveExactRegistration(t *testing.T) { |
| 207 | tr, r, o, _ := resetTransportFixture(t) |
| 208 | w, err := NewControlWriter(tr.Store, tr.Directory.PublicKey, tr.Now) |
| 209 | if err != nil { |
| 210 | t.Fatal(err) |
| 211 | } |
| 212 | signer := release.RootSigner(ed25519.NewKeyFromSeed(make([]byte, 32))) |
| 213 | issue := func(ctx context.Context) (WakeResetRequest, error) { |
| 214 | return w.RequestWakeReset(ctx, tr, signer, r.Console, r.ConsoleEnrollment, r.Namespace, o, r.Attempt) |
| 215 | } |
| 216 | if _, err := issue(t.Context()); !errors.Is(err, ErrIdentity) { |
| 217 | t.Fatalf("unregistered issue=%v", err) |
| 218 | } |
| 219 | b := r.Admission |
| 220 | grant := lifecycleGrant{kind: "app", account: b.Account, app: b.App, appID: b.AppID, operation: b.Registration, owner: b.Owner, epoch: b.Epoch, w: w, registration: RegistrationWakeRetryReset, incarnation: r.Incarnation} |
| 221 | ctx := withLifecycleGrant(t.Context(), grant) |
| 222 | first, err := issue(ctx) |
| 223 | if err != nil { |
| 224 | t.Fatal(err) |
| 225 | } |
| 226 | replay, err := issue(ctx) |
| 227 | if err != nil || replay != first { |
| 228 | t.Fatalf("replay=%+v err=%v", replay, err) |
| 229 | } |
| 230 | if err = w.CancelWakeReset(t.Context(), tr, signer, first); err != nil { |
| 231 | t.Fatal(err) |
| 232 | } |
| 233 | cancelled, err := tr.cancelled(t.Context(), first) |
| 234 | if err != nil || !cancelled { |
| 235 | t.Fatalf("cancel=%t %v", cancelled, err) |
| 236 | } |
| 237 | wrong := grant |
| 238 | wrong.incarnation = "another" |
| 239 | if _, err := issue(withLifecycleGrant(t.Context(), wrong)); !errors.Is(err, ErrIdentity) { |
| 240 | t.Fatalf("other incarnation=%v", err) |
| 241 | } |
| 242 | } |
| 243 | |
| 244 | func TestPlan9ResetNewControlObjectsNeverPassDataBoundary(t *testing.T) { |
| 245 | for _, key := range []string{WakeResetPrefix + "box/id.json", WakeResetPrefix + "box/id.json.cancel"} { |
| 246 | if !IsControlOnlyKey(key) { |
| 247 | t.Fatalf("not control-only: %s", key) |
| 248 | } |
| 249 | } |
| 250 | } |
| 251 | |
| 252 | func TestPlan9ResetArchivePreventsResurrectionAndKeepsProof(t *testing.T) { |
| 253 | tr, r, _, boxkey := resetTransportFixture(t) |
| 254 | root := ed25519.NewKeyFromSeed(make([]byte, 32)) |
| 255 | raw, err := release.SignRootEnvelope(release.RootSigner(root), r) |
| 256 | if err != nil { |
| 257 | t.Fatal(err) |
| 258 | } |
| 259 | if _, err = tr.Store.Put(t.Context(), r.Key(), raw, ""); err != nil { |
| 260 | t.Fatal(err) |
| 261 | } |
| 262 | publishResetProof(t, tr, r, boxkey) |
| 263 | now := r.Expires.Add(hostbox.RequestLimit + time.Second) |
| 264 | tr.Now = func() time.Time { return now } |
| 265 | writer := &ControlWriter{raw: tr.Store, pub: root.Public().(ed25519.PublicKey), now: tr.Now} |
| 266 | if err = writer.SweepWakeResets(t.Context(), tr); err != nil { |
| 267 | t.Fatal(err) |
| 268 | } |
| 269 | if _, _, err = tr.Store.Get(t.Context(), r.Key()); !errors.Is(err, os.ErrNotExist) { |
| 270 | t.Fatalf("expired request retained: %v", err) |
| 271 | } |
| 272 | if _, _, err = tr.Store.Get(t.Context(), wakeResetArchiveKey(r)); err != nil { |
| 273 | t.Fatal(err) |
| 274 | } |
| 275 | if err = tr.VerifyReceipt(t.Context(), r); err != nil { |
| 276 | t.Fatalf("archive lost proof: %v", err) |
| 277 | } |
| 278 | // A delayed reissue that passed its first archive read cannot dispatch even |
| 279 | // if it recreated the original request key with a later expiry. |
| 280 | revived := r |
| 281 | revived.IssuedAt = now |
| 282 | revived.Expires = now.Add(time.Minute) |
| 283 | if revived.ID != wakeResetID(revived) { |
| 284 | t.Fatal("fixture did not reuse deterministic id") |
| 285 | } |
| 286 | if err = tr.Authorize(t.Context(), revived); err == nil { |
| 287 | t.Fatal("archived request resurrected") |
| 288 | } |
| 289 | if !IsControlOnlyKey(wakeResetArchiveKey(r)) { |
| 290 | t.Fatal("archive not control-only") |
| 291 | } |
| 292 | } |
| 293 | func TestPlan9ResetStopDeadlineIsFairAndExpiryBounded(t *testing.T) { |
| 294 | now := time.Now() |
| 295 | if got := minResetDeadline(now, now.Add(time.Minute)); !got.Equal(now.Add(hostbox.RequestLimit / 4)) { |
| 296 | t.Fatal(got) |
| 297 | } |
| 298 | if got := minResetDeadline(now, now.Add(time.Second)); !got.Equal(now.Add(time.Second)) { |
| 299 | t.Fatal(got) |
| 300 | } |
| 301 | } |
| 302 | |
| 303 | func TestPlan9ArchivedResetRejectsLateCancellation(t *testing.T) { |
| 304 | tr, r, _, _ := resetTransportFixture(t) |
| 305 | root := ed25519.NewKeyFromSeed(make([]byte, 32)) |
| 306 | raw, _ := release.SignRootEnvelope(release.RootSigner(root), r) |
| 307 | if _, err := tr.Store.Put(t.Context(), wakeResetArchiveKey(r), raw, ""); err != nil { |
| 308 | t.Fatal(err) |
| 309 | } |
| 310 | writer := &ControlWriter{raw: tr.Store, pub: root.Public().(ed25519.PublicKey), now: tr.Now} |
| 311 | if err := writer.CancelWakeReset(t.Context(), tr, release.RootSigner(root), r); err == nil { |
| 312 | t.Fatal("late cancellation recreated an orphan") |
| 313 | } |
| 314 | if _, _, err := tr.Store.Get(t.Context(), r.Key()+".cancel"); !errors.Is(err, os.ErrNotExist) { |
| 315 | t.Fatal(err) |
| 316 | } |
| 317 | } |
| 318 | |
| 319 | type plan9CancelDeleteStore struct { |
| 320 | release.Store |
| 321 | fail bool |
| 322 | deleted []string |
| 323 | } |
| 324 | |
| 325 | func (s *plan9CancelDeleteStore) DeleteIfMatch(ctx context.Context, key, etag string) error { |
| 326 | s.deleted = append(s.deleted, key) |
| 327 | if s.fail && strings.HasSuffix(key, ".cancel") { |
| 328 | return errors.New("cancel delete unavailable") |
| 329 | } |
| 330 | return release.DeleteIfMatch(ctx, s.Store, key, etag) |
| 331 | } |
| 332 | func TestPlan9ResetSweepRetainsRequestUntilCancellationRemoved(t *testing.T) { |
| 333 | tr, r, _, _ := resetTransportFixture(t) |
| 334 | root := ed25519.NewKeyFromSeed(make([]byte, 32)) |
| 335 | raw, _ := release.SignRootEnvelope(release.RootSigner(root), r) |
| 336 | if _, err := tr.Store.Put(t.Context(), r.Key(), raw, ""); err != nil { |
| 337 | t.Fatal(err) |
| 338 | } |
| 339 | writer := &ControlWriter{raw: tr.Store, pub: root.Public().(ed25519.PublicKey), now: tr.Now} |
| 340 | if err := writer.CancelWakeReset(t.Context(), tr, release.RootSigner(root), r); err != nil { |
| 341 | t.Fatal(err) |
| 342 | } |
| 343 | now := r.Expires.Add(hostbox.RequestLimit + time.Second) |
| 344 | tr.Now = func() time.Time { return now } |
| 345 | writer.now = tr.Now |
| 346 | store := &plan9CancelDeleteStore{Store: tr.Store, fail: true} |
| 347 | writer.raw = store |
| 348 | if err := writer.SweepWakeResets(t.Context(), tr); err == nil { |
| 349 | t.Fatal("ignored cancellation deletion failure") |
| 350 | } |
| 351 | if _, _, err := tr.Store.Get(t.Context(), r.Key()); err != nil { |
| 352 | t.Fatalf("removed retry discovery: %v", err) |
| 353 | } |
| 354 | if len(store.deleted) != 1 || store.deleted[0] != r.Key()+".cancel" { |
| 355 | t.Fatalf("wrong deletion order: %v", store.deleted) |
| 356 | } |
| 357 | store.fail = false |
| 358 | store.deleted = nil |
| 359 | if err := writer.SweepWakeResets(t.Context(), tr); err != nil { |
| 360 | t.Fatal(err) |
| 361 | } |
| 362 | if len(store.deleted) != 2 || store.deleted[0] != r.Key()+".cancel" || store.deleted[1] != r.Key() { |
| 363 | t.Fatalf("wrong successful order: %v", store.deleted) |
| 364 | } |
| 365 | } |
| 366 | |