| 1 | package hostops |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "crypto/ed25519" |
| 7 | "encoding/json" |
| 8 | "errors" |
| 9 | "reflect" |
| 10 | "slices" |
| 11 | "time" |
| 12 | |
| 13 | "github.com/carlosframework/platform/internal/hostactors" |
| 14 | "github.com/carlosframework/platform/internal/hostclaim" |
| 15 | "github.com/carlosframework/platform/internal/hostfence" |
| 16 | "github.com/carlosframework/platform/internal/hostresolve" |
| 17 | "github.com/carlosframework/platform/internal/provision" |
| 18 | "github.com/carlosframework/platform/internal/release" |
| 19 | ) |
| 20 | |
| 21 | // The declare-lost grant for a move's target (claim Plan 10, J17 with J4). |
| 22 | // The console's resolve doors call these two typed ControlWriter methods; |
| 23 | // neither takes a store, a callback or a transition. Each builds the generic |
| 24 | // hostresolve service with only the moves owner registered, for the move |
| 25 | // kinds only, over a private store that never leaves the method: |
| 26 | // |
| 27 | // - inspection reads the raw bucket through a store that refuses every |
| 28 | // write; |
| 29 | // - a declaration writes through lossScopedStore, bound to the exact |
| 30 | // declaration the signed challenge names (loss id, host, op, kind, box, |
| 31 | // claim ETag, incarnation, account and app). It admits exactly the |
| 32 | // service's acceptance, quarantine of that box, and loss record for that |
| 33 | // id, the claim's declare-lost CAS and the owner's release of it, and |
| 34 | // the owner's two record transitions, each recomputed from the bucket |
| 35 | // and compared with the bytes offered. The move's admitted registration |
| 36 | // is reread at every acceptance, quarantine, claim and record write. |
| 37 | |
| 38 | // lossBinding is one declaration's scope. |
| 39 | type lossBinding struct { |
| 40 | lossID, host, opID, kind, box, claimETag, incarnation, account, app, bucket string |
| 41 | } |
| 42 | |
| 43 | // moveLossService is the service a typed method runs, with the moves owner |
| 44 | // for the move kinds and nothing else. |
| 45 | func moveLossService(store release.Store, pub ed25519.PublicKey, signer release.Signer, bucket string, now func() time.Time) hostresolve.Service { |
| 46 | owner := &moveLossOwner{store: store, pub: pub, now: now} |
| 47 | owners := map[string]hostresolve.OperationOwner{} |
| 48 | for _, kind := range moveLossKinds { |
| 49 | owners[kind] = owner |
| 50 | } |
| 51 | return hostresolve.Service{Directory: hostactors.Directory{Store: store, PublicKey: pub, Bucket: bucket}, Signer: signer, Now: now, Owners: owners} |
| 52 | } |
| 53 | |
| 54 | func (w *ControlWriter) lossSigner(signer release.Signer) error { |
| 55 | if err := w.ready(); err != nil { |
| 56 | return err |
| 57 | } |
| 58 | if !bytes.Equal(signer.RootPublicKey(), w.pub) { |
| 59 | return ErrContract |
| 60 | } |
| 61 | return nil |
| 62 | } |
| 63 | |
| 64 | // moveLossClaim reads the claim a door names and holds it to the door's |
| 65 | // account and app and to a move kind this owner serves. |
| 66 | func (w *ControlWriter) moveLossClaim(ctx context.Context, account, app, host string) (hostclaim.Observed, error) { |
| 67 | o, err := hostclaim.Read(ctx, w.raw, host) |
| 68 | if err != nil { |
| 69 | return hostclaim.Observed{}, err |
| 70 | } |
| 71 | if o.Absent || o.Claim == nil || o.Claim.Holder == nil { |
| 72 | return hostclaim.Observed{}, hostresolve.ErrResolve |
| 73 | } |
| 74 | h := o.Claim.Holder |
| 75 | if !operationHomeMatches(w.homeRegion, o.Claim) { |
| 76 | return hostclaim.Observed{}, ErrIdentity |
| 77 | } |
| 78 | if h.Family != "instance" || h.Account != account || h.App != app || h.Key != provision.RecordKey(account, app, host) { |
| 79 | return hostclaim.Observed{}, ErrIdentity |
| 80 | } |
| 81 | return o, nil |
| 82 | } |
| 83 | |
| 84 | // InspectMoveLoss answers the signed challenge for declaring box, a move's |
| 85 | // target, lost on host. Read-only. |
| 86 | func (w *ControlWriter) InspectMoveLoss(ctx context.Context, signer release.Signer, bucket, actor, account, app, host, box string) (hostresolve.Inspection, error) { |
| 87 | if err := w.lossSigner(signer); err != nil { |
| 88 | return hostresolve.Inspection{}, err |
| 89 | } |
| 90 | o, err := w.moveLossClaim(ctx, account, app, host) |
| 91 | if err != nil { |
| 92 | return hostresolve.Inspection{}, err |
| 93 | } |
| 94 | if !slices.Contains(moveLossKinds, o.Claim.Op.Kind) { |
| 95 | return hostresolve.Inspection{}, hostresolve.ErrResolve |
| 96 | } |
| 97 | if err := moveAdmission(ctx, w.raw, host, o.Claim); err != nil { |
| 98 | return hostresolve.Inspection{}, err |
| 99 | } |
| 100 | return moveLossService(readOnlyStore{w.raw}, w.pub, signer, bucket, w.now).Inspect(ctx, actor, host, box) |
| 101 | } |
| 102 | |
| 103 | // DeclareMoveLoss accepts and completes a declaration for the challenge in |
| 104 | // the submission. A new declaration needs the move's exact admitted |
| 105 | // registration (closed admission and an expired registration still drain |
| 106 | // an accepted move); a retry of an accepted one replays it from its durable |
| 107 | // records, even after its claim CAS. It answers the claim the service last |
| 108 | // observed. |
| 109 | func (w *ControlWriter) DeclareMoveLoss(ctx context.Context, signer release.Signer, bucket, actor, account, app, host, box string, in hostfence.Submission) (hostclaim.Observed, error) { |
| 110 | if err := w.lossSigner(signer); err != nil { |
| 111 | return hostclaim.Observed{}, err |
| 112 | } |
| 113 | var c hostfence.Challenge |
| 114 | if err := release.VerifyRootEnvelope(w.pub, in.Challenge, &c); err != nil { |
| 115 | return hostclaim.Observed{}, hostresolve.ErrResolve |
| 116 | } |
| 117 | if _, err := hostclaim.LossRecordKey(c.ID); err != nil || c.Host != host || c.Actor != actor || c.Op.ID == "" || !slices.Contains(moveLossKinds, c.Op.Kind) { |
| 118 | return hostclaim.Observed{}, hostresolve.ErrResolve |
| 119 | } |
| 120 | d := hostactors.Directory{Store: readOnlyStore{w.raw}, PublicKey: w.pub, Bucket: bucket} |
| 121 | ref, _, err := d.Resolve(ctx, box) |
| 122 | if err != nil || ref != c.Box { |
| 123 | return hostclaim.Observed{}, hostresolve.ErrResolve |
| 124 | } |
| 125 | o, err := w.moveLossClaim(ctx, account, app, host) |
| 126 | if err != nil { |
| 127 | return hostclaim.Observed{}, err |
| 128 | } |
| 129 | if o.Claim.Holder.Incarnation != c.HolderIncarnation { |
| 130 | return hostclaim.Observed{}, hostresolve.ErrResolve |
| 131 | } |
| 132 | if o.Claim.Op.ID == c.Op.ID { |
| 133 | if err := moveAdmission(ctx, w.raw, host, o.Claim); err != nil { |
| 134 | return hostclaim.Observed{}, err |
| 135 | } |
| 136 | } else if prior, err := hostclaim.ReadLossRecord(ctx, w.raw, w.pub, c.ID); err != nil || prior.Record.Host != host || prior.Record.OpID != c.Op.ID { |
| 137 | // The operation has moved on: only an accepted declaration replays. |
| 138 | return hostclaim.Observed{}, hostresolve.ErrResolve |
| 139 | } |
| 140 | b := lossBinding{bucket: bucket, lossID: c.ID, host: host, opID: c.Op.ID, kind: c.Op.Kind, box: c.Box, claimETag: c.ClaimETag, incarnation: c.HolderIncarnation, account: account, app: app} |
| 141 | scoped := &lossScopedStore{raw: w.raw, pub: w.pub, now: w.now, b: b, homeRegion: w.homeRegion} |
| 142 | if _, err := moveLossService(scoped, w.pub, signer, bucket, w.now).Declare(ctx, actor, host, in); err != nil { |
| 143 | return hostclaim.Observed{}, err |
| 144 | } |
| 145 | // The service answers its claim CAS; the owner's release wrote after it. |
| 146 | return hostclaim.Read(ctx, w.raw, host) |
| 147 | } |
| 148 | |
| 149 | // readOnlyStore refuses every write. |
| 150 | type readOnlyStore struct{ raw release.Store } |
| 151 | |
| 152 | func (s readOnlyStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| 153 | return s.raw.Get(ctx, key) |
| 154 | } |
| 155 | func (s readOnlyStore) List(ctx context.Context, prefix string) ([]string, error) { |
| 156 | return s.raw.List(ctx, prefix) |
| 157 | } |
| 158 | func (s readOnlyStore) ListDirs(ctx context.Context, prefix string) ([]string, error) { |
| 159 | return s.raw.ListDirs(ctx, prefix) |
| 160 | } |
| 161 | func (readOnlyStore) Put(context.Context, string, []byte, string) (string, error) { |
| 162 | return "", errMoveLossScope |
| 163 | } |
| 164 | func (readOnlyStore) Delete(context.Context, string) error { return errMoveLossScope } |
| 165 | |
| 166 | // lossScopedStore is one declaration's private store (see above). |
| 167 | type lossScopedStore struct { |
| 168 | homeRegion string |
| 169 | raw release.Store |
| 170 | pub ed25519.PublicKey |
| 171 | now func() time.Time |
| 172 | b lossBinding |
| 173 | } |
| 174 | |
| 175 | func (s *lossScopedStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| 176 | return s.raw.Get(ctx, key) |
| 177 | } |
| 178 | func (s *lossScopedStore) List(ctx context.Context, prefix string) ([]string, error) { |
| 179 | return s.raw.List(ctx, prefix) |
| 180 | } |
| 181 | func (s *lossScopedStore) ListDirs(ctx context.Context, prefix string) ([]string, error) { |
| 182 | return s.raw.ListDirs(ctx, prefix) |
| 183 | } |
| 184 | func (s *lossScopedStore) Delete(context.Context, string) error { return errMoveLossScope } |
| 185 | |
| 186 | func (s *lossScopedStore) Put(ctx context.Context, key string, body []byte, etag string) (string, error) { |
| 187 | lossKey, err := hostclaim.LossRecordKey(s.b.lossID) |
| 188 | if err != nil { |
| 189 | return "", errMoveLossScope |
| 190 | } |
| 191 | claimKey, err := hostclaim.Key(s.b.host) |
| 192 | if err != nil { |
| 193 | return "", errMoveLossScope |
| 194 | } |
| 195 | switch { |
| 196 | case key == "control/host-loss-acceptances/"+s.b.lossID+".json": |
| 197 | err = s.acceptance(ctx, body, etag) |
| 198 | case key == lossKey: |
| 199 | err = s.lossRecord(ctx, body, etag) |
| 200 | case key == hostactors.Key: |
| 201 | err = s.quarantine(ctx, body, etag) |
| 202 | case key == claimKey: |
| 203 | err = s.claim(ctx, body, etag) |
| 204 | case provision.IsRecordKey(key): |
| 205 | err = s.record(ctx, key, body, etag) |
| 206 | default: |
| 207 | err = errMoveLossScope |
| 208 | } |
| 209 | if err != nil { |
| 210 | return "", err |
| 211 | } |
| 212 | if provision.IsRecordKey(key) { |
| 213 | return putSequencedRecord(ctx, s.raw, key, body, etag) |
| 214 | } |
| 215 | return s.raw.Put(ctx, key, body, etag) |
| 216 | } |
| 217 | |
| 218 | // bound holds a loss record to the declaration. |
| 219 | func (s *lossScopedStore) bound(r hostclaim.LossRecord) bool { |
| 220 | b := s.b |
| 221 | return r.ID == b.lossID && r.Host == b.host && r.OpID == b.opID && r.OpKind == b.kind && r.Box == b.box && |
| 222 | r.ExpectedClaimETag == b.claimETag && r.HolderIncarnation == b.incarnation && r.Validate() == nil |
| 223 | } |
| 224 | |
| 225 | // registered rereads the claim and the move's admitted registration. The |
| 226 | // claim must still be this holder's, under this op. |
| 227 | func (s *lossScopedStore) registered(ctx context.Context) (hostclaim.Observed, error) { |
| 228 | o, err := hostclaim.Read(ctx, s.raw, s.b.host) |
| 229 | if err != nil { |
| 230 | return hostclaim.Observed{}, err |
| 231 | } |
| 232 | if o.Absent || o.Claim == nil || o.Claim.Holder == nil { |
| 233 | return hostclaim.Observed{}, ErrIdentity |
| 234 | } |
| 235 | h := o.Claim.Holder |
| 236 | if !operationHomeMatches(s.homeRegion, o.Claim) { |
| 237 | return hostclaim.Observed{}, ErrIdentity |
| 238 | } |
| 239 | if h.Account != s.b.account || h.App != s.b.app || h.Incarnation != s.b.incarnation || o.Claim.Op.ID != s.b.opID { |
| 240 | return hostclaim.Observed{}, ErrIdentity |
| 241 | } |
| 242 | if err := moveAdmission(ctx, s.raw, s.b.host, o.Claim); err != nil { |
| 243 | return hostclaim.Observed{}, err |
| 244 | } |
| 245 | return o, nil |
| 246 | } |
| 247 | |
| 248 | func (s *lossScopedStore) acceptance(ctx context.Context, body []byte, etag string) error { |
| 249 | if etag != "" { |
| 250 | return errMoveLossScope |
| 251 | } |
| 252 | var a struct { |
| 253 | V int `json:"v"` |
| 254 | Bucket string `json:"bucket"` |
| 255 | RosterETag string `json:"roster_etag"` |
| 256 | Loss hostclaim.LossRecord `json:"loss"` |
| 257 | } |
| 258 | if release.VerifyRootEnvelope(s.pub, body, &a) != nil || a.V != 1 || s.b.bucket == "" || a.Bucket != s.b.bucket || a.RosterETag == "" || a.Loss.State != "prepared" || !s.bound(a.Loss) { |
| 259 | return errMoveLossScope |
| 260 | } |
| 261 | _, err := s.registered(ctx) |
| 262 | return err |
| 263 | } |
| 264 | |
| 265 | func (s *lossScopedStore) lossRecord(ctx context.Context, body []byte, etag string) error { |
| 266 | var r hostclaim.LossRecord |
| 267 | if release.VerifyRootEnvelope(s.pub, body, &r) != nil || !s.bound(r) { |
| 268 | return errMoveLossScope |
| 269 | } |
| 270 | if _, err := s.registered(ctx); err != nil { |
| 271 | return err |
| 272 | } |
| 273 | before, err := hostclaim.ReadLossRecord(ctx, s.raw, s.pub, s.b.lossID) |
| 274 | if errors.Is(err, hostclaim.ErrLossRecordAbsent) { |
| 275 | if etag != "" || r.State != "prepared" { |
| 276 | return errMoveLossScope |
| 277 | } |
| 278 | return nil |
| 279 | } |
| 280 | if err != nil { |
| 281 | return err |
| 282 | } |
| 283 | if before.ETag != etag { |
| 284 | return release.ErrCASConflict |
| 285 | } |
| 286 | if hostclaim.ValidateLossRecordReplacement(before, r) != nil { |
| 287 | return errMoveLossScope |
| 288 | } |
| 289 | return nil |
| 290 | } |
| 291 | |
| 292 | // quarantine admits exactly Directory.Quarantine of the bound box for the |
| 293 | // bound declaration: one revision, that box's state, revision and loss ids, |
| 294 | // nothing else. |
| 295 | func (s *lossScopedStore) quarantine(ctx context.Context, body []byte, etag string) error { |
| 296 | if _, err := s.registered(ctx); err != nil { |
| 297 | return err |
| 298 | } |
| 299 | raw, tag, err := s.raw.Get(ctx, hostactors.Key) |
| 300 | if err != nil { |
| 301 | return err |
| 302 | } |
| 303 | if tag != etag { |
| 304 | return release.ErrCASConflict |
| 305 | } |
| 306 | var before, after hostactors.Roster |
| 307 | if release.VerifyRootEnvelope(s.pub, raw, &before) != nil || release.VerifyRootEnvelope(s.pub, body, &after) != nil { |
| 308 | return errMoveLossScope |
| 309 | } |
| 310 | old, ok := before.Boxes[s.b.box] |
| 311 | next, ok2 := after.Boxes[s.b.box] |
| 312 | if !ok || !ok2 || after.Revision != before.Revision+1 || next.State != "quarantined" || next.QuarantineRevision != old.QuarantineRevision+1 || |
| 313 | !reflect.DeepEqual(next.LossIDs, append(append([]string(nil), old.LossIDs...), s.b.lossID)) { |
| 314 | return errMoveLossScope |
| 315 | } |
| 316 | rest := hostactors.Clone(after) |
| 317 | rest.Revision = before.Revision |
| 318 | box := next |
| 319 | box.State, box.QuarantineRevision, box.LossIDs = old.State, old.QuarantineRevision, old.LossIDs |
| 320 | rest.Boxes[s.b.box] = box |
| 321 | a, err1 := json.Marshal(rest) |
| 322 | b, err2 := json.Marshal(before) |
| 323 | if err1 != nil || err2 != nil || !bytes.Equal(a, b) { |
| 324 | return errMoveLossScope |
| 325 | } |
| 326 | return nil |
| 327 | } |
| 328 | |
| 329 | // claim admits exactly the declaration's claim CAS (hostclaim.DeclareLost |
| 330 | // of the bound loss id, while the loss is prepared) or the owner's release |
| 331 | // of the lost target (once the claim carries the declaration), each |
| 332 | // recomputed from the bucket's current claim at the offered ETag. |
| 333 | func (s *lossScopedStore) claim(ctx context.Context, body []byte, etag string) error { |
| 334 | o, err := s.registered(ctx) |
| 335 | if err != nil { |
| 336 | return err |
| 337 | } |
| 338 | if o.ETag != etag { |
| 339 | return release.ErrCASConflict |
| 340 | } |
| 341 | loss, err := hostclaim.ReadLossRecord(ctx, s.raw, s.pub, s.b.lossID) |
| 342 | if err != nil { |
| 343 | return err |
| 344 | } |
| 345 | if !s.bound(loss.Record) { |
| 346 | return errMoveLossScope |
| 347 | } |
| 348 | owner := &moveLossOwner{store: readOnlyStore{s.raw}, pub: s.pub, now: s.now} |
| 349 | var want []byte |
| 350 | switch { |
| 351 | case loss.Record.State == "prepared" && !slicesContain(o.Claim.LossRefs, s.b.lossID): |
| 352 | want, err = simulateClaim(ctx, s.raw, o, func(sim release.Store) error { |
| 353 | _, err := hostclaim.DeclareLost(ctx, sim, s.pub, o, s.b.lossID, owner) |
| 354 | return err |
| 355 | }) |
| 356 | case loss.Record.State == "committed" && slicesContain(o.Claim.LossRefs, s.b.lossID): |
| 357 | in, _, ierr := lossIntentOf(loss.Record) |
| 358 | if ierr != nil { |
| 359 | return ierr |
| 360 | } |
| 361 | rel := moveLossRelease(in, loss.Record, o.Claim.Holder, s.b.host) |
| 362 | want, err = simulateClaim(ctx, s.raw, o, func(sim release.Store) error { |
| 363 | _, err := hostclaim.Update(ctx, sim, o, hostclaim.ReleaseLostTarget(rel)) |
| 364 | return err |
| 365 | }) |
| 366 | default: |
| 367 | return errMoveLossScope |
| 368 | } |
| 369 | if err != nil { |
| 370 | return err |
| 371 | } |
| 372 | if !bytes.Equal(want, body) { |
| 373 | return errMoveLossScope |
| 374 | } |
| 375 | return nil |
| 376 | } |
| 377 | |
| 378 | // record admits exactly the owner's two record transitions for the holder's |
| 379 | // record: the prepared declaration's decision at the inspected record, or |
| 380 | // the committed declaration's clean release once the claim has released |
| 381 | // the target. Only move fields, the record sequence and the move's update |
| 382 | // time may differ from the recomputation. |
| 383 | func (s *lossScopedStore) record(ctx context.Context, key string, body []byte, etag string) error { |
| 384 | o, err := s.registered(ctx) |
| 385 | if err != nil { |
| 386 | return err |
| 387 | } |
| 388 | if key != o.Claim.Holder.Key { |
| 389 | return errMoveLossScope |
| 390 | } |
| 391 | if err := CheckCreateFreeze(ctx, s.raw, s.pub); err != nil { |
| 392 | return err |
| 393 | } |
| 394 | cur, err := ReadRecord(ctx, s.raw, key) |
| 395 | if err != nil { |
| 396 | return err |
| 397 | } |
| 398 | if cur.ETag != etag { |
| 399 | return release.ErrCASConflict |
| 400 | } |
| 401 | if !matches(cur, o.Claim.Holder) { |
| 402 | return ErrIdentity |
| 403 | } |
| 404 | loss, err := hostclaim.ReadLossRecord(ctx, s.raw, s.pub, s.b.lossID) |
| 405 | if err != nil { |
| 406 | return err |
| 407 | } |
| 408 | if !s.bound(loss.Record) { |
| 409 | return errMoveLossScope |
| 410 | } |
| 411 | in, ref, err := lossIntentOf(loss.Record) |
| 412 | if err != nil { |
| 413 | return err |
| 414 | } |
| 415 | _, seq, err := recordSequence(cur.Body) |
| 416 | if err != nil { |
| 417 | return err |
| 418 | } |
| 419 | _, next, err := recordSequence(body) |
| 420 | if err != nil || next != seq+1 { |
| 421 | return errMoveLossScope |
| 422 | } |
| 423 | want, err := provision.DecodeRecord(cur.Body) |
| 424 | if err != nil { |
| 425 | return ErrIdentity |
| 426 | } |
| 427 | switch { |
| 428 | case loss.Record.State == "prepared" && o.ETag == loss.Record.ExpectedClaimETag && cur.ETag == in.RecordETag && seq == in.RecordSeq: |
| 429 | if in.Supersedes != "" { |
| 430 | // A rebind is admitted only while the superseded declaration is |
| 431 | // still signed conflicted and the intent recomputes exactly. |
| 432 | again, ierr := lossIntentAt(ctx, readOnlyStore{s.raw}, s.pub, o, cur, in.Target) |
| 433 | if ierr != nil || !reflect.DeepEqual(again, in) { |
| 434 | return errMoveLossScope |
| 435 | } |
| 436 | } |
| 437 | err = prepareMoveLoss(&want, in, ref, loss.Record.FencedAt) |
| 438 | case loss.Record.State == "committed" && hostclaim.LostTargetReleased(o.Claim, s.b.lossID): |
| 439 | err = provision.MoveRollbackLossCommitted(provision.MoveGuard{ID: in.Move, Incarnation: in.Incarnation})(&want) |
| 440 | default: |
| 441 | return errMoveLossScope |
| 442 | } |
| 443 | if err != nil { |
| 444 | return err |
| 445 | } |
| 446 | expected, err := json.Marshal(want) |
| 447 | if err != nil { |
| 448 | return err |
| 449 | } |
| 450 | if !sameExceptSequenceAndUpdate(expected, body) { |
| 451 | return errMoveLossScope |
| 452 | } |
| 453 | return nil |
| 454 | } |
| 455 | |
| 456 | // sameExceptSequenceAndUpdate compares two record bodies as JSON values, |
| 457 | // ignoring record_seq and move.updated_at only. |
| 458 | func sameExceptSequenceAndUpdate(a, b []byte) bool { |
| 459 | strip := func(raw []byte) (map[string]any, bool) { |
| 460 | var v map[string]any |
| 461 | dec := json.NewDecoder(bytes.NewReader(raw)) |
| 462 | dec.UseNumber() |
| 463 | if dec.Decode(&v) != nil { |
| 464 | return nil, false |
| 465 | } |
| 466 | delete(v, "record_seq") |
| 467 | if m, ok := v["move"].(map[string]any); ok { |
| 468 | delete(m, "updated_at") |
| 469 | } |
| 470 | return v, true |
| 471 | } |
| 472 | x, ok1 := strip(a) |
| 473 | y, ok2 := strip(b) |
| 474 | return ok1 && ok2 && reflect.DeepEqual(x, y) |
| 475 | } |
| 476 | |
| 477 | // simulateClaim runs a claim write against a private copy of the bucket's |
| 478 | // current claim and returns the bytes it would Put. Every other key reads |
| 479 | // through to the bucket; no write reaches it. |
| 480 | func simulateClaim(ctx context.Context, raw release.Store, o hostclaim.Observed, write func(release.Store) error) ([]byte, error) { |
| 481 | key, err := hostclaim.Key(o.Host) |
| 482 | if err != nil { |
| 483 | return nil, err |
| 484 | } |
| 485 | sim := &claimSimulation{raw: raw, key: key, etag: o.ETag} |
| 486 | if err := write(sim); err != nil { |
| 487 | return nil, err |
| 488 | } |
| 489 | if sim.put == nil { |
| 490 | return nil, errMoveLossScope |
| 491 | } |
| 492 | return sim.put, nil |
| 493 | } |
| 494 | |
| 495 | type claimSimulation struct { |
| 496 | raw release.Store |
| 497 | key, etag string |
| 498 | put []byte |
| 499 | } |
| 500 | |
| 501 | func (s *claimSimulation) Get(ctx context.Context, key string) ([]byte, string, error) { |
| 502 | return s.raw.Get(ctx, key) |
| 503 | } |
| 504 | func (s *claimSimulation) List(ctx context.Context, prefix string) ([]string, error) { |
| 505 | return s.raw.List(ctx, prefix) |
| 506 | } |
| 507 | func (s *claimSimulation) ListDirs(ctx context.Context, prefix string) ([]string, error) { |
| 508 | return s.raw.ListDirs(ctx, prefix) |
| 509 | } |
| 510 | func (s *claimSimulation) Delete(context.Context, string) error { return errMoveLossScope } |
| 511 | func (s *claimSimulation) Put(_ context.Context, key string, body []byte, etag string) (string, error) { |
| 512 | if key != s.key || etag != s.etag || s.put != nil { |
| 513 | return "", errMoveLossScope |
| 514 | } |
| 515 | s.put = append([]byte(nil), body...) |
| 516 | return "simulated", nil |
| 517 | } |
| 518 | |