| index ff8ab90d9..f7eaff645 100644 |
| --- a/internal/moves/offline.go |
| +++ b/internal/moves/offline.go |
| @@ -3,6 +3,8 @@ package moves |
| import ( |
| "crypto/sha256" |
| "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| "fmt" |
| "path/filepath" |
| "time" |
| @@ -451,17 +453,128 @@ func OfflineStager(boxes []string) string { |
| return stager |
| } |
| |
| -// OfflineActiveKey is the one lock of the offline move: while it names a |
| -// host whose record still carries that move, no other offline move starts. |
| +// OfflineActiveKey is the offline moves' reservation table (self-serve |
| +// moves plan 2 Task 3; spec §7.1): one entry per participating box of every |
| +// operation from its reservation to its release. Before the table form it |
| +// was one deployment-wide lock, and its legacy fields still carry that |
| +// lock's shape as the table's projection. |
| const OfflineActiveKey = "control/offline-moves/active.json" |
| |
| -// OfflineActive is the lock's body. |
| +// OfflineActive is active.json's body. Reservations is the table, keyed by |
| +// box id (OfflineSentinel for an operation that reserves every box). Host, |
| +// Op, Account, App and At are the projection: the oldest move entry's host, |
| +// id, account, app and reserved_at (the oldest entry of any kind when no |
| +// move holds a box), so a console that predates the table reads one lock |
| +// it believes while young and whose record it checks when old. A body with |
| +// no Reservations and V 0 is a legacy lock (ParseOfflineActive). |
| type OfflineActive struct { |
| - Host string `json:"host"` |
| - Op string `json:"op"` |
| - Account string `json:"account"` |
| - App string `json:"app"` |
| - At time.Time `json:"at"` |
| + V int `json:"v,omitempty"` |
| + Host string `json:"host"` |
| + Op string `json:"op"` |
| + Account string `json:"account"` |
| + App string `json:"app"` |
| + At time.Time `json:"at"` |
| + Reservations map[string]OfflineReservation `json:"reservations,omitempty"` |
| +} |
| + |
| +// OfflineActiveVersion is the table form's V. |
| +const OfflineActiveVersion = 1 |
| + |
| +// The reservation kinds, and the sentinel key. |
| +const ( |
| + // OfflineKindMove is a self-serve or operator move; ID is the move id. |
| + OfflineKindMove = "move" |
| + // OfflineKindCleanup is a box's cleanup of a retired host (plan 4); |
| + // ID is its retired.json's id. |
| + OfflineKindCleanup = "cleanup" |
| + // OfflineSentinel is the key of an operation that reserves every box: |
| + // a selection with a participant whose InstanceID is empty, and a |
| + // legacy lock. It conflicts with every other acquisition, and every |
| + // acquisition conflicts with it. |
| + OfflineSentinel = "*" |
| +) |
| + |
| +// OfflineReservation is one box's entry. ExpectSeq and ExpectIncarnation |
| +// are the record the reserver observed before reserving, the sequence and |
| +// lifetime its first record write is bound to (provision.SetRecordMoveAt). |
| +type OfflineReservation struct { |
| + Kind string `json:"kind"` |
| + ID string `json:"id"` |
| + Host string `json:"host"` |
| + Account string `json:"account"` |
| + App string `json:"app"` |
| + ReservedAt time.Time `json:"reserved_at"` |
| + ExpectSeq uint64 `json:"expect_seq,omitempty"` |
| + ExpectIncarnation uint64 `json:"expect_incarnation,omitempty"` |
| +} |
| + |
| +// valid: a known kind, an id, a host and a time. |
| +func (r OfflineReservation) valid() bool { |
| + return (r.Kind == OfflineKindMove || r.Kind == OfflineKindCleanup) && r.ID != "" && r.Host != "" && !r.ReservedAt.IsZero() |
| +} |
| + |
| +// ErrOfflineActiveMalformed is a body that is not a table and not a legacy |
| +// lock, or holds any malformed entry: the whole object is unreadable until an |
| +// operator repairs it, and no reservation is acquired, reclaimed or released |
| +// against it. |
| +var ErrOfflineActiveMalformed = errors.New("moves: active.json is malformed") |
| + |
| +// ParseOfflineActive reads active.json's body as its reservation table. A |
| +// legacy lock (no Reservations, V 0) reads as the one sentinel entry |
| +// {move, Op, Host, Account, App, At}. An unknown version, a table without |
| +// V, an empty key, or an entry with an unknown kind, an empty id or host, or |
| +// a zero time is ErrOfflineActiveMalformed. |
| +func ParseOfflineActive(raw []byte) (map[string]OfflineReservation, error) { |
| + var a OfflineActive |
| + if err := json.Unmarshal(raw, &a); err != nil { |
| + return nil, fmt.Errorf("%w: %v", ErrOfflineActiveMalformed, err) |
| + } |
| + if a.V == 0 && a.Reservations == nil { |
| + e := OfflineReservation{Kind: OfflineKindMove, ID: a.Op, Host: a.Host, Account: a.Account, App: a.App, ReservedAt: a.At} |
| + if !e.valid() { |
| + return nil, fmt.Errorf("%w: a legacy lock without a host, an op or a time", ErrOfflineActiveMalformed) |
| + } |
| + return map[string]OfflineReservation{OfflineSentinel: e}, nil |
| + } |
| + if a.V != OfflineActiveVersion { |
| + return nil, fmt.Errorf("%w: version %d", ErrOfflineActiveMalformed, a.V) |
| + } |
| + table := make(map[string]OfflineReservation, len(a.Reservations)) |
| + for k, e := range a.Reservations { |
| + if k == "" || !e.valid() { |
| + return nil, fmt.Errorf("%w: entry %q", ErrOfflineActiveMalformed, k) |
| + } |
| + table[k] = e |
| + } |
| + return table, nil |
| +} |
| + |
| +// OfflineActiveBody is the table form of table: V, the table, and the |
| +// projection (OfflineActive's doc). Ties in reserved_at go to the lower key, |
| +// so two writers project one table identically. |
| +func OfflineActiveBody(table map[string]OfflineReservation) OfflineActive { |
| + a := OfflineActive{V: OfflineActiveVersion, Reservations: table} |
| + var pick string |
| + better := func(k string) bool { |
| + if pick == "" { |
| + return true |
| + } |
| + e, p := table[k], table[pick] |
| + if (e.Kind == OfflineKindMove) != (p.Kind == OfflineKindMove) { |
| + return e.Kind == OfflineKindMove |
| + } |
| + return e.ReservedAt.Before(p.ReservedAt) || (e.ReservedAt.Equal(p.ReservedAt) && k < pick) |
| + } |
| + for k := range table { |
| + if better(k) { |
| + pick = k |
| + } |
| + } |
| + if pick != "" { |
| + e := table[pick] |
| + a.Host, a.Op, a.Account, a.App, a.At = e.Host, e.ID, e.Account, e.App, e.ReservedAt |
| + } |
| + return a |
| } |
| |
| // OfflineArchivePrefix is where the move's sealed files archive and its |