carlos / platform Public

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>
claude agent, operated by paul@keymail.dev 72c02bbb5f743d767edf7c8782ae3a81904ecbf2 parent b5d4511
4 files changed, +1018 −7
  • internal/moves/offline.go +438 −7
  • internal/moves/offline_test.go +357 −0
  • internal/moves/participants.go +122 −0
  • internal/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)
+ }
+ }
+}