moves: participant identity by box and instance, the self-serve report shapes, contextual validation with a freshness table, and staged ownership
ParticipantRole is the one resolver of a box's role (box id and instance id with Participants, today's region and label rules without); ParticipantStillPresent is the gates' identity check. OfflineReport gains the identity, request, lane, ready, precheck, staged book, layout, round, lift and final-copy fields; the lane role and the lane, precheck, ready and reverting phases. ValidateOfflineReport is unchanged; ValidateOfflineReportFor adds the move's rules. StagedMatches is the console's staged ownership comparison; FailedDetail a failed report's diagnostic.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
4 files changed,
+1018
−7
internal/moves/offline.go+438 −7internal/moves/offline_test.go+357 −0internal/moves/participants.go+122 −0internal/moves/participants_test.go+101 −0
diff --git a/internal/moves/offline.go b/internal/moves/offline.go| index f7eaff645..aa33c8db5 100644 |
| --- a/internal/moves/offline.go |
| +++ b/internal/moves/offline.go |
| @@ -7,6 +7,7 @@ import ( |
| "errors" |
| "fmt" |
| "path/filepath" |
| + "sort" |
| "time" |
| "github.com/carlosframework/platform/internal/provision" |
| @@ -129,6 +130,11 @@ const ( |
| OfflineRoleSealer = "sealer" |
| OfflineRoleTarget = "target" |
| OfflineRoleStager = "stager" |
| + // OfflineRoleLane is the stager's lane probe of a self-serve move |
| + // (self-serve moves plan 2 Task 6): the source participant that stages |
| + // proves, once per move while it is offline-queued, that it can put, |
| + // list and delete in the staging bucket. |
| + OfflineRoleLane = "lane" |
| ) |
| // The phases a report names. |
| @@ -144,16 +150,32 @@ const ( |
| OfflinePhaseFailed = "failed" |
| // OfflinePhaseStaged is a stager's report after a pre-copy round. |
| OfflinePhaseStaged = "staged" |
| + // The self-serve move's phases (plan 2 Task 6): the lane probe's |
| + // report (role lane), the source's precheck and the target's ready |
| + // report, each answering the record's precheck_at (Request), and the |
| + // sealer's report that the tombstone is lifted during a revert, written |
| + // before the cleanup of the target's prefixes. |
| + OfflinePhaseLane = "lane" |
| + OfflinePhasePrecheck = "precheck" |
| + OfflinePhaseReady = "ready" |
| + OfflinePhaseReverting = "reverting" |
| ) |
| // OfflineReportVersion is OfflineReport.V. |
| const OfflineReportVersion = 1 |
| // StagedObject is one object of the staged replica prefix: its size and ETag, |
| -// keyed in OfflineReport.Staged by the key relative to the prefix. |
| +// keyed in OfflineReport.Staged by the key relative to the prefix. In a |
| +// stager's book (self-serve moves plan 2 Task 6) VersionID is the |
| +// destination version the copy returned, when the store returns one, and |
| +// Deferred marks a key whose last copy was deferred because the source |
| +// changed under it: an entry that is Deferred with no ETag was never copied |
| +// and must be absent from the staging prefix. |
| type StagedObject struct { |
| - Size int64 `json:"size"` |
| - ETag string `json:"etag"` |
| + Size int64 `json:"size"` |
| + ETag string `json:"etag"` |
| + VersionID string `json:"version_id,omitempty"` |
| + Deferred bool `json:"deferred,omitempty"` |
| } |
| // RetiredPath is one line of a source's parked report: a path the box kept |
| @@ -240,13 +262,58 @@ type OfflineReport struct { |
| // per stage request (provision.OfflineStage, OfflineRestage), and a |
| // stager runs none while its report already answers the record's. |
| StageRequest time.Time `json:"stage_request,omitzero"` |
| + |
| + // The self-serve move's fields (self-serve moves plan 2 Task 6). All |
| + // are additive and omitempty: a report written before them parses and |
| + // re-encodes byte for byte. |
| + // |
| + // BoxID and InstanceID are the reporting box's identity: its edges |
| + // file's box id and the cloud machine it runs on (status.InstanceID), |
| + // which ValidateOfflineReportFor compares with the move's pinned |
| + // participants. Request is the record's precheck_at a precheck or ready |
| + // report answers. |
| + BoxID string `json:"box_id,omitempty"` |
| + InstanceID string `json:"instance_id,omitempty"` |
| + Request time.Time `json:"request,omitzero"` |
| + // Lane is the lane report's result, Ready the target's facts and |
| + // Precheck the source's. |
| + Lane *LaneReport `json:"lane,omitempty"` |
| + Ready *TargetReadyReport `json:"ready,omitempty"` |
| + Precheck *SourcePrecheck `json:"precheck,omitempty"` |
| + // The stager's book: StagedKnown says Staged is the book of what this |
| + // move staged (every copy the mirror reported, receipt-vouched and |
| + // same-ETag skips included), at StagedBucket and StagedRegion, the |
| + // record's staging coordinates; StagedRequest is always equal to |
| + // StageRequest on a stager report (the stager sets both). |
| + StagedKnown bool `json:"staged_known,omitempty"` |
| + StagedBucket string `json:"staged_bucket,omitempty"` |
| + StagedRegion string `json:"staged_region,omitempty"` |
| + StagedRequest time.Time `json:"staged_request,omitzero"` |
| + // Layout is the source's layout for the host, flat or nested, on every |
| + // source's stopped and precheck report. |
| + Layout string `json:"layout,omitempty"` |
| + // What the stager's last round copied and deferred, and how many |
| + // rounds it has run for the move. |
| + RoundCopiedBytes int64 `json:"round_copied_bytes,omitempty"` |
| + RoundDeferredBytes int64 `json:"round_deferred_bytes,omitempty"` |
| + RoundDeferredObjects int `json:"round_deferred_objects,omitempty"` |
| + Rounds int `json:"rounds,omitempty"` |
| + // The sealer's revert: TombstoneLifted once lease.LiftTombstone has |
| + // answered that the source's lease no longer holds this move's |
| + // tombstone, and TombstoneLiftedAt the first time it did (kept on every |
| + // retry, and on the final reverted report). |
| + TombstoneLifted bool `json:"tombstone_lifted,omitempty"` |
| + TombstoneLiftedAt time.Time `json:"tombstone_lifted_at,omitzero"` |
| + // FinalCopiedBytes is what the seal's own final mirror round copied. |
| + FinalCopiedBytes int64 `json:"final_copied_bytes,omitempty"` |
| } |
| var ( |
| - offlineRoles = map[string]bool{OfflineRoleSource: true, OfflineRoleSealer: true, OfflineRoleTarget: true, OfflineRoleStager: true} |
| + offlineRoles = map[string]bool{OfflineRoleSource: true, OfflineRoleSealer: true, OfflineRoleTarget: true, OfflineRoleStager: true, |
| + OfflineRoleLane: true} |
| offlinePhases = map[string]bool{OfflinePhaseStopped: true, OfflinePhaseSealed: true, OfflinePhaseLanded: true, OfflinePhaseOpened: true, |
| OfflinePhaseParked: true, OfflinePhaseGone: true, OfflinePhaseLifted: true, OfflinePhaseReverted: true, OfflinePhaseFailed: true, |
| - OfflinePhaseStaged: true} |
| + OfflinePhaseStaged: true, OfflinePhaseLane: true, OfflinePhasePrecheck: true, OfflinePhaseReady: true, OfflinePhaseReverting: true} |
| ) |
| // offlineCarries reports whether a role's report may carry the item. A |
| @@ -332,8 +399,8 @@ func offlineSegment(s string) bool { |
| // OfflineReportKey is where a box writes its report: |
| // control/offline-moves/<host>/<op>/<role>/<box>.json. It is "" when host |
| -// fails provision.ValidHostShape, role is not source, sealer, target or |
| -// stager, or op |
| +// fails provision.ValidHostShape, role is not source, sealer, target, |
| +// stager or lane, or op |
| // or box is not a plain name; a caller that gets "" has a bug and writes |
| // nothing. The prefix is outside every create-only and no-delete prefix, so |
| // a box may rewrite its own report. |
| @@ -628,3 +695,367 @@ func OfflineParkDir(dataDir, host, op, role string) string { |
| func OfflineNamesOK(host, op, role, box string) bool { |
| return OfflineReportKey(host, op, role, box) != "" |
| } |
| + |
| +// ---- the self-serve move's reports (self-serve moves plan 2 Task 6) ---- |
| + |
| +// Fact is one thing a box observed for a precheck or a ready report: Known |
| +// is whether the box could tell at all, OK whether what it saw passes the |
| +// box's own rule, and Detail what it saw when that is worth saying (a path, |
| +// a build, an error). The box reports facts; the console decides. |
| +type Fact struct { |
| + Known bool `json:"known"` |
| + OK bool `json:"ok"` |
| + Detail string `json:"detail,omitempty"` |
| +} |
| + |
| +// LaneReport is the lane probe's result (the existing Phase 1 probe, |
| +// put-if-absent, list and delete-version of LaneKey(move id) in the staging |
| +// bucket): where it probed, and whether it passed. |
| +type LaneReport struct { |
| + Bucket string `json:"bucket"` |
| + Region string `json:"region"` |
| + Key string `json:"key"` |
| + OK bool `json:"ok"` |
| + Error string `json:"error,omitempty"` |
| +} |
| + |
| +// PrefixObject is one current object under a bucket prefix. |
| +type PrefixObject struct { |
| + ETag string `json:"etag"` |
| + Size int64 `json:"size"` |
| + VersionID string `json:"version_id,omitempty"` |
| + LastModified time.Time `json:"last_modified"` |
| +} |
| + |
| +// PrefixFacts is a bucket prefix as the box listed it: keys relative to the |
| +// prefix, current versions only, delete markers and lock.json left out. |
| +// Known is false when the listing failed. |
| +type PrefixFacts struct { |
| + Known bool `json:"known"` |
| + Objects map[string]PrefixObject `json:"objects,omitempty"` |
| +} |
| + |
| +// ReturnLeftovers is what a target box found of an earlier forward move |
| +// when it reports ready for that move's return (Task 8 fills it; nil on |
| +// every other ready report). |
| +type ReturnLeftovers struct { |
| + ForwardID string `json:"forward_id"` |
| + LockOwner string `json:"lock_owner"` |
| + LockLifted bool `json:"lock_lifted"` |
| + ParkDirs []string `json:"park_dirs"` |
| + EmptyNested []string `json:"empty_nested"` |
| + NewerObjects []string `json:"newer_objects,omitempty"` |
| + Layout string `json:"layout"` |
| +} |
| + |
| +// TargetReadyReport is the target box's facts before any copy and again |
| +// before the stop: nothing of the host's on the box (route, timers, data |
| +// paths), a live link and a programs file absent or naming the channel's |
| +// build, the park directory, free disk, the target bucket's two prefixes |
| +// and the lock, the box's capabilities, its secrets key fingerprint and |
| +// whether root's stage path is armed. DiskFreeBytes is what the console |
| +// compares with twice the replica's size; DiskFree's own OK is the box's |
| +// local threshold. |
| +type TargetReadyReport struct { |
| + RouteAbsent Fact `json:"route_absent"` |
| + TimersAbsent Fact `json:"timers_absent"` |
| + LiveLink Fact `json:"live_link"` |
| + ChannelBuild string `json:"channel_build"` |
| + ProgramsFile Fact `json:"programs_file"` |
| + DataPaths map[string]Fact `json:"data_paths"` |
| + ParkDir Fact `json:"park_dir"` |
| + DiskFree Fact `json:"disk_free"` |
| + DiskFreeBytes int64 `json:"disk_free_bytes"` |
| + MovesPrefix PrefixFacts `json:"moves_prefix"` |
| + InstancesPrefix PrefixFacts `json:"instances_prefix"` |
| + Lock Fact `json:"lock"` |
| + Capabilities []string `json:"capabilities"` |
| + SecretsKeyFP string `json:"secrets_key_fp"` |
| + StagePathArmed Fact `json:"stage_path_armed"` |
| + Leftovers *ReturnLeftovers `json:"leftovers,omitempty"` |
| +} |
| + |
| +// The kinds a StrayEntry names. |
| +const ( |
| + StrayDir = "dir" |
| + StrayFile = "file" |
| + StraySymlink = "symlink" |
| + StrayOther = "other" |
| +) |
| + |
| +// StrayEntry is one entry the instance keeps beside its files directory, |
| +// which the archive cannot carry: its kind, and for a directory how many |
| +// bytes and entries it holds (Known false when that could not be read). |
| +type StrayEntry struct { |
| + Path string `json:"path"` |
| + Kind string `json:"kind"` |
| + Bytes int64 `json:"bytes"` |
| + Entries int `json:"entries"` |
| + Known bool `json:"known"` |
| + Detail string `json:"detail,omitempty"` |
| +} |
| + |
| +// StraysFact is every stray entry, Known false when the directory could not |
| +// be listed. |
| +type StraysFact struct { |
| + Known bool `json:"known"` |
| + Entries []StrayEntry `json:"entries,omitempty"` |
| + Detail string `json:"detail,omitempty"` |
| +} |
| + |
| +// The layouts a source reports. |
| +const ( |
| + LayoutFlat = "flat" |
| + LayoutNested = "nested" |
| +) |
| + |
| +// SourcePrecheck is the source box's facts before the stop: the files the |
| +// archive would carry (a dry run of archive.Build over the seal's own |
| +// selection: the size cap with the env file counted, the entry cap, |
| +// unsupported types, unreadable paths), the layout, the stray entries, the |
| +// build the row runs and whether root's stage path is armed. StraysRemoved |
| +// and StraysLeft are the start's removal of empty stray directories after |
| +// the stop, on the stopped report. |
| +type SourcePrecheck struct { |
| + FilesBytes int64 `json:"files_bytes"` |
| + FilesEntries int `json:"files_entries"` |
| + Layout string `json:"layout"` |
| + ArchiveEligible Fact `json:"archive_eligible"` |
| + Strays StraysFact `json:"strays"` |
| + Build string `json:"build"` |
| + StagePathArmed Fact `json:"stage_path_armed"` |
| + StraysRemoved []string `json:"strays_removed,omitempty"` |
| + StraysLeft []StrayEntry `json:"strays_left,omitempty"` |
| +} |
| + |
| +// The freshness table (self-serve moves plan 2 Task 6). A request-bound |
| +// report (lane by its move id; precheck and ready by Request; staged by |
| +// StagedRequest) answers one request and has no age limit, only at most |
| +// OfflineRequestSkew in the future. The phase reports today's doors read |
| +// by age keep OfflinePhaseFresh with OfflinePhaseSkew. The seal is |
| +// immutable and has no age at all. A participant's identity, read from its |
| +// status report, is believed for OfflineIdentityFresh. |
| +const ( |
| + OfflineRequestSkew = 60 * time.Second |
| + OfflinePhaseFresh = 2 * time.Minute |
| + OfflinePhaseSkew = time.Minute |
| + OfflineIdentityFresh = 10 * time.Minute |
| +) |
| + |
| +// RequestBound reports whether r answers one request (the freshness table's |
| +// first row): a lane, precheck, ready or staged report. |
| +func RequestBound(r OfflineReport) bool { |
| + switch r.Phase { |
| + case OfflinePhaseLane, OfflinePhasePrecheck, OfflinePhaseReady: |
| + return true |
| + case OfflinePhaseStaged: |
| + return r.Role == OfflineRoleStager |
| + } |
| + return false |
| +} |
| + |
| +// OfflineReportFresh is the freshness table for one report at now: a |
| +// request-bound report is fresh whatever its age while it is not more than |
| +// OfflineRequestSkew in the future; any other report is fresh for |
| +// OfflinePhaseFresh and at most OfflinePhaseSkew in the future. A report |
| +// with no At is never fresh. |
| +func OfflineReportFresh(r OfflineReport, now time.Time) bool { |
| + if r.At.IsZero() { |
| + return false |
| + } |
| + age := now.Sub(r.At) |
| + if RequestBound(r) { |
| + return age >= -OfflineRequestSkew |
| + } |
| + return age <= OfflinePhaseFresh && age >= -OfflinePhaseSkew |
| +} |
| + |
| +// ValidateOfflineReportFor is ValidateOfflineReport and the rules a report |
| +// must meet for the move m it is read for, at now. m nil is the legacy |
| +// validation alone (a reader that has not read the move). |
| +// |
| +// - The report is of this move (Op is m.ID when m has one). |
| +// - A stager report that names StagedRequest names it equal to |
| +// StageRequest; on a self-serve move a stager report naming either |
| +// names both. |
| +// - A request-bound report is not more than OfflineRequestSkew in the |
| +// future. |
| +// - With Participants: the report's BoxID and InstanceID are the pinned |
| +// participant's for its role (ParticipantFor; InstanceID compared only |
| +// when the pinned one is non-empty), and its Box is that participant's |
| +// label. |
| +// - With SelfServe: role lane is phase lane with Lane; phase precheck is |
| +// role source with Precheck and Request; phase ready is role target with |
| +// Ready and Request; a stager's staged report has StagedKnown, the |
| +// record's StageBucket and StageRegion, and StagedRequest; phase |
| +// reverting is role sealer with TombstoneLifted. A failed report needs |
| +// the identity rule only (FailedDetail reads its diagnostic). |
| +func ValidateOfflineReportFor(r OfflineReport, m *provision.MoveState, now time.Time) error { |
| + if err := ValidateOfflineReport(r); err != nil { |
| + return err |
| + } |
| + if m == nil { |
| + return nil |
| + } |
| + if m.ID != "" && r.Op != m.ID { |
| + return fmt.Errorf("offline report: of move %q, not %q", r.Op, m.ID) |
| + } |
| + if r.Role == OfflineRoleStager { |
| + switch { |
| + case !r.StagedRequest.IsZero() && !r.StagedRequest.Equal(r.StageRequest): |
| + return errors.New("offline report: the stager's staged_request is not its stage_request") |
| + case m.SelfServe != nil && r.StagedRequest.IsZero() != r.StageRequest.IsZero(): |
| + return errors.New("offline report: a stager report names one of stage_request and staged_request") |
| + } |
| + } |
| + if RequestBound(r) && r.At.Sub(now) > OfflineRequestSkew { |
| + return fmt.Errorf("offline report: written %s in the future", r.At.Sub(now).Round(time.Second)) |
| + } |
| + if m.Participants != nil { |
| + p, ok := ParticipantFor(m, r.Role, r.Box) |
| + if !ok { |
| + return fmt.Errorf("offline report: %s is not a pinned %s participant of move %s", r.Box, r.Role, m.ID) |
| + } |
| + if r.BoxID != p.BoxID || (p.InstanceID != "" && r.InstanceID != p.InstanceID) { |
| + return fmt.Errorf("offline report: %s reports box %q instance %q, the move pinned box %q instance %q", r.Box, r.BoxID, r.InstanceID, p.BoxID, p.InstanceID) |
| + } |
| + } |
| + if m.SelfServe == nil || r.Phase == OfflinePhaseFailed { |
| + return nil |
| + } |
| + pair := func(role, phase string) bool { return (r.Role == role) == (r.Phase == phase) } |
| + switch { |
| + case !pair(OfflineRoleLane, OfflinePhaseLane): |
| + return fmt.Errorf("offline report: a %s report in phase %s", r.Role, r.Phase) |
| + case r.Phase == OfflinePhasePrecheck && r.Role != OfflineRoleSource, |
| + r.Phase == OfflinePhaseReady && r.Role != OfflineRoleTarget, |
| + r.Phase == OfflinePhaseReverting && r.Role != OfflineRoleSealer: |
| + return fmt.Errorf("offline report: a %s report in phase %s", r.Role, r.Phase) |
| + } |
| + switch r.Phase { |
| + case OfflinePhaseLane: |
| + if r.Lane == nil { |
| + return errors.New("offline report: a lane report without its result") |
| + } |
| + case OfflinePhasePrecheck: |
| + if r.Precheck == nil || r.Request.IsZero() { |
| + return errors.New("offline report: a precheck without its facts or its request") |
| + } |
| + case OfflinePhaseReady: |
| + if r.Ready == nil || r.Request.IsZero() { |
| + return errors.New("offline report: a ready report without its facts or its request") |
| + } |
| + case OfflinePhaseStaged: |
| + if r.Role == OfflineRoleStager && (!r.StagedKnown || r.StagedBucket != m.StageBucket || r.StagedRegion != m.StageRegion || r.StagedRequest.IsZero()) { |
| + return errors.New("offline report: a staged report without this move's book") |
| + } |
| + case OfflinePhaseReverting: |
| + if !r.TombstoneLifted { |
| + return errors.New("offline report: a reverting report before the tombstone is lifted") |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +// failedWithoutDetail is FailedDetail's answer for a failed report that says |
| +// nothing. |
| +const failedWithoutDetail = "the box reported a failure without detail" |
| + |
| +// FailedDetail is a failed report's diagnostic: its StageError when set, |
| +// else the reason of its first item that is not OK (in manifest order, then |
| +// any other item by id), else "the box reported a failure without detail". |
| +func FailedDetail(r OfflineReport) string { |
| + if r.StageError != "" { |
| + return r.StageError |
| + } |
| + seen := map[string]bool{} |
| + for _, it := range OfflineManifest { |
| + seen[it.ID] = true |
| + if c, ok := r.Items[it.ID]; ok && !c.OK && c.Reason != "" { |
| + return c.Reason |
| + } |
| + } |
| + var rest []string |
| + for id := range r.Items { |
| + if !seen[id] { |
| + rest = append(rest, id) |
| + } |
| + } |
| + sort.Strings(rest) |
| + for _, id := range rest { |
| + if c := r.Items[id]; !c.OK && c.Reason != "" { |
| + return c.Reason |
| + } |
| + } |
| + return failedWithoutDetail |
| +} |
| + |
| +// ErrStagedMismatch is StagedMatches' answer when the target's staging |
| +// prefix is not what the move's stager staged. The console says "the |
| +// staging prefix for <host> does not match what this move staged". |
| +var ErrStagedMismatch = errors.New("moves: the staging prefix does not match what this move staged") |
| + |
| +// StagedMatches is the staged ownership the console's prechecks and its |
| +// barrier require of a self-serve move: the stager's report r holds a known |
| +// book (StagedKnown) of this move's destination (bucket, region) answering |
| +// request (the row's stage request), the target listed its staging prefix |
| +// (target.Known), and the two hold the same keys, both relative to their |
| +// prefixes: an entry the book marks Deferred with no ETag was never copied |
| +// and must be absent from the target; every other entry must be on the |
| +// target with the book's ETag and size, and the same version id when both |
| +// name one. The barrier also requires the stager's last round to have |
| +// deferred nothing. Any difference is ErrStagedMismatch, wrapped with what |
| +// differed. |
| +func StagedMatches(r OfflineReport, bucket, region string, request time.Time, target PrefixFacts, barrier bool) error { |
| + bad := func(format string, a ...any) error { |
| + return fmt.Errorf("%w: %s", ErrStagedMismatch, fmt.Sprintf(format, a...)) |
| + } |
| + switch { |
| + case !r.StagedKnown: |
| + return bad("the stager reports no book") |
| + case r.StagedBucket != bucket || r.StagedRegion != region: |
| + return bad("the book is of %s (%s), not %s (%s)", r.StagedBucket, r.StagedRegion, bucket, region) |
| + case !r.StagedRequest.Equal(request): |
| + return bad("the book answers the stage request of %s, not %s", r.StagedRequest.UTC().Format(time.RFC3339Nano), request.UTC().Format(time.RFC3339Nano)) |
| + case !target.Known: |
| + return bad("the target could not list its staging prefix") |
| + case barrier && r.RoundDeferredObjects != 0: |
| + return bad("the stager's last round deferred %d object(s)", r.RoundDeferredObjects) |
| + } |
| + keys := make([]string, 0, len(r.Staged)) |
| + for k := range r.Staged { |
| + keys = append(keys, k) |
| + } |
| + sort.Strings(keys) |
| + for _, k := range keys { |
| + b := r.Staged[k] |
| + got, present := target.Objects[k] |
| + if b.Deferred && b.ETag == "" { |
| + if present { |
| + return bad("%s was never copied by this move but is on the target", k) |
| + } |
| + continue |
| + } |
| + switch { |
| + case !present: |
| + return bad("%s is missing on the target", k) |
| + case got.ETag != b.ETag: |
| + return bad("%s has ETag %q on the target, %q in the book", k, got.ETag, b.ETag) |
| + case got.Size != b.Size: |
| + return bad("%s has size %d on the target, %d in the book", k, got.Size, b.Size) |
| + case got.VersionID != "" && b.VersionID != "" && got.VersionID != b.VersionID: |
| + return bad("%s is version %q on the target, %q in the book", k, got.VersionID, b.VersionID) |
| + } |
| + } |
| + extra := make([]string, 0) |
| + for k := range target.Objects { |
| + if _, ok := r.Staged[k]; !ok { |
| + extra = append(extra, k) |
| + } |
| + } |
| + if len(extra) > 0 { |
| + sort.Strings(extra) |
| + return bad("the target holds %s, which this move did not stage", extra[0]) |
| + } |
| + return nil |
| +} |
diff --git a/internal/moves/offline_test.go b/internal/moves/offline_test.go| index 46e8eca3e..f5bc7987f 100644 |
| --- a/internal/moves/offline_test.go |
| +++ b/internal/moves/offline_test.go |
| @@ -2,6 +2,7 @@ package moves |
| import ( |
| "encoding/json" |
| + "errors" |
| "fmt" |
| "reflect" |
| "sort" |
| @@ -523,3 +524,359 @@ func TestOfflineStagerReport(t *testing.T) { |
| } |
| } |
| } |
| + |
| +// --- self-serve moves plan 2 Task 6: the reports, their validation and the |
| +// staged book --- |
| + |
| +var ssAt = time.Date(2026, 10, 22, 9, 0, 0, 0, time.UTC) |
| + |
| +// ssReport is a report of ssMove from the pinned box of its role. |
| +func ssReport(role, phase string) OfflineReport { |
| + r := OfflineReport{V: OfflineReportVersion, Op: offTestOp, Host: offTestHost, Role: role, Phase: phase, At: ssAt} |
| + switch role { |
| + case OfflineRoleTarget: |
| + r.Box, r.BoxID, r.InstanceID = "uk-a", "uk-a", "i-tgt" |
| + default: |
| + r.Box, r.BoxID, r.InstanceID = "ie-a", "ie-a", "i-src" |
| + } |
| + switch phase { |
| + case OfflinePhaseLane: |
| + r.Lane = &LaneReport{Bucket: "data-uk", Region: "eu-west-2", Key: LaneKey(offTestOp), OK: true} |
| + case OfflinePhasePrecheck: |
| + r.Precheck, r.Request = &SourcePrecheck{Layout: LayoutFlat}, ssAt.Add(-time.Minute) |
| + case OfflinePhaseReady: |
| + r.Ready, r.Request = &TargetReadyReport{}, ssAt.Add(-time.Minute) |
| + case OfflinePhaseStaged: |
| + r.StagedKnown, r.StagedBucket, r.StagedRegion = true, "data-uk", "eu-west-2" |
| + r.StageRequest, r.StagedRequest = ssAt.Add(-time.Hour), ssAt.Add(-time.Hour) |
| + case OfflinePhaseReverting: |
| + r.TombstoneLifted, r.TombstoneLiftedAt = true, ssAt |
| + } |
| + return r |
| +} |
| + |
| +// Every report a box wrote before this plan still validates, with and |
| +// without the move it is read for: ValidateOfflineReport is unchanged, and a |
| +// move without Participants or SelfServe adds no rule a legacy report |
| +// misses. A legacy stager report names StageRequest alone. |
| +func TestLegacyReportsStillValidate(t *testing.T) { |
| + legacy := &provision.MoveState{Kind: provision.MoveKindOffline, ID: offTestOp, From: "", To: "uk", State: provision.OfflineSealed, StageBucket: "data-uk"} |
| + reports := []OfflineReport{ |
| + {Role: OfflineRoleSource, Phase: OfflinePhaseStopped, Box: "ie-a", Held: true, Fenced: true}, |
| + {Role: OfflineRoleSealer, Phase: OfflinePhaseSealed, Box: "ie-a", Items: map[string]Check{"db": {OK: true}}}, |
| + {Role: OfflineRoleTarget, Phase: OfflinePhaseLanded, Box: "uk-a", Items: map[string]Check{"files": {OK: true}}}, |
| + {Role: OfflineRoleStager, Phase: OfflinePhaseStaged, Box: "ie-a", StageRequest: ssAt, ReplicaObjects: 3}, |
| + {Role: OfflineRoleStager, Phase: OfflinePhaseFailed, Box: "ie-a", StageRequest: ssAt, StageError: "boom"}, |
| + {Role: OfflineRoleSource, Phase: OfflinePhaseParked, Box: "ie-a", Retired: []RetiredPath{{Path: "/data/x", Action: RetiredParked}}}, |
| + {Role: OfflineRoleSealer, Phase: OfflinePhaseReverted, Box: "ie-a"}, |
| + } |
| + for _, r := range reports { |
| + r.V, r.Op, r.Host, r.At = OfflineReportVersion, offTestOp, offTestHost, ssAt |
| + if err := ValidateOfflineReport(r); err != nil { |
| + t.Errorf("%s/%s: legacy validation: %v", r.Role, r.Phase, err) |
| + } |
| + for _, m := range []*provision.MoveState{nil, legacy} { |
| + if err := ValidateOfflineReportFor(r, m, ssAt); err != nil { |
| + t.Errorf("%s/%s for %v: %v", r.Role, r.Phase, m != nil, err) |
| + } |
| + } |
| + } |
| + // The new roles and phases are names ValidateOfflineReport knows, and |
| + // the lane's key is a report key. |
| + if OfflineReportKey(offTestHost, offTestOp, OfflineRoleLane, "ie-a") != "control/offline-moves/"+offTestHost+"/"+offTestOp+"/lane/ie-a.json" { |
| + t.Fatal("no lane report key") |
| + } |
| + for _, ph := range []string{OfflinePhaseLane, OfflinePhasePrecheck, OfflinePhaseReady, OfflinePhaseReverting} { |
| + r := OfflineReport{V: OfflineReportVersion, Op: offTestOp, Host: offTestHost, Box: "ie-a", Role: OfflineRoleSource, Phase: ph} |
| + if err := ValidateOfflineReport(r); err != nil { |
| + t.Errorf("phase %s: %v", ph, err) |
| + } |
| + } |
| + // Another move's report is not this move's. |
| + r := reports[0] |
| + r.V, r.Op, r.Host, r.At = OfflineReportVersion, "mv_another0000000000000000000", offTestHost, ssAt |
| + if ValidateOfflineReportFor(r, legacy, ssAt) == nil { |
| + t.Error("another move's report validated for this one") |
| + } |
| +} |
| + |
| +func TestContextualValidationForSelfServeMoves(t *testing.T) { |
| + m := ssMove() |
| + ok := []OfflineReport{ |
| + ssReport(OfflineRoleLane, OfflinePhaseLane), |
| + ssReport(OfflineRoleSource, OfflinePhasePrecheck), |
| + ssReport(OfflineRoleTarget, OfflinePhaseReady), |
| + ssReport(OfflineRoleStager, OfflinePhaseStaged), |
| + ssReport(OfflineRoleSealer, OfflinePhaseReverting), |
| + ssReport(OfflineRoleSource, OfflinePhaseStopped), |
| + } |
| + for _, r := range ok { |
| + if err := ValidateOfflineReportFor(r, m, ssAt); err != nil { |
| + t.Errorf("%s/%s: %v", r.Role, r.Phase, err) |
| + } |
| + } |
| + type mut struct { |
| + name string |
| + r OfflineReport |
| + } |
| + var bad []mut |
| + add := func(name, role, phase string, f func(*OfflineReport)) { |
| + r := ssReport(role, phase) |
| + f(&r) |
| + bad = append(bad, mut{name, r}) |
| + } |
| + // Identity. |
| + add("source from another box id", OfflineRoleSource, OfflinePhasePrecheck, func(r *OfflineReport) { r.BoxID = "ie-b" }) |
| + add("source from another instance", OfflineRoleSource, OfflinePhasePrecheck, func(r *OfflineReport) { r.InstanceID = "i-new" }) |
| + add("target from another instance", OfflineRoleTarget, OfflinePhaseReady, func(r *OfflineReport) { r.InstanceID = "" }) |
| + add("target under another label", OfflineRoleTarget, OfflinePhaseReady, func(r *OfflineReport) { r.Box = "uk-b" }) |
| + add("a target report from the source", OfflineRoleTarget, OfflinePhaseReady, func(r *OfflineReport) { r.Box, r.BoxID, r.InstanceID = "ie-a", "ie-a", "i-src" }) |
| + // Role, phase and payload. |
| + add("lane without its result", OfflineRoleLane, OfflinePhaseLane, func(r *OfflineReport) { r.Lane = nil }) |
| + add("lane phase from a source", OfflineRoleLane, OfflinePhaseLane, func(r *OfflineReport) { r.Role = OfflineRoleSource }) |
| + add("lane role in another phase", OfflineRoleLane, OfflinePhaseLane, func(r *OfflineReport) { r.Phase = OfflinePhaseStopped }) |
| + add("precheck without facts", OfflineRoleSource, OfflinePhasePrecheck, func(r *OfflineReport) { r.Precheck = nil }) |
| + add("precheck without a request", OfflineRoleSource, OfflinePhasePrecheck, func(r *OfflineReport) { r.Request = time.Time{} }) |
| + add("precheck from a target", OfflineRoleSource, OfflinePhasePrecheck, func(r *OfflineReport) { |
| + r.Role, r.Box, r.BoxID, r.InstanceID = OfflineRoleTarget, "uk-a", "uk-a", "i-tgt" |
| + }) |
| + add("ready without facts", OfflineRoleTarget, OfflinePhaseReady, func(r *OfflineReport) { r.Ready = nil }) |
| + add("ready without a request", OfflineRoleTarget, OfflinePhaseReady, func(r *OfflineReport) { r.Request = time.Time{} }) |
| + add("staged without a book", OfflineRoleStager, OfflinePhaseStaged, func(r *OfflineReport) { r.StagedKnown = false }) |
| + add("staged into another bucket", OfflineRoleStager, OfflinePhaseStaged, func(r *OfflineReport) { r.StagedBucket = "data-fr" }) |
| + add("staged into another region", OfflineRoleStager, OfflinePhaseStaged, func(r *OfflineReport) { r.StagedRegion = "eu-west-3" }) |
| + add("staged with one request only", OfflineRoleStager, OfflinePhaseStaged, func(r *OfflineReport) { r.StagedRequest = time.Time{} }) |
| + add("staged with two requests", OfflineRoleStager, OfflinePhaseStaged, func(r *OfflineReport) { r.StagedRequest = r.StagedRequest.Add(time.Second) }) |
| + add("staged naming staged_request alone", OfflineRoleStager, OfflinePhaseStaged, func(r *OfflineReport) { r.StageRequest = time.Time{} }) |
| + add("staged from a second source", OfflineRoleStager, OfflinePhaseStaged, func(r *OfflineReport) { r.Box, r.BoxID = "ie-b", "ie-b" }) |
| + add("reverting before the lift", OfflineRoleSealer, OfflinePhaseReverting, func(r *OfflineReport) { r.TombstoneLifted = false }) |
| + add("reverting from a source", OfflineRoleSealer, OfflinePhaseReverting, func(r *OfflineReport) { r.Role = OfflineRoleSource }) |
| + for _, b := range bad { |
| + if err := ValidateOfflineReportFor(b.r, m, ssAt); err == nil { |
| + t.Errorf("%s: accepted", b.name) |
| + } |
| + } |
| + // A failed report is kept with only identity checked: its payload |
| + // may be anything, and FailedDetail reads its diagnostic. |
| + failed := ssReport(OfflineRoleSource, OfflinePhaseFailed) |
| + failed.Items = map[string]Check{"files": {OK: false, Reason: "the archive refused a FIFO"}, "db": {OK: true}} |
| + if err := ValidateOfflineReportFor(failed, m, ssAt); err != nil { |
| + t.Fatalf("a failed report was dropped: %v", err) |
| + } |
| + if got := FailedDetail(failed); got != "the archive refused a FIFO" { |
| + t.Fatalf("FailedDetail = %q", got) |
| + } |
| + failed.InstanceID = "i-new" |
| + if ValidateOfflineReportFor(failed, m, ssAt) == nil { |
| + t.Fatal("a failed report from a replaced box was kept") |
| + } |
| + stagerFailed := ssReport(OfflineRoleStager, OfflinePhaseFailed) |
| + stagerFailed.StageError = "list the replica: boom" |
| + if err := ValidateOfflineReportFor(stagerFailed, m, ssAt); err != nil || FailedDetail(stagerFailed) != "list the replica: boom" { |
| + t.Fatalf("a failed stager report: %v, %q", err, FailedDetail(stagerFailed)) |
| + } |
| + if got := FailedDetail(ssReport(OfflineRoleTarget, OfflinePhaseFailed)); got != "the box reported a failure without detail" { |
| + t.Fatalf("FailedDetail without detail = %q", got) |
| + } |
| + // An id-less pin compares the box id, never the instance. |
| + idless := ssMove() |
| + idless.Participants.Target.InstanceID = "" |
| + r := ssReport(OfflineRoleTarget, OfflinePhaseReady) |
| + r.InstanceID = "i-anything" |
| + if err := ValidateOfflineReportFor(r, idless, ssAt); err != nil { |
| + t.Fatalf("an id-less pin compared the instance: %v", err) |
| + } |
| + r.BoxID = "uk-b" |
| + if ValidateOfflineReportFor(r, idless, ssAt) == nil { |
| + t.Fatal("an id-less pin accepted another box id") |
| + } |
| + // Participants without SelfServe: identity only (an operator move |
| + // pinned by a later plan). |
| + pinned := ssMove() |
| + pinned.SelfServe = nil |
| + if err := ValidateOfflineReportFor(ssReport(OfflineRoleLane, OfflinePhaseLane), pinned, ssAt); err != nil { |
| + t.Fatal(err) |
| + } |
| + lane := ssReport(OfflineRoleLane, OfflinePhaseLane) |
| + lane.Lane = nil |
| + if err := ValidateOfflineReportFor(lane, pinned, ssAt); err != nil { |
| + t.Fatalf("payload rules applied to a move without SelfServe: %v", err) |
| + } |
| +} |
| + |
| +func TestFreshnessTable(t *testing.T) { |
| + now := ssAt |
| + m := ssMove() |
| + cases := []struct { |
| + name string |
| + r OfflineReport |
| + age time.Duration // now - At |
| + fresh bool |
| + valid bool |
| + }{ |
| + {"lane, an hour old", ssReport(OfflineRoleLane, OfflinePhaseLane), time.Hour, true, true}, |
| + {"precheck, a day old", ssReport(OfflineRoleSource, OfflinePhasePrecheck), 24 * time.Hour, true, true}, |
| + {"ready, a day old", ssReport(OfflineRoleTarget, OfflinePhaseReady), 24 * time.Hour, true, true}, |
| + {"staged, a day old", ssReport(OfflineRoleStager, OfflinePhaseStaged), 24 * time.Hour, true, true}, |
| + {"precheck, 59s ahead", ssReport(OfflineRoleSource, OfflinePhasePrecheck), -59 * time.Second, true, true}, |
| + {"precheck, 61s ahead", ssReport(OfflineRoleSource, OfflinePhasePrecheck), -61 * time.Second, false, false}, |
| + {"lane, 2m ahead", ssReport(OfflineRoleLane, OfflinePhaseLane), -2 * time.Minute, false, false}, |
| + {"stopped, 1m59s old", ssReport(OfflineRoleSource, OfflinePhaseStopped), 119 * time.Second, true, true}, |
| + {"stopped, 2m01s old", ssReport(OfflineRoleSource, OfflinePhaseStopped), 121 * time.Second, false, true}, |
| + {"stopped, 59s ahead", ssReport(OfflineRoleSource, OfflinePhaseStopped), -59 * time.Second, true, true}, |
| + {"stopped, 61s ahead", ssReport(OfflineRoleSource, OfflinePhaseStopped), -61 * time.Second, false, true}, |
| + {"reverting, 3m old", ssReport(OfflineRoleSealer, OfflinePhaseReverting), 3 * time.Minute, false, true}, |
| + {"reverted, 3m old", ssReport(OfflineRoleSealer, OfflinePhaseReverted), 3 * time.Minute, false, true}, |
| + {"failed, 3m old", ssReport(OfflineRoleSource, OfflinePhaseFailed), 3 * time.Minute, false, true}, |
| + } |
| + for _, c := range cases { |
| + c.r.At = now.Add(-c.age) |
| + if got := OfflineReportFresh(c.r, now); got != c.fresh { |
| + t.Errorf("%s: fresh %v, want %v", c.name, got, c.fresh) |
| + } |
| + if err := ValidateOfflineReportFor(c.r, m, now); (err == nil) != c.valid { |
| + t.Errorf("%s: validation %v, want valid %v", c.name, err, c.valid) |
| + } |
| + } |
| + if OfflineReportFresh(OfflineReport{Phase: OfflinePhaseLane, Role: OfflineRoleLane}, now) { |
| + t.Error("a report with no time is fresh") |
| + } |
| + if OfflineIdentityFresh != 10*time.Minute || OfflinePhaseFresh != 2*time.Minute || OfflineRequestSkew != time.Minute { |
| + t.Error("the freshness table's bounds moved") |
| + } |
| +} |
| + |
| +// A stager whose round found nothing to stage reports an empty book that is |
| +// still known: StagedKnown survives the round trip with Staged empty, and |
| +// the zero report writes none of the new fields. |
| +func TestEmptyStagedInventoryRoundTrips(t *testing.T) { |
| + in := ssReport(OfflineRoleStager, OfflinePhaseStaged) |
| + in.Staged = map[string]StagedObject{} |
| + raw, err := json.Marshal(in) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var out OfflineReport |
| + if err := json.Unmarshal(raw, &out); err != nil { |
| + t.Fatal(err) |
| + } |
| + if !out.StagedKnown || len(out.Staged) != 0 || out.StagedBucket != "data-uk" || !out.StagedRequest.Equal(in.StagedRequest) { |
| + t.Fatalf("round trip: %+v", out) |
| + } |
| + if err := ValidateOfflineReportFor(out, ssMove(), ssAt); err != nil { |
| + t.Fatalf("the empty book does not validate: %v", err) |
| + } |
| + if err := StagedMatches(out, "data-uk", "eu-west-2", in.StagedRequest, PrefixFacts{Known: true}, true); err != nil { |
| + t.Fatalf("an empty book and an empty prefix: %v", err) |
| + } |
| + // Every field is additive: a full report round-trips, and a zero one |
| + // names none of them. |
| + full := ssReport(OfflineRoleSource, OfflinePhasePrecheck) |
| + full.Precheck = &SourcePrecheck{FilesBytes: 7, FilesEntries: 2, Layout: LayoutNested, ArchiveEligible: Fact{Known: true, OK: true}, |
| + Strays: StraysFact{Known: true, Entries: []StrayEntry{{Path: "/data/x-blobs", Kind: StrayDir, Bytes: 1, Entries: 1, Known: true}}}, |
| + Build: "v1", StagePathArmed: Fact{Known: true, OK: true}, StraysRemoved: []string{"/data/y"}, StraysLeft: []StrayEntry{{Path: "/data/z", Kind: StrayDir, Detail: "busy"}}} |
| + full.Ready = &TargetReadyReport{DataPaths: map[string]Fact{"/data/x": {Known: true, OK: true}}, MovesPrefix: PrefixFacts{Known: true, Objects: map[string]PrefixObject{"a": {ETag: "e", Size: 1, VersionID: "v", LastModified: ssAt}}}, |
| + Capabilities: []string{"offline-move-1"}, Leftovers: &ReturnLeftovers{ForwardID: offTestOp, ParkDirs: []string{}, EmptyNested: []string{}}} |
| + full.Lane = &LaneReport{Bucket: "b", Region: "r", Key: "k", OK: false, Error: "denied"} |
| + full.Layout, full.RoundCopiedBytes, full.RoundDeferredBytes, full.RoundDeferredObjects, full.Rounds = LayoutNested, 1, 2, 3, 4 |
| + full.TombstoneLifted, full.TombstoneLiftedAt, full.FinalCopiedBytes = true, ssAt, 9 |
| + full.Staged = map[string]StagedObject{"a": {Size: 1, ETag: "e", VersionID: "v"}, "b": {Deferred: true}} |
| + raw, _ = json.Marshal(full) |
| + var back OfflineReport |
| + if err := json.Unmarshal(raw, &back); err != nil || !reflect.DeepEqual(full, back) { |
| + t.Fatalf("full round trip: %v\n in %+v\nout %+v", err, full, back) |
| + } |
| + zero, _ := json.Marshal(OfflineReport{}) |
| + for _, k := range []string{"box_id", "instance_id", `"request"`, "lane", "ready", "precheck", "staged_known", "staged_bucket", "staged_region", |
| + "staged_request", "layout", "round_copied_bytes", "round_deferred", "rounds", "tombstone_lifted", "final_copied_bytes"} { |
| + if strings.Contains(string(zero), k) { |
| + t.Errorf("a zero report writes %s: %s", k, zero) |
| + } |
| + } |
| +} |
| + |
| +func TestStagedOwnershipComparison(t *testing.T) { |
| + req := ssAt.Add(-time.Hour) |
| + book := func() OfflineReport { |
| + r := ssReport(OfflineRoleStager, OfflinePhaseStaged) |
| + r.Staged = map[string]StagedObject{ |
| + "generations/0/ltx/0/0001.ltx": {Size: 10, ETag: "e1", VersionID: "v1"}, |
| + "generations/0/ltx/0/0002.ltx": {Size: 20, ETag: "e2"}, |
| + "generations/0/ltx/0/0003.ltx": {Deferred: true}, |
| + } |
| + return r |
| + } |
| + prefix := func() PrefixFacts { |
| + return PrefixFacts{Known: true, Objects: map[string]PrefixObject{ |
| + "generations/0/ltx/0/0001.ltx": {Size: 10, ETag: "e1", VersionID: "v1", LastModified: ssAt}, |
| + "generations/0/ltx/0/0002.ltx": {Size: 20, ETag: "e2", VersionID: "v9", LastModified: ssAt}, |
| + }} |
| + } |
| + if err := StagedMatches(book(), "data-uk", "eu-west-2", req, prefix(), false); err != nil { |
| + t.Fatalf("equal: %v", err) |
| + } |
| + cases := []struct { |
| + name string |
| + r func(*OfflineReport) |
| + p func(*PrefixFacts) |
| + bucket string |
| + request time.Time |
| + barrier bool |
| + }{ |
| + {name: "missing on the target", p: func(p *PrefixFacts) { delete(p.Objects, "generations/0/ltx/0/0002.ltx") }}, |
| + {name: "extra on the target", p: func(p *PrefixFacts) { p.Objects["foreign"] = PrefixObject{Size: 1, ETag: "x"} }}, |
| + {name: "changed etag", p: func(p *PrefixFacts) { |
| + o := p.Objects["generations/0/ltx/0/0001.ltx"] |
| + o.ETag = "other" |
| + p.Objects["generations/0/ltx/0/0001.ltx"] = o |
| + }}, |
| + {name: "changed size", p: func(p *PrefixFacts) { |
| + o := p.Objects["generations/0/ltx/0/0001.ltx"] |
| + o.Size = 11 |
| + p.Objects["generations/0/ltx/0/0001.ltx"] = o |
| + }}, |
| + {name: "version mismatch", p: func(p *PrefixFacts) { |
| + o := p.Objects["generations/0/ltx/0/0001.ltx"] |
| + o.VersionID = "v2" |
| + p.Objects["generations/0/ltx/0/0001.ltx"] = o |
| + }}, |
| + {name: "deferred and never copied present", p: func(p *PrefixFacts) { p.Objects["generations/0/ltx/0/0003.ltx"] = PrefixObject{Size: 3, ETag: "e3"} }}, |
| + {name: "wrong request", request: req.Add(time.Second)}, |
| + {name: "wrong destination", bucket: "data-fr"}, |
| + {name: "unknown prefix", p: func(p *PrefixFacts) { p.Known = false }}, |
| + {name: "an unknown book", r: func(r *OfflineReport) { r.StagedKnown = false }}, |
| + {name: "a book of another bucket", r: func(r *OfflineReport) { r.StagedBucket = "data-fr" }}, |
| + {name: "the barrier with a deferred object", r: func(r *OfflineReport) { r.RoundDeferredObjects = 1 }, barrier: true}, |
| + } |
| + for _, c := range cases { |
| + r, p := book(), prefix() |
| + if c.r != nil { |
| + c.r(&r) |
| + } |
| + if c.p != nil { |
| + c.p(&p) |
| + } |
| + bucket, request := "data-uk", req |
| + if c.bucket != "" { |
| + bucket = c.bucket |
| + } |
| + if !c.request.IsZero() { |
| + request = c.request |
| + } |
| + err := StagedMatches(r, bucket, "eu-west-2", request, p, c.barrier) |
| + if !errors.Is(err, ErrStagedMismatch) { |
| + t.Errorf("%s: %v, want ErrStagedMismatch", c.name, err) |
| + } |
| + } |
| + // A deferred object that was copied before keeps its entry, and the |
| + // prechecks (not the barrier) accept it as the book says. |
| + r := book() |
| + r.Staged["generations/0/ltx/0/0002.ltx"] = StagedObject{Size: 20, ETag: "e2", Deferred: true} |
| + r.RoundDeferredObjects = 1 |
| + if err := StagedMatches(r, "data-uk", "eu-west-2", req, prefix(), false); err != nil { |
| + t.Fatalf("a copied then deferred object: %v", err) |
| + } |
| + if err := StagedMatches(r, "data-uk", "eu-west-2", req, prefix(), true); !errors.Is(err, ErrStagedMismatch) { |
| + t.Fatalf("the barrier accepted a deferred round: %v", err) |
| + } |
| +} |
diff --git a/internal/moves/participants.go b/internal/moves/participants.go| new file mode 100644 |
| index 000000000..d7ccbf1bc |
| --- /dev/null |
| +++ b/internal/moves/participants.go |
| @@ -0,0 +1,122 @@ |
| +package moves |
| + |
| +import ( |
| + "github.com/carlosframework/platform/internal/park" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/status" |
| +) |
| + |
| +// A move's participants (self-serve moves plan 2 Task 6). A self-serve move |
| +// pins its boxes at admission (MoveState.Participants: the source boxes, |
| +// the first of which stages, and the one target), each by its box id and |
| +// the cloud instance its status report named. One resolver decides a box's |
| +// role in every box path and every console gate (ParticipantRole), and one |
| +// identity check runs at every gate (ParticipantStillPresent). A move |
| +// without Participants keeps today's region and label rules everywhere. |
| + |
| +// ParticipantMatches reports whether the box boxID, running on instanceID, |
| +// is the participant p: the same box id, and the same instance unless p was |
| +// pinned without one (it then matches any instance of its box, and never |
| +// another box of the region). |
| +func ParticipantMatches(p provision.Participant, boxID, instanceID string) bool { |
| + return p.BoxID != "" && p.BoxID == boxID && (p.InstanceID == "" || p.InstanceID == instanceID) |
| +} |
| + |
| +// ParticipantReplaced reports whether the box boxID, running on instanceID, |
| +// is a participant of m by box id but not by instance: a box that was |
| +// replaced since the move pinned it. Such a box has no role in the move. A |
| +// move without Participants has nobody to replace. |
| +func ParticipantReplaced(m *provision.MoveState, boxID, instanceID string) bool { |
| + if m == nil || m.Participants == nil || boxID == "" { |
| + return false |
| + } |
| + replaced := func(p provision.Participant) bool { |
| + return p.BoxID == boxID && p.InstanceID != "" && p.InstanceID != instanceID |
| + } |
| + for _, p := range m.Participants.Source { |
| + if replaced(p) { |
| + return true |
| + } |
| + } |
| + return replaced(m.Participants.Target) |
| +} |
| + |
| +// ParticipantRole is the one resolver of a box's role in an offline move m: |
| +// whether the box (boxID on instanceID, its label, its placement code and |
| +// the verified home binding) is a source, the target, the sealer and the |
| +// stager. |
| +// |
| +// With Participants: source when it matches a source participant, target |
| +// when it matches the target, stager when it matches the first source (and |
| +// the record's stager, when it names one, is that participant's label), |
| +// sealer when it is a source and its label is the record's sealer. A box |
| +// that matches a participant's box id but not its pinned instance has no |
| +// role at all (ParticipantReplaced). No region rule applies. |
| +// |
| +// Without Participants, today's rules: source and target by placement under |
| +// the home binding (park.PlacementMatches), sealer by label, stager a source |
| +// whose label is the record's stager. |
| +func ParticipantRole(m *provision.MoveState, boxID, instanceID, label, placement, home string) (source, target, sealer, stager bool) { |
| + if m == nil { |
| + return false, false, false, false |
| + } |
| + if m.Participants == nil { |
| + source = placement != "" && park.PlacementMatches(home, m.From, placement) |
| + target = placement != "" && park.PlacementMatches(home, m.To, placement) |
| + sealer = label != "" && label == m.Sealer |
| + stager = source && label != "" && label == m.Stager |
| + return source, target, sealer, stager |
| + } |
| + if ParticipantReplaced(m, boxID, instanceID) { |
| + return false, false, false, false |
| + } |
| + for i, p := range m.Participants.Source { |
| + if !ParticipantMatches(p, boxID, instanceID) { |
| + continue |
| + } |
| + source = true |
| + if i == 0 && (m.Stager == "" || m.Stager == p.Label) { |
| + stager = true |
| + } |
| + } |
| + target = ParticipantMatches(m.Participants.Target, boxID, instanceID) |
| + sealer = source && label != "" && label == m.Sealer |
| + return source, target, sealer, stager |
| +} |
| + |
| +// ParticipantFor is the pinned participant a report of role from the box |
| +// labelled box must come from: the target for a target report, the first |
| +// source for a stager or lane report, and the source labelled box for a |
| +// source or sealer report. false when m pins no participants or none fits. |
| +func ParticipantFor(m *provision.MoveState, role, box string) (provision.Participant, bool) { |
| + if m == nil || m.Participants == nil { |
| + return provision.Participant{}, false |
| + } |
| + ps := m.Participants |
| + switch role { |
| + case OfflineRoleTarget: |
| + return ps.Target, ps.Target.Label == box && ps.Target.BoxID != "" |
| + case OfflineRoleStager, OfflineRoleLane: |
| + if len(ps.Source) == 0 { |
| + return provision.Participant{}, false |
| + } |
| + return ps.Source[0], ps.Source[0].Label == box && ps.Source[0].BoxID != "" |
| + case OfflineRoleSource, OfflineRoleSealer: |
| + for _, p := range ps.Source { |
| + if p.Label == box && p.BoxID != "" { |
| + return p, true |
| + } |
| + } |
| + } |
| + return provision.Participant{}, false |
| +} |
| + |
| +// ParticipantStillPresent is the identity check every gate makes on a |
| +// participant: one pinned without an instance id is present by its box id |
| +// alone (the label's report is all there is to go on); one pinned with an |
| +// instance id is present only while the label's status report m names the |
| +// same box id and the same instance. The caller judges m's age (within |
| +// OfflineIdentityFresh) before asking. |
| +func ParticipantStillPresent(p provision.Participant, m *status.BoxMetrics) bool { |
| + return p.InstanceID == "" || (m != nil && m.BoxID == p.BoxID && m.InstanceID == p.InstanceID) |
| +} |
diff --git a/internal/moves/participants_test.go b/internal/moves/participants_test.go| new file mode 100644 |
| index 000000000..a0a3921b4 |
| --- /dev/null |
| +++ b/internal/moves/participants_test.go |
| @@ -0,0 +1,101 @@ |
| +package moves |
| + |
| +import ( |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/park" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/status" |
| +) |
| + |
| +// ssMove is a self-serve move from the home placement to uk with pinned |
| +// participants: the source uk-a's sibling ie-a on i-src, the target uk-a |
| +// on i-tgt. |
| +func ssMove() *provision.MoveState { |
| + return &provision.MoveState{ |
| + Kind: provision.MoveKindOffline, ID: offTestOp, From: "", To: "uk", State: provision.OfflineQueued, |
| + Stager: "ie-a", StageBucket: "data-uk", StageRegion: "eu-west-2", |
| + Participants: &provision.Participants{ |
| + Source: []provision.Participant{{BoxID: "ie-a", Label: "ie-a", InstanceID: "i-src", Verified: true}}, |
| + Target: provision.Participant{BoxID: "uk-a", Label: "uk-a", InstanceID: "i-tgt", Verified: true}, |
| + }, |
| + SelfServe: &provision.SelfServeMove{ID: "ss_1", Incarnation: 1}, |
| + } |
| +} |
| + |
| +func TestParticipantRoleByBoxAndInstance(t *testing.T) { |
| + type role struct{ source, target, sealer, stager bool } |
| + type tc struct { |
| + name string |
| + m *provision.MoveState |
| + boxID, inst, label, placement string |
| + want role |
| + } |
| + home := park.HomeRegion |
| + m := ssMove() |
| + m.Sealer = "ie-a" |
| + // An id-less participant matches any instance of its box, never |
| + // another box of the region. |
| + idless := ssMove() |
| + idless.Participants.Source[0].InstanceID = "" |
| + idless.Participants.Target.InstanceID = "" |
| + // The stager is the first source, and only while the record's stager |
| + // names it. |
| + other := ssMove() |
| + other.Stager = "ie-z" |
| + // Without Participants: today's region and label rules. |
| + legacy := &provision.MoveState{Kind: provision.MoveKindOffline, ID: offTestOp, From: "", To: "uk", State: provision.OfflineStaging, Sealer: "ie-a", Stager: "ie-a"} |
| + cases := []tc{ |
| + {"the pinned source", m, "ie-a", "i-src", "ie-a", home, role{source: true, sealer: true, stager: true}}, |
| + {"the pinned target", m, "uk-a", "i-tgt", "uk-a", "uk", role{target: true}}, |
| + {"a replaced source has no role", m, "ie-a", "i-new", "ie-a", home, role{}}, |
| + {"a replaced target has no role", m, "uk-a", "i-new", "uk-a", "uk", role{}}, |
| + {"another box of the source region has no role", m, "ie-b", "i-b", "ie-b", home, role{}}, |
| + {"another box of the target region has no role", m, "uk-b", "i-b", "uk-b", "uk", role{}}, |
| + {"an empty box id has no role", m, "", "", "", home, role{}}, |
| + {"an id-less source on any instance", idless, "ie-a", "i-whatever", "ie-a", home, role{source: true, stager: true}}, |
| + {"an id-less target with no instance", idless, "uk-a", "", "uk-a", "uk", role{target: true}}, |
| + {"an id-less pin is not another box of the region", idless, "uk-b", "", "uk-b", "uk", role{}}, |
| + {"a first source the record does not name as stager", other, "ie-a", "i-src", "ie-a", home, role{source: true}}, |
| + {"legacy source by region, stager and sealer by label", legacy, "", "", "ie-a", home, role{source: true, sealer: true, stager: true}}, |
| + {"legacy source by region alone", legacy, "x", "y", "ie-b", home, role{source: true}}, |
| + {"legacy target by region", legacy, "", "", "uk-b", "uk", role{target: true}}, |
| + {"legacy box with no placement", legacy, "", "", "uk-b", "", role{}}, |
| + } |
| + for _, c := range cases { |
| + s, tg, se, st := ParticipantRole(c.m, c.boxID, c.inst, c.label, c.placement, "") |
| + if got := (role{s, tg, se, st}); got != c.want { |
| + t.Errorf("%s: got %+v, want %+v", c.name, got, c.want) |
| + } |
| + } |
| + if !ParticipantReplaced(m, "ie-a", "i-new") || ParticipantReplaced(m, "ie-a", "i-src") || ParticipantReplaced(legacy, "ie-a", "x") { |
| + t.Error("ParticipantReplaced does not tell a replaced box") |
| + } |
| + if ParticipantMatches(provision.Participant{}, "", "") { |
| + t.Error("an empty participant matched an empty box") |
| + } |
| +} |
| + |
| +func TestParticipantStillPresent(t *testing.T) { |
| + pinned := provision.Participant{BoxID: "uk-a", Label: "uk-a", InstanceID: "i-1"} |
| + idless := provision.Participant{BoxID: "uk-a", Label: "uk-a"} |
| + cases := []struct { |
| + name string |
| + p provision.Participant |
| + m *status.BoxMetrics |
| + want bool |
| + }{ |
| + {"same box and instance", pinned, &status.BoxMetrics{BoxID: "uk-a", InstanceID: "i-1"}, true}, |
| + {"replaced instance", pinned, &status.BoxMetrics{BoxID: "uk-a", InstanceID: "i-2"}, false}, |
| + {"another box id", pinned, &status.BoxMetrics{BoxID: "uk-b", InstanceID: "i-1"}, false}, |
| + {"no report", pinned, nil, false}, |
| + {"a report naming no instance", pinned, &status.BoxMetrics{BoxID: "uk-a"}, false}, |
| + {"id-less, no report", idless, nil, true}, |
| + {"id-less, any instance", idless, &status.BoxMetrics{BoxID: "uk-a", InstanceID: "i-9"}, true}, |
| + } |
| + for _, c := range cases { |
| + if got := ParticipantStillPresent(c.p, c.m); got != c.want { |
| + t.Errorf("%s: %v, want %v", c.name, got, c.want) |
| + } |
| + } |
| +} |