carlos / platform Public

offline-move: console doors fix round 1 (immutable seal, literal routes, start lock, failed flag, left paths)

The seal is written once, create-only, at control/offline-moves/<host>/<id>/seal.json
and source.evidence names it by hash, so the sealer's per-pass report can change
without breaking flip. The doors are literal cliRoute registrations (route-gate tests
green). Start takes a create-only active lock instead of listing every record.
The status view carries failed and the paths a source left.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 968e6068172b6daf0fc1be7899c02be37ba16420 parent e242e49
6 files changed, +644 −99
  • internal/console/apiinstancemove_offline.go +213 −57
  • internal/console/apiinstancemove_offline_test.go +293 −36
  • internal/console/authcli.go +7 −1
  • internal/console/svcroutes_test.go +15 −5
  • internal/moves/offline.go +64 −0
  • internal/moves/offline_test.go +52 −0
diff --git a/internal/console/apiinstancemove_offline.go b/internal/console/apiinstancemove_offline.go
index 32354ac35..033ceb635 100644
--- a/internal/console/apiinstancemove_offline.go
+++ b/internal/console/apiinstancemove_offline.go
@@ -21,8 +21,8 @@ import (
"github.com/carlosframework/platform/internal/hostops"
"github.com/carlosframework/platform/internal/moves"
"github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/release"
"github.com/carlosframework/platform/internal/status"
- "github.com/carlosframework/platform/internal/svcrole"
)
// The offline operator move's doors (docs/superpowers/plans/2026-10-20-
@@ -57,7 +57,7 @@ const offlineMoveCapability = "offline-move-1"
const offlineReportFresh = 2 * time.Minute
// Every sentence a door speaks has its row in the copy record (MV-O20 to
-// MV-O31, MV-O40, MV-O41) or is an existing approved one, written out whole
+// MV-O31, MV-O50, MV-O51) or is an existing approved one, written out whole
// where it is used so the copy gate reads it.
// offlineName is a region code as the operator reads it: the home's
@@ -163,8 +163,13 @@ type offlineReport struct {
raw []byte
}
-// offlineEvidence is every report of one move, as read.
-type offlineEvidence struct{ reports []offlineReport }
+// offlineEvidence is every report of one move, as read, and its seal: the
+// immutable record of what the sealer decided (moves.OfflineSealKey), nil
+// when absent or not a valid seal of this move.
+type offlineEvidence struct {
+ reports []offlineReport
+ seal *offlineReport
+}
func (e offlineEvidence) get(role, box string) (offlineReport, bool) {
for _, r := range e.reports {
@@ -199,7 +204,11 @@ func (s *Server) readOfflineEvidence(ctx context.Context, host, id string) (offl
return offlineEvidence{}, err
}
var ev offlineEvidence
+ sealKey := moves.OfflineSealKey(host, id)
for _, key := range keys {
+ if key == sealKey {
+ continue // read below, as the seal and not as a report
+ }
raw, _, err := s.cfg.Releases.Get(ctx, key)
if errors.Is(err, os.ErrNotExist) {
continue
@@ -221,6 +230,19 @@ func (s *Server) readOfflineEvidence(ctx context.Context, host, id string) (offl
}
return ev.reports[i].Box < ev.reports[j].Box
})
+ raw, _, err := s.cfg.Releases.Get(ctx, sealKey)
+ switch {
+ case errors.Is(err, os.ErrNotExist):
+ case err != nil:
+ return offlineEvidence{}, err
+ default:
+ var rep moves.OfflineReport
+ if err := json.Unmarshal(raw, &rep); err != nil || moves.ValidateOfflineSeal(rep, host, id) != nil {
+ log.Printf("console: offline move %s: ignoring %s: not the seal of this move", host, sealKey)
+ } else {
+ ev.seal = &offlineReport{OfflineReport: rep, raw: raw}
+ }
+ }
return ev, nil
}
@@ -266,10 +288,12 @@ type offlineFleet struct {
func (s *Server) readOfflineFleet(ctx context.Context) (offlineFleet, bool) {
snap, err := s.liveEdges(ctx)
if err != nil || !snap.EvidenceComplete() {
+ log.Printf("console: offline move: the edges files are not complete evidence (error %v)", err)
return offlineFleet{}, false
}
boxes, complete, err := status.LoadBoxesComplete(ctx, s.cfg.Releases)
if err != nil || !complete {
+ log.Printf("console: offline move: the boxes' status reports are not complete evidence (complete %v, error %v)", complete, err)
return offlineFleet{}, false
}
f := offlineFleet{snap: snap, metrics: map[string]*status.BoxMetrics{}}
@@ -458,18 +482,6 @@ func (s *Server) handleAPIInstanceOfflineStart(w http.ResponseWriter, r *http.Re
writeRefusal(w, ref)
return
}
- all, unreadable, err := provision.ListAllRecordsStrict(ctx, s.cfg.Releases)
- if err != nil || len(unreadable) > 0 {
- log.Printf("console: offline move %s: listing records: %v (%d unreadable)", host, err, len(unreadable))
- jsonError(w, http.StatusBadGateway, "could not read the instance")
- return
- }
- for _, o := range all {
- if o.Host != host && o.Move.Offline() {
- jsonError(w, http.StatusConflict, fmt.Sprintf("%s is moving offline. move one instance at a time", o.Host))
- return
- }
- }
var req struct {
Region string `json:"region"`
}
@@ -501,9 +513,10 @@ func (s *Server) handleAPIInstanceOfflineStart(w http.ResponseWriter, r *http.Re
jsonError(w, http.StatusConflict, "the fleet's edges files could not all be read. try again")
return
}
- if to == "" {
+ if to == "" || rec.Region == "" {
// A box that sets CARLOS_REGION to the home's name is home to this
- // console and not to itself, so it cannot take the move.
+ // console and not to itself, so it cannot take a move into home, and
+ // out of home the placement it publishes is not the record's.
for _, f := range fleet.snap.Files {
if f.Placement != "" && homePlacementCode(s.moveHomeName(), f.Placement) == "" {
jsonError(w, http.StatusConflict, f.Box+" sets CARLOS_REGION, so it cannot take a move into the home region")
@@ -527,25 +540,125 @@ func (s *Server) handleAPIInstanceOfflineStart(w http.ResponseWriter, r *http.Re
jsonError(w, http.StatusConflict, offlineConflict(err))
return
}
- s.offlineAdvance(w, r, d, start)
+ // One offline move at a time: the lock is taken before the record is
+ // written, and released if the write fails.
+ if ref := s.offlineTakeLock(ctx, d, id); ref != nil {
+ writeRefusal(w, ref)
+ return
+ }
+ got, ok := s.offlineWrite(w, r, d, start)
+ if !ok {
+ s.offlineReleaseLock(ctx, host, id)
+ return
+ }
+ writeJSON(w, http.StatusAccepted, s.offlineViewOf(ctx, got))
}
-// offlineAdvance applies one transition and answers 202 with the status
-// view of the record it left.
-func (s *Server) offlineAdvance(w http.ResponseWriter, r *http.Request, d offlineDoor, mut func(*provision.Record) error) {
+// offlineWrite applies one transition and bumps the serial, or answers the
+// refusal itself. When the move it leaves is cleared (finish, or the finish
+// that ends an abort) it releases the lock.
+func (s *Server) offlineWrite(w http.ResponseWriter, r *http.Request, d offlineDoor, mut func(*provision.Record) error) (*provision.Record, bool) {
ctx := r.Context()
rec, err := provision.SetRecordMove(ctx, s.cfg.Releases, d.acct.Sqid, d.app, d.host, mut)
if errors.Is(err, provision.ErrMoveConflict) {
jsonError(w, http.StatusConflict, offlineConflict(err))
- return
+ return nil, false
}
if err != nil {
log.Printf("console: offline move %s/%s/%s: %v", d.acct.Sqid, d.app, d.host, err)
jsonError(w, http.StatusBadGateway, "could not write the move. try again")
- return
+ return nil, false
}
s.bumpSerial(ctx)
- writeJSON(w, http.StatusAccepted, s.offlineViewOf(ctx, rec))
+ if rec.Move == nil && d.rec.Move != nil {
+ s.offlineReleaseLock(ctx, d.host, d.rec.Move.ID)
+ }
+ return rec, true
+}
+
+// offlineAdvance applies one transition and answers 202 with the status
+// view of the record it left.
+func (s *Server) offlineAdvance(w http.ResponseWriter, r *http.Request, d offlineDoor, mut func(*provision.Record) error) {
+ if rec, ok := s.offlineWrite(w, r, d, mut); ok {
+ writeJSON(w, http.StatusAccepted, s.offlineViewOf(r.Context(), rec))
+ }
+}
+
+// offlineLockGrace is how long a lock is believed whatever its record says:
+// the start door takes the lock, then writes the record.
+const offlineLockGrace = 2 * time.Minute
+
+// offlineTakeLock takes control/offline-moves/active.json for this host and
+// move, create-only. A lock already there is live while the record it names
+// still carries that move, or is younger than offlineLockGrace (MV-O30 naming
+// its host); otherwise it is stale (a crashed start, a failed release) and is
+// replaced under its etag. A
+// lock that cannot be read is not guessed at.
+func (s *Server) offlineTakeLock(ctx context.Context, d offlineDoor, id string) *offlineRefusal {
+ unreadable := &offlineRefusal{status: http.StatusBadGateway, msg: "could not read the instance"}
+ body, err := json.Marshal(moves.OfflineActive{Host: d.host, Op: id, Account: d.acct.Sqid, App: d.app, At: time.Now().UTC()})
+ if err != nil {
+ return unreadable
+ }
+ _, err = s.cfg.Releases.Put(ctx, moves.OfflineActiveKey, body, "")
+ if err == nil {
+ return nil
+ }
+ if !errors.Is(err, release.ErrCASConflict) {
+ log.Printf("console: offline move %s: taking the lock: %v", d.host, err)
+ return unreadable
+ }
+ raw, etag, err := s.cfg.Releases.Get(ctx, moves.OfflineActiveKey)
+ var held moves.OfflineActive
+ if err != nil || json.Unmarshal(raw, &held) != nil || held.Host == "" {
+ log.Printf("console: offline move %s: reading the lock: %v", d.host, err)
+ return unreadable
+ }
+ busy := &offlineRefusal{status: http.StatusConflict, msg: fmt.Sprintf("%s is moving offline. move one instance at a time", held.Host)}
+ // A lock is taken before its record is written, so a record without the
+ // move is only a stale lock once the holder has had time to write it.
+ if time.Since(held.At) < offlineLockGrace {
+ return busy
+ }
+ other, err := provision.GetRecord(ctx, s.cfg.Releases, held.Account, held.App, held.Host)
+ switch {
+ case errors.Is(err, os.ErrNotExist):
+ case err != nil:
+ log.Printf("console: offline move %s: reading the lock holder %s: %v", d.host, held.Host, err)
+ return unreadable
+ case other.Move.Offline() && other.Move.ID == held.Op:
+ return busy
+ }
+ if _, err := s.cfg.Releases.Put(ctx, moves.OfflineActiveKey, body, etag); err != nil {
+ if errors.Is(err, release.ErrCASConflict) {
+ return busy
+ }
+ log.Printf("console: offline move %s: replacing a stale lock: %v", d.host, err)
+ return unreadable
+ }
+ return nil
+}
+
+// offlineReleaseLock deletes the lock under the etag it read, when it is
+// this move's. A failure is logged and left: the next start finds the lock
+// stale (its record no longer carries the move) and replaces it.
+func (s *Server) offlineReleaseLock(ctx context.Context, host, id string) {
+ ctx = context.WithoutCancel(ctx)
+ raw, etag, err := s.cfg.Releases.Get(ctx, moves.OfflineActiveKey)
+ if errors.Is(err, os.ErrNotExist) {
+ return
+ }
+ var held moves.OfflineActive
+ if err != nil || json.Unmarshal(raw, &held) != nil {
+ log.Printf("console: offline move %s: reading the lock to release it: %v", host, err)
+ return
+ }
+ if held.Host != host || held.Op != id {
+ return
+ }
+ if err := release.DeleteIfMatch(ctx, s.cfg.Releases, moves.OfflineActiveKey, etag); err != nil {
+ log.Printf("console: offline move %s: releasing the lock: %v", host, err)
+ }
}
// ---- the step each state waits for ----
@@ -678,15 +791,22 @@ func (s *Server) offlineSealCheck(f offlineFleet, ev offlineEvidence, mv *provis
return sealer, boxes, nil
}
-// offlineFlipCheck: the seal's report is the one the record names and every
-// item it owns is OK; every source box has stopped and left no process or
-// layout journal, on a report this fresh.
+// offlineSealHolds says the record's source.evidence names the seal on disk,
+// byte for byte. The seal is written once, so the hash holds for the whole
+// move whatever the sealer's living report does each pass.
+func offlineSealHolds(ev offlineEvidence, mv *provision.MoveState, host string) bool {
+ return ev.seal != nil && mv.Source != nil &&
+ offlineEvidenceMatches(mv.Source.Evidence, moves.OfflineSealKey(host, mv.ID), ev.seal.raw)
+}
+
+// offlineFlipCheck: the seal is the one the record names and every item the
+// source side reports is OK in it; every source box has stopped and left no
+// process or layout journal, on a report this fresh.
func (s *Server) offlineFlipCheck(ev offlineEvidence, mv *provision.MoveState, host, from string, now time.Time) *offlineRefusal {
- seal, ok := ev.get(moves.OfflineRoleSealer, mv.Sealer)
- if !ok || mv.Source == nil || !offlineEvidenceMatches(mv.Source.Evidence, moves.OfflineReportKey(host, mv.ID, moves.OfflineRoleSealer, mv.Sealer), seal.raw) {
+ if !offlineSealHolds(ev, mv, host) {
return offlineStateRefusal(mv.State, host)
}
- if items, _ := offlineSealItems(seal.OfflineReport); !items {
+ if items, _ := offlineSealItems(ev.seal.OfflineReport); !items {
return offlineStateRefusal(mv.State, host)
}
for _, box := range mv.Boxes {
@@ -709,9 +829,9 @@ func processRefusal(box, host string) *offlineRefusal {
}
// offlineOpenCheck is the re-check of the stop: the target's landing report
-// has every landing item OK, the sealer's report is from this pass and holds
-// the tombstone for this move, and every source box is still held, fenced and
-// quiesced.
+// has every landing item OK, the seal is still the one the record names, the
+// sealer's living report is from this pass and holds the tombstone for this
+// move, and every source box is still held, fenced and quiesced.
func (s *Server) offlineOpenCheck(ev offlineEvidence, mv *provision.MoveState, host, from string, now time.Time) *offlineRefusal {
tgt := ev.byRole(moves.OfflineRoleTarget)
if len(tgt) != 1 {
@@ -720,6 +840,9 @@ func (s *Server) offlineOpenCheck(ev offlineEvidence, mv *provision.MoveState, h
if items, _ := offlineLandingItems(tgt[0].OfflineReport); !items {
return offlineStateRefusal(mv.State, host)
}
+ if !offlineSealHolds(ev, mv, host) {
+ return offlineStateRefusal(mv.State, host)
+ }
seal, ok := ev.get(moves.OfflineRoleSealer, mv.Sealer)
if !ok || !offlineFresh(seal.OfflineReport, now) || seal.TombstoneOwner != moves.TombstoneOwner(mv.ID) || !seal.TombstoneUntil.After(now) {
return offlineStateRefusal(mv.State, host)
@@ -757,13 +880,37 @@ func offlineFinishCheck(ev offlineEvidence, mv *provision.MoveState, host, targe
return refuse
}
for _, box := range boxes {
- if rep, ok := ev.get(moves.OfflineRoleSource, box); !ok || rep.Phase != sourcePhase {
+ rep, ok := ev.get(moves.OfflineRoleSource, box)
+ if !ok || rep.Phase != sourcePhase || !offlineRetiredTerminal(rep.Retired) {
return refuse
}
}
return nil
}
+// offlineRetiredTerminal says every path a parked report lists is settled:
+// parked, absent, or left with the reason it could not be parked (a
+// root-owned directory, a view mount). A left path is terminal; the move
+// goes on and the status view shows it. An unknown action, or a left with no
+// reason, is not a settled path.
+func offlineRetiredTerminal(retired []moves.RetiredPath) bool {
+ for _, p := range retired {
+ switch p.Action {
+ case moves.RetiredParked, moves.RetiredAbsent:
+ case moves.RetiredLeft:
+ if p.Reason == "" {
+ return false
+ }
+ default:
+ return false
+ }
+ if p.Path == "" {
+ return false
+ }
+ }
+ return true
+}
+
// offlineVerbDoor is the shape of seal, flip, open and finish: the gate, an
// offline move in flight, the state the verb follows, the evidence, the
// verb's checks, one transition.
@@ -827,6 +974,14 @@ type offlineReportView struct {
Fresh bool `json:"fresh"`
}
+// offlineLeftView is a path a source box could not park and left in place,
+// with the reason it gave (the parked report's inventory).
+type offlineLeftView struct {
+ Box string `json:"box"`
+ Path string `json:"path"`
+ Reason string `json:"reason"`
+}
+
// offlineItemView is one row of the manifest with the latest check of it.
// Status is ok, waiting, failed or unchecked (no report decides the item).
type offlineItemView struct {
@@ -843,7 +998,8 @@ type offlineItemView struct {
// State is "none" when no offline move is in flight (Last then carries the
// outcome of the one that ended). Next is the verb that moves the move on
// right now; empty while a box has work to do. Waiting is the sentence that
-// verb would be refused with. CanAbort says abort is legal.
+// verb would be refused with. CanAbort says abort is legal. Failed says the
+// sealer or the target reported it could not do its step: abort is the way on.
type offlineView struct {
Host string `json:"host"`
State string `json:"state"`
@@ -859,6 +1015,8 @@ type offlineView struct {
Next string `json:"next,omitempty"`
Waiting string `json:"waiting,omitempty"`
CanAbort bool `json:"can_abort,omitempty"`
+ Failed bool `json:"failed,omitempty"`
+ Left []offlineLeftView `json:"left,omitempty"`
Reports []offlineReportView `json:"reports,omitempty"`
Items []offlineItemView `json:"items,omitempty"`
Last *provision.MoveOutcome `json:"last,omitempty"`
@@ -886,7 +1044,7 @@ func (s *Server) offlineViewOf(ctx context.Context, rec *provision.Record) offli
}
ev, err := s.readOfflineEvidence(ctx, rec.Host, mv.ID)
if err != nil {
- log.Printf("console: offline move %s: reports: %v", rec.Host, err)
+ log.Printf("console: offline move %s: reading the reports: %v", rec.Host, err)
v.Waiting = "could not read the move's reports. try again"
return v
}
@@ -894,6 +1052,13 @@ func (s *Server) offlineViewOf(ctx context.Context, rec *provision.Record) offli
v.Reports = append(v.Reports, offlineReportView{OfflineReport: r.OfflineReport, Fresh: offlineFresh(r.OfflineReport, now)})
}
v.Items = offlineItems(ev, mv)
+ for _, r := range ev.byRole(moves.OfflineRoleSource) {
+ for _, p := range r.Retired {
+ if p.Action == moves.RetiredLeft {
+ v.Left = append(v.Left, offlineLeftView{Box: r.Box, Path: p.Path, Reason: p.Reason})
+ }
+ }
+ }
if verb := offlineVerbOf(mv.State); verb != "" {
d := offlineDoor{host: rec.Host}
if _, ref := s.offlineStepFor(ctx, d, mv, ev, now); ref != nil {
@@ -902,11 +1067,12 @@ func (s *Server) offlineViewOf(ctx context.Context, rec *provision.Record) offli
v.Next = verb
}
}
- // Landing waits for a box, unless the box says it cannot land.
- if mv.State == provision.OfflineLanding {
- for _, r := range ev.byRole(moves.OfflineRoleTarget) {
- if r.Phase == moves.OfflinePhaseFailed {
- v.Waiting = fmt.Sprintf("%s could not land %s. run carlos move offline abort --host %s", r.Box, rec.Host, rec.Host)
+ // A box that says it failed (the sealer, or the target that cannot
+ // land) means abort is the way on. The CLI says it with MV-O6.
+ if v.CanAbort {
+ for _, r := range ev.reports {
+ if r.Phase == moves.OfflinePhaseFailed && (r.Role == moves.OfflineRoleSealer || r.Role == moves.OfflineRoleTarget) {
+ v.Failed = true
}
}
}
@@ -924,6 +1090,11 @@ func offlineItems(ev offlineEvidence, mv *provision.MoveState) []offlineItemView
if r, ok := ev.get(moves.OfflineRoleSealer, mv.Sealer); ok {
owners[moves.OfflineOwnerSeal] = []offlineReport{r}
}
+ if ev.seal != nil {
+ // The seal is what the sealer decided; the living report is the
+ // latest word on the same items, and is asked first.
+ owners[moves.OfflineOwnerSeal] = append(owners[moves.OfflineOwnerSeal], *ev.seal)
+ }
out := make([]offlineItemView, 0, len(moves.OfflineManifest))
for _, it := range moves.OfflineManifest {
row := offlineItemView{ID: it.ID, Item: it.Item, Disposition: it.Disposition, Owner: it.Owner, Source: it.Source, Status: "waiting"}
@@ -960,18 +1131,3 @@ func (s *Server) handleAPIInstanceOfflineStatus(w http.ResponseWriter, r *http.R
}
writeJSON(w, http.StatusOK, s.offlineViewOf(r.Context(), d.rec))
}
-
-// registerOfflineMoveRoutes registers the offline move's doors. They are
-// always registered: a console on the claim path or with Phase 1 on answers
-// every one with MV-O20, after the operator gate.
-func (s *Server) registerOfflineMoveRoutes() {
- const base = "/api/cli/apps/{account}/{app}/instances/{host}/move/offline"
- inst := svcrole.Allow(svcrole.Instance)
- s.cliRoute("GET "+base, inst, s.handleAPIInstanceOfflineStatus)
- s.cliRoute("POST "+base, inst, s.handleAPIInstanceOfflineStart)
- s.cliRoute("POST "+base+"/seal", inst, s.offlineVerbDoor(offlineVerbSeal))
- s.cliRoute("POST "+base+"/flip", inst, s.offlineVerbDoor(offlineVerbFlip))
- s.cliRoute("POST "+base+"/open", inst, s.offlineVerbDoor(offlineVerbOpen))
- s.cliRoute("POST "+base+"/finish", inst, s.offlineVerbDoor(offlineVerbFinish))
- s.cliRoute("POST "+base+"/abort", inst, s.handleAPIInstanceOfflineAbort)
-}
diff --git a/internal/console/apiinstancemove_offline_test.go b/internal/console/apiinstancemove_offline_test.go
index 49a7776e8..7e453142f 100644
--- a/internal/console/apiinstancemove_offline_test.go
+++ b/internal/console/apiinstancemove_offline_test.go
@@ -8,6 +8,7 @@ import (
"fmt"
"net/http"
"strings"
+ "sync"
"testing"
"time"
@@ -305,19 +306,38 @@ func sealerChecks() map[string]moves.Check {
return out
}
-// sealed plays the sealer: its report, and the transition that records
-// where it is.
+// putSeal writes seal.json as the sealer does: once and create-only when it
+// is not there, and replaced only by a test that tampers with it.
+func (w *offlineWorld) putSeal(sealer string, edit func(*moves.OfflineReport)) []byte {
+ w.t.Helper()
+ r := moves.OfflineReport{V: moves.OfflineReportVersion, Op: w.id, Host: offlineHost, Box: sealer, Role: moves.OfflineRoleSealer,
+ Phase: moves.OfflinePhaseSealed, At: time.Now().UTC(), Items: sealerChecks(), FinalTXID: 7, TombstoneOwner: moves.TombstoneOwner(w.id)}
+ if edit != nil {
+ edit(&r)
+ }
+ body, err := json.Marshal(r)
+ if err != nil {
+ w.t.Fatal(err)
+ }
+ key := moves.OfflineSealKey(offlineHost, w.id)
+ if _, _, err := w.fx.rel.Get(context.Background(), key); err != nil {
+ if _, err := w.fx.rel.Put(context.Background(), key, body, ""); err != nil {
+ w.t.Fatal(err)
+ }
+ } else {
+ offlinePut(w.t, w.fx.rel, key, body)
+ }
+ return body
+}
+
+// sealed plays the sealer: the seal, its living report, and the transition
+// that records where the seal is. It returns the seal's bytes.
func (w *offlineWorld) sealed(sealer string) []byte {
w.t.Helper()
- raw := w.report(moves.OfflineRoleSealer, sealer, moves.OfflinePhaseSealed, func(r *moves.OfflineReport) {
- r.Items = sealerChecks()
- r.TombstoneOwner = moves.TombstoneOwner(w.id)
- r.TombstoneUntil = time.Now().Add(moves.TombstoneTTL)
- })
- sum := sha256.Sum256(raw)
- key := moves.OfflineReportKey(offlineHost, w.id, moves.OfflineRoleSealer, sealer)
- w.edit(provision.OfflineSealedBy(w.id, sealer, provision.MoveSource{FinalTXID: 7, Evidence: key + "@" + hex.EncodeToString(sum[:])}))
- return raw
+ seal := w.putSeal(sealer, nil)
+ w.sealerFresh(sealer)
+ w.edit(provision.OfflineSealedBy(w.id, sealer, provision.MoveSource{FinalTXID: 7, Evidence: moves.OfflineSealEvidence(offlineHost, w.id, seal)}))
+ return seal
}
// landed plays the target: its landing report and the transition.
@@ -337,6 +357,9 @@ func (w *offlineWorld) toLanded() {
w.stopped(nil, "ie-box")
w.ok("/seal", "", provision.OfflineSealing)
w.sealed("ie-box")
+ // The sealer rewrites its living report on every pass, so by the time
+ // the operator flips it is not the bytes it was at the seal. The seal is.
+ w.sealerFresh("ie-box")
w.stopped(nil, "ie-box")
w.ok("/flip", "", provision.OfflineLanding)
w.landed("uk-box")
@@ -462,14 +485,11 @@ func TestOfflineStartRefusals(t *testing.T) {
}
const body = `{"region":"uk"}`
other := func(w *offlineWorld) {
- // A second instance, moving offline.
- u := instancesURL(w.fx, "bab", "titogo")
- if c, _, out := rawReq(t, http.MethodPost, u, w.token, `{"host":"beta.go.tito.io","region":"ie"}`); c != http.StatusCreated {
- t.Fatalf("create = %d %s", c, out)
- }
- if _, err := provision.SetRecordMove(context.Background(), w.fx.rel, "bab", "titogo", "beta.go.tito.io",
- provision.OfflineStart("mv_aaaaaaaaaaaaaaaaaaaaaaaaaa", "ie", "uk", "x", time.Now())); err != nil {
- t.Fatal(err)
+ // A second instance, moving offline: started through its own door, so
+ // it holds the lock.
+ v, code, out := w.startOther("beta.go.tito.io")
+ if code != http.StatusAccepted {
+ t.Fatalf("beta start = %d %s (%+v)", code, out, v)
}
}
cases := []tc{
@@ -725,23 +745,47 @@ func TestOfflineFlipDoor(t *testing.T) {
w.stopped(nil, "ie-box")
w.ok("/seal", "", provision.OfflineSealing)
w.refused(http.MethodPost, "/flip", "", http.StatusConflict, msgO25(provision.OfflineSealing))
- // The sealer's report is not what the record names.
- w.sealed("ie-box")
+ seal := w.sealed("ie-box")
w.stopped(nil, "ie-box")
+ sealKey := moves.OfflineSealKey(offlineHost, w.id)
+ // seal.json is create-only: a second write does not replace it.
+ if _, err := w.fx.rel.Put(context.Background(), sealKey, []byte("{}"), ""); err == nil {
+ t.Fatal("the seal was written twice")
+ }
+ // The sealer's living report is rewritten on every pass and the seal is
+ // not: nothing the living report does moves the hash flip checks.
+ w.sealerFresh("ie-box")
w.report(moves.OfflineRoleSealer, "ie-box", moves.OfflinePhaseSealed, func(r *moves.OfflineReport) {
- r.Items = sealerChecks()
- r.FinalTXID = 99 // a different report from the one the record hashed
+ r.Items = map[string]moves.Check{} // the living report carries no items at all now
+ r.TombstoneOwner = moves.TombstoneOwner(w.id)
+ r.TombstoneUntil = time.Now().Add(time.Hour)
+ r.FinalTXID = 99
})
+ // A seal that is not the one the record names.
+ tampered := w.putSeal("ie-box", func(r *moves.OfflineReport) { r.FinalTXID = 99 })
+ if string(tampered) == string(seal) {
+ t.Fatal("the tampered seal is the seal")
+ }
w.refused(http.MethodPost, "/flip", "", http.StatusConflict, msgO25(provision.OfflineSealed))
- // A seal item that is not OK.
- raw := w.report(moves.OfflineRoleSealer, "ie-box", moves.OfflinePhaseSealed, func(r *moves.OfflineReport) {
- r.Items = sealerChecks()
+ offlinePut(t, w.fx.rel, sealKey, seal)
+ // No seal at all.
+ if err := w.fx.rel.Delete(context.Background(), sealKey); err != nil {
+ t.Fatal(err)
+ }
+ w.refused(http.MethodPost, "/flip", "", http.StatusConflict, msgO25(provision.OfflineSealed))
+ offlinePut(t, w.fx.rel, sealKey, seal)
+ // A seal item that is not OK, and a seal of another move's tombstone, each
+ // with a hash the record names.
+ bad := w.putSeal("ie-box", func(r *moves.OfflineReport) {
r.Items["files"] = moves.Check{OK: false, Reason: "a file the archive refuses"}
})
- w.rehash("ie-box", raw)
+ w.rehash(bad)
+ w.refused(http.MethodPost, "/flip", "", http.StatusConflict, msgO25(provision.OfflineSealed))
+ bad = w.putSeal("ie-box", func(r *moves.OfflineReport) { r.TombstoneOwner = moves.TombstoneOwner("mv_other") })
+ w.rehash(bad)
w.refused(http.MethodPost, "/flip", "", http.StatusConflict, msgO25(provision.OfflineSealed))
- good := w.report(moves.OfflineRoleSealer, "ie-box", moves.OfflinePhaseSealed, func(r *moves.OfflineReport) { r.Items = sealerChecks() })
- w.rehash("ie-box", good)
+ offlinePut(t, w.fx.rel, sealKey, seal)
+ w.rehash(seal)
// A process left, a layout journal, a stale report.
w.stopped(func(_ string, r *moves.OfflineReport) { r.Procs = 1 }, "ie-box")
w.refused(http.MethodPost, "/flip", "", http.StatusConflict, msgO31("ie-box"))
@@ -766,14 +810,12 @@ func TestOfflineFlipDoor(t *testing.T) {
}
}
-// rehash points the record's source evidence at a report's bytes, as the
-// sealer's transition does when it records its own report.
-func (w *offlineWorld) rehash(sealer string, raw []byte) {
+// rehash points the record's source evidence at a seal's bytes, as the
+// sealer's transition records its own seal.
+func (w *offlineWorld) rehash(seal []byte) {
w.t.Helper()
- sum := sha256.Sum256(raw)
- key := moves.OfflineReportKey(offlineHost, w.id, moves.OfflineRoleSealer, sealer)
w.edit(func(r *provision.Record) error {
- r.Move.Source.Evidence = key + "@" + hex.EncodeToString(sum[:])
+ r.Move.Source.Evidence = moves.OfflineSealEvidence(offlineHost, w.id, seal)
return nil
})
}
@@ -822,6 +864,15 @@ func TestOfflineOpenDoorRechecksTheStop(t *testing.T) {
w.stopped(func(_ string, r *moves.OfflineReport) { r.At = time.Now().Add(-10 * time.Minute) }, "ie-box")
w.refused(http.MethodPost, "/open", "", http.StatusConflict, msgMC10("ie-box"))
reset()
+ // The seal changed under the move.
+ sealKey := moves.OfflineSealKey(offlineHost, w.id)
+ sealRaw, _, err := w.fx.rel.Get(context.Background(), sealKey)
+ if err != nil {
+ t.Fatal(err)
+ }
+ offlinePut(t, w.fx.rel, sealKey, append(append([]byte{}, sealRaw...), ' '))
+ w.refused(http.MethodPost, "/open", "", http.StatusConflict, msgO25(provision.OfflineLanded))
+ offlinePut(t, w.fx.rel, sealKey, sealRaw)
// A landing item that is not OK.
w.report(moves.OfflineRoleTarget, "uk-box", moves.OfflinePhaseLanded, func(r *moves.OfflineReport) {
r.Items = okChecks(moves.OfflineOwnerLanding)
@@ -860,7 +911,30 @@ func TestOfflineFinishDoor(t *testing.T) {
w.refused(http.MethodPost, "/finish", "", http.StatusConflict, msgO25(provision.OfflineOpen))
w.report(moves.OfflineRoleTarget, "uk-box", moves.OfflinePhaseOpened, func(r *moves.OfflineReport) { r.Items = okChecks(moves.OfflineOwnerLanding) })
w.refused(http.MethodPost, "/finish", "", http.StatusConflict, msgO25(provision.OfflineOpen))
- w.report(moves.OfflineRoleSource, "ie-box", moves.OfflinePhaseParked, nil)
+ // A parked report whose inventory is not settled is not parked.
+ for name, retired := range map[string][]moves.RetiredPath{
+ "an unknown action": {{Path: "/data/acme.go.tito.io", Action: "deleted"}},
+ "left, no reason": {{Path: "/opt/carlos/live/acme.go.tito.io", Action: moves.RetiredLeft}},
+ "no path": {{Action: moves.RetiredParked}},
+ } {
+ w.report(moves.OfflineRoleSource, "ie-box", moves.OfflinePhaseParked, func(r *moves.OfflineReport) { r.Retired = retired })
+ code, out := w.req(http.MethodPost, "/finish", "")
+ if code != http.StatusConflict || errorText(out) != msgO25(provision.OfflineOpen) {
+ t.Fatalf("%s: %d %s", name, code, out)
+ }
+ }
+ // A path the box could not park is terminal when it says why, and the
+ // status view shows it.
+ w.report(moves.OfflineRoleSource, "ie-box", moves.OfflinePhaseParked, func(r *moves.OfflineReport) {
+ r.Retired = []moves.RetiredPath{
+ {Path: "/data/acme.go.tito.io", Action: moves.RetiredParked},
+ {Path: "/data/acme.go.tito.io.env", Action: moves.RetiredAbsent},
+ {Path: "/opt/carlos/live/acme.go.tito.io", Action: moves.RetiredLeft, Reason: "root-owned"},
+ }
+ })
+ if v := w.status(); v.Next != offlineVerbFinish || len(v.Left) != 1 || v.Left[0] != (offlineLeftView{Box: "ie-box", Path: "/opt/carlos/live/acme.go.tito.io", Reason: "root-owned"}) {
+ t.Fatalf("status = next %q left %+v waiting %q", v.Next, v.Left, v.Waiting)
+ }
before := serialBytes(t, w.fx.rel)
w.ok("/finish", "", provision.OfflineFinishing)
if serialBytes(t, w.fx.rel) == before {
@@ -959,6 +1033,9 @@ func TestOfflineStatusView(t *testing.T) {
if v.State != provision.OfflineLanded || v.ID != w.id || v.Sealer != "ie-box" || v.From != "ie" || v.To != "uk" || !v.CanAbort || v.Next != offlineVerbOpen {
t.Fatalf("status = %+v", v)
}
+ if v.Failed {
+ t.Error("a healthy move is marked failed")
+ }
roles := map[string]bool{}
for _, r := range v.Reports {
roles[r.Role+"/"+r.Box] = true
@@ -992,6 +1069,9 @@ func TestOfflineStatusView(t *testing.T) {
delete(r.Items, "release")
r.Items["files"] = moves.Check{OK: false, Reason: "the digest differs"}
})
+ if !w.status().Failed {
+ t.Error("a sealer that reported failed is not marked failed in the view")
+ }
seen := map[string]offlineItemView{}
for _, it := range w.status().Items {
seen[it.ID] = it
@@ -1125,4 +1205,181 @@ func TestOfflineMovesOutOfAndIntoHome(t *testing.T) {
named := offlineBox{Box: "ie-named", Placement: "ie", Bucket: "data-ie"}
c := newOfflineWorldIn(t, "ie", "uk", homeBox, ukOff("uk-box"), named)
c.refused(http.MethodPost, "", `{"region":"ie"}`, http.StatusConflict, "ie-named sets CARLOS_REGION, so it cannot take a move into the home region")
+
+ // And out of home, where its published placement is not the record's.
+ d := newOfflineWorldIn(t, "ie", "ie", homeBox, ukOff("uk-box"), named)
+ d.refused(http.MethodPost, "", `{"region":"uk"}`, http.StatusConflict, "ie-named sets CARLOS_REGION, so it cannot take a move into the home region")
+}
+
+// startOther creates a second instance and starts it through its own door.
+func (w *offlineWorld) startOther(host string) (offlineView, int, string) {
+ w.t.Helper()
+ u := instancesURL(w.fx, "bab", "titogo")
+ if c, _, out := rawReq(w.t, http.MethodPost, u, w.token, `{"host":"`+host+`","region":"ie"}`); c != http.StatusCreated && c != http.StatusConflict {
+ w.t.Fatalf("create %s = %d %s", host, c, out)
+ }
+ code, _, out := rawReq(w.t, http.MethodPost, u+"/"+host+"/move/offline", w.token, `{"region":"uk"}`)
+ var v offlineView
+ _ = json.Unmarshal([]byte(out), &v)
+ return v, code, out
+}
+
+func (w *offlineWorld) lock() (moves.OfflineActive, bool) {
+ w.t.Helper()
+ raw, _, err := w.fx.rel.Get(context.Background(), moves.OfflineActiveKey)
+ if err != nil {
+ return moves.OfflineActive{}, false
+ }
+ var a moves.OfflineActive
+ if err := json.Unmarshal(raw, &a); err != nil {
+ w.t.Fatal(err)
+ }
+ return a, true
+}
+
+// Two starts at once for different hosts: exactly one wins the lock.
+func TestOfflineConcurrentStartsOneWins(t *testing.T) {
+ w := newOfflineWorld(t, oneEach...)
+ u := instancesURL(w.fx, "bab", "titogo")
+ if c, _, out := rawReq(t, http.MethodPost, u, w.token, `{"host":"beta.go.tito.io","region":"ie"}`); c != http.StatusCreated {
+ t.Fatalf("create = %d %s", c, out)
+ }
+ hosts := []string{offlineHost, "beta.go.tito.io"}
+ type answer struct {
+ code int
+ out string
+ }
+ got := make([]answer, len(hosts))
+ var wg sync.WaitGroup
+ gate := make(chan struct{})
+ for i, h := range hosts {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ <-gate
+ c, _, out := rawReq(t, http.MethodPost, u+"/"+h+"/move/offline", w.token, `{"region":"uk"}`)
+ got[i] = answer{c, out}
+ }()
+ }
+ close(gate)
+ wg.Wait()
+ won, lost := -1, -1
+ for i, a := range got {
+ switch a.code {
+ case http.StatusAccepted:
+ won = i
+ case http.StatusConflict:
+ lost = i
+ default:
+ t.Fatalf("start %s = %d %s", hosts[i], a.code, a.out)
+ }
+ }
+ if won < 0 || lost < 0 {
+ t.Fatalf("want one winner and one loser: %+v", got)
+ }
+ if want := hosts[won] + " is moving offline. move one instance at a time"; errorText(got[lost].out) != want {
+ t.Fatalf("the loser was told %q, want %q", errorText(got[lost].out), want)
+ }
+ if a, ok := w.lock(); !ok || a.Host != hosts[won] {
+ t.Fatalf("lock = %+v %v, want the winner %s", a, ok, hosts[won])
+ }
+ loser, err := provision.GetRecord(context.Background(), w.fx.rel, "bab", "titogo", hosts[lost])
+ if err != nil || loser.Move != nil {
+ t.Fatalf("the loser's record = %+v %v", loser.Move, err)
+ }
+}
+
+// The lock is released when the move ends, by finish and by the finish that
+// ends an abort, and a stale lock does not stop the next start.
+func TestOfflineLockLifecycle(t *testing.T) {
+ w := newOfflineWorld(t, oneEach...)
+ w.toLanded()
+ if a, ok := w.lock(); !ok || a.Host != offlineHost || a.Op != w.id || a.Account != "bab" || a.App != "titogo" {
+ t.Fatalf("lock = %+v %v", a, ok)
+ }
+ w.ok("/open", "", provision.OfflineOpen)
+ w.report(moves.OfflineRoleTarget, "uk-box", moves.OfflinePhaseOpened, func(r *moves.OfflineReport) { r.Items = okChecks(moves.OfflineOwnerLanding) })
+ w.report(moves.OfflineRoleSource, "ie-box", moves.OfflinePhaseParked, nil)
+ w.ok("/finish", "", provision.OfflineFinishing)
+ if _, ok := w.lock(); !ok {
+ t.Fatal("the lock was released before the move ended")
+ }
+ w.report(moves.OfflineRoleSealer, "ie-box", moves.OfflinePhaseLifted, nil)
+ w.report(moves.OfflineRoleSource, "ie-box", moves.OfflinePhaseGone, nil)
+ w.ok("/finish", "", "none")
+ if _, ok := w.lock(); ok {
+ t.Fatal("finish left the lock")
+ }
+
+ // An abort ends at finish too.
+ b := newOfflineWorld(t, oneEach...)
+ b.start()
+ b.ok("/abort", "", provision.OfflineReverting)
+ if _, ok := b.lock(); !ok {
+ t.Fatal("abort released the lock before the finish")
+ }
+ b.report(moves.OfflineRoleSource, "ie-box", moves.OfflinePhaseReverted, nil)
+ b.ok("/finish", "", "none")
+ if _, ok := b.lock(); ok {
+ t.Fatal("the finish after an abort left the lock")
+ }
+ // Another start now takes it.
+ b.start()
+ if a, ok := b.lock(); !ok || a.Op != b.id {
+ t.Fatalf("lock = %+v %v", a, ok)
+ }
+}
+
+func TestOfflineStaleLockIsReplaced(t *testing.T) {
+ put := func(w *offlineWorld, a moves.OfflineActive) {
+ body, _ := json.Marshal(a)
+ offlinePut(t, w.fx.rel, moves.OfflineActiveKey, body)
+ }
+ // A lock naming a host with no record.
+ w := newOfflineWorld(t, oneEach...)
+ put(w, moves.OfflineActive{Host: "gone.go.tito.io", Op: "mv_aaaaaaaaaaaaaaaaaaaaaaaaaa", Account: "bab", App: "titogo", At: time.Now().Add(-time.Hour)})
+ w.start()
+ if a, _ := w.lock(); a.Host != offlineHost || a.Op != w.id {
+ t.Fatalf("lock = %+v", a)
+ }
+ // A lock naming a host whose record carries no such move (a failed release).
+ x := newOfflineWorld(t, oneEach...)
+ if c, _, out := rawReq(t, http.MethodPost, instancesURL(x.fx, "bab", "titogo"), x.token, `{"host":"beta.go.tito.io","region":"ie"}`); c != http.StatusCreated {
+ t.Fatalf("create = %d %s", c, out)
+ }
+ put(x, moves.OfflineActive{Host: "beta.go.tito.io", Op: "mv_aaaaaaaaaaaaaaaaaaaaaaaaaa", Account: "bab", App: "titogo", At: time.Now().Add(-time.Hour)})
+ x.start()
+ // A lock whose record carries another move of that host is not this one's.
+ y := newOfflineWorld(t, oneEach...)
+ put(y, moves.OfflineActive{Host: offlineHost, Op: "mv_aaaaaaaaaaaaaaaaaaaaaaaaaa", Account: "bab", App: "titogo", At: time.Now().Add(-time.Hour)})
+ y.start()
+ // A young lock is believed even before its record carries the move: the
+ // start door takes the lock, then writes the record.
+ f := newOfflineWorld(t, oneEach...)
+ if c, _, out := rawReq(t, http.MethodPost, instancesURL(f.fx, "bab", "titogo"), f.token, `{"host":"beta.go.tito.io","region":"ie"}`); c != http.StatusCreated {
+ t.Fatalf("create = %d %s", c, out)
+ }
+ put(f, moves.OfflineActive{Host: "beta.go.tito.io", Op: "mv_aaaaaaaaaaaaaaaaaaaaaaaaaa", Account: "bab", App: "titogo", At: time.Now()})
+ f.refused(http.MethodPost, "", `{"region":"uk"}`, http.StatusConflict, "beta.go.tito.io is moving offline. move one instance at a time")
+ // An old lock is live while its record carries the move.
+ g := newOfflineWorld(t, oneEach...)
+ if _, code, out := g.startOther("beta.go.tito.io"); code != http.StatusAccepted {
+ t.Fatalf("beta start = %d %s", code, out)
+ }
+ a, _ := g.lock()
+ a.At = time.Now().Add(-24 * time.Hour)
+ put(g, a)
+ g.refused(http.MethodPost, "", `{"region":"uk"}`, http.StatusConflict, "beta.go.tito.io is moving offline. move one instance at a time")
+ // An unreadable lock is not guessed at.
+ z := newOfflineWorld(t, oneEach...)
+ offlinePut(t, z.fx.rel, moves.OfflineActiveKey, []byte("not json"))
+ z.refused(http.MethodPost, "", `{"region":"uk"}`, http.StatusBadGateway, "could not read the instance")
+}
+
+// A record nobody can read elsewhere does not block a start: the lock is the
+// gate, not a listing of every record.
+func TestOfflineStartIgnoresOtherRecords(t *testing.T) {
+ w := newOfflineWorld(t, oneEach...)
+ offlinePut(t, w.fx.rel, provision.RecordKey("bab", "titogo", "junk.go.tito.io"), []byte("{not a record"))
+ w.start()
}
diff --git a/internal/console/authcli.go b/internal/console/authcli.go
index 8733e88f6..efcdb15d7 100644
--- a/internal/console/authcli.go
+++ b/internal/console/authcli.go
@@ -718,7 +718,13 @@ func (s *Server) registerCLIRoutes() {
}
// The offline operator move's doors: always registered, operator-only,
// and refused with MV-O20 on a console with host claims or Phase 1 on.
- s.registerOfflineMoveRoutes()
+ s.cliRoute("GET /api/cli/apps/{account}/{app}/instances/{host}/move/offline", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceOfflineStatus)
+ s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/move/offline", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceOfflineStart)
+ s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/move/offline/seal", svcrole.Allow(svcrole.Instance), s.offlineVerbDoor(offlineVerbSeal))
+ s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/move/offline/flip", svcrole.Allow(svcrole.Instance), s.offlineVerbDoor(offlineVerbFlip))
+ s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/move/offline/open", svcrole.Allow(svcrole.Instance), s.offlineVerbDoor(offlineVerbOpen))
+ s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/move/offline/finish", svcrole.Allow(svcrole.Instance), s.offlineVerbDoor(offlineVerbFinish))
+ s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/move/offline/abort", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceOfflineAbort)
s.cliRoute("DELETE /api/cli/apps/{account}/{app}/instances/{host}", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceDelete)
// The import door (seeded-instances spec §5): the instance role, like
// the create door that begins it.
diff --git a/internal/console/svcroutes_test.go b/internal/console/svcroutes_test.go
index 1777ac98b..f46127714 100644
--- a/internal/console/svcroutes_test.go
+++ b/internal/console/svcroutes_test.go
@@ -529,11 +529,21 @@ var exemptFromLegacyPin = map[string]bool{
"POST /api/cli/apps/{account}/{app}/instances/{host}/move": true,
"POST /api/cli/apps/{account}/{app}/instances/{host}/move/cut": true,
"DELETE /api/cli/apps/{account}/{app}/instances/{host}/move": true,
- "GET /api/cli/apps/{account}/{app}/sidecars": true,
- "POST /api/cli/apps/{account}/{app}/sidecars": true,
- "GET /api/cli/apps/{account}/{app}/sidecars/{name}": true,
- "DELETE /api/cli/apps/{account}/{app}/sidecars/{name}": true,
- "POST /api/cli/apps/{account}/{app}/sidecars/{name}/wake": true,
+ // The offline operator move's doors: each is operator-only behind
+ // moveOperatorGate (a service token meets the family's 404), and
+ // refuses with MV-O20 on a console with host claims or Phase 1 on.
+ "GET /api/cli/apps/{account}/{app}/instances/{host}/move/offline": true,
+ "POST /api/cli/apps/{account}/{app}/instances/{host}/move/offline": true,
+ "POST /api/cli/apps/{account}/{app}/instances/{host}/move/offline/seal": true,
+ "POST /api/cli/apps/{account}/{app}/instances/{host}/move/offline/flip": true,
+ "POST /api/cli/apps/{account}/{app}/instances/{host}/move/offline/open": true,
+ "POST /api/cli/apps/{account}/{app}/instances/{host}/move/offline/finish": true,
+ "POST /api/cli/apps/{account}/{app}/instances/{host}/move/offline/abort": true,
+ "GET /api/cli/apps/{account}/{app}/sidecars": true,
+ "POST /api/cli/apps/{account}/{app}/sidecars": true,
+ "GET /api/cli/apps/{account}/{app}/sidecars/{name}": true,
+ "DELETE /api/cli/apps/{account}/{app}/sidecars/{name}": true,
+ "POST /api/cli/apps/{account}/{app}/sidecars/{name}/wake": true,
// The platform's GeoIP lookup (geoip spec §11.1) is new access on the
// analytics read's footing: it did not exist before service principals,
// and the app itself is the consumer (a batch job or a webhook handler
diff --git a/internal/moves/offline.go b/internal/moves/offline.go
index a51bdbbe1..b9da2fc97 100644
--- a/internal/moves/offline.go
+++ b/internal/moves/offline.go
@@ -1,6 +1,8 @@
package moves
import (
+ "crypto/sha256"
+ "encoding/hex"
"fmt"
"path/filepath"
"time"
@@ -323,6 +325,68 @@ func OfflineReportPrefix(host, op string) string {
return "control/offline-moves/" + host + "/" + op + "/"
}
+// OfflineSealKey is where the sealer writes the seal, once and create-only:
+// control/offline-moves/<host>/<op>/seal.json. Unlike a report it is never
+// rewritten. It is an OfflineReport (role sealer, phase sealed) holding what
+// the sealer decided and recorded at the seal: the sealed position
+// (FinalTXID, Staged), the files and env digests, the env pin, the release,
+// the timers digest, the replication rung and next due, TombstoneOwner
+// (moved:<op>), and Items, an OK Check for every manifest item with Source
+// "seal". At is when it was sealed; TombstoneUntil is zero (the live
+// expiry is the sealer's per-pass report). The record's source.evidence is
+// this key and the sha256 of its bytes (OfflineSealEvidence). "" for a host
+// that fails provision.ValidHostShape or an unusable op.
+func OfflineSealKey(host, op string) string {
+ if !provision.ValidHostShape(host) || !offlineSegment(op) {
+ return ""
+ }
+ return "control/offline-moves/" + host + "/" + op + "/seal.json"
+}
+
+// OfflineSealEvidence is source.evidence for a seal written as raw: the seal
+// key, "@", and the hex sha256 of the bytes. "" for a bad host or op.
+func OfflineSealEvidence(host, op string, raw []byte) string {
+ key := OfflineSealKey(host, op)
+ if key == "" {
+ return ""
+ }
+ sum := sha256.Sum256(raw)
+ return key + "@" + hex.EncodeToString(sum[:])
+}
+
+// ValidateOfflineSeal reports why a seal cannot be trusted as the seal of op
+// on host: it is a valid sealer report of that move, phase sealed, that names
+// the move's tombstone owner and carries an OK Check for every item the
+// source side reports.
+func ValidateOfflineSeal(r OfflineReport, host, op string) error {
+ if err := ValidateOfflineReport(r); err != nil {
+ return err
+ }
+ if r.Host != host || r.Op != op || r.Role != OfflineRoleSealer || r.Phase != OfflinePhaseSealed {
+ return fmt.Errorf("offline seal: not the sealed report of this move")
+ }
+ if r.TombstoneOwner != TombstoneOwner(op) {
+ return fmt.Errorf("offline seal: tombstone owner %q, want %q", r.TombstoneOwner, TombstoneOwner(op))
+ }
+ if ok, bad := r.SourceItemsOK(); !ok {
+ return fmt.Errorf("offline seal: items not OK: %v", bad)
+ }
+ return nil
+}
+
+// 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.
+const OfflineActiveKey = "control/offline-moves/active.json"
+
+// OfflineActive is the lock's body.
+type OfflineActive struct {
+ Host string `json:"host"`
+ Op string `json:"op"`
+ Account string `json:"account"`
+ App string `json:"app"`
+ At time.Time `json:"at"`
+}
+
// OfflineArchivePrefix is where the move's sealed files archive and its
// timers snapshot live in the target bucket: moves/@offline/<host>/<op>/. The
// "@" cannot start a host, so it never collides with a host's own staging
diff --git a/internal/moves/offline_test.go b/internal/moves/offline_test.go
index 635eb9e23..84a0e4050 100644
--- a/internal/moves/offline_test.go
+++ b/internal/moves/offline_test.go
@@ -441,3 +441,55 @@ func TestOfflineStateAndPhaseNamesAreDistinct(t *testing.T) {
t.Fatalf("kind %q", provision.MoveKindOffline)
}
}
+
+func TestOfflineSealKeyAndValidation(t *testing.T) {
+ const host, op = "acme.go.tito.io", "mv_aaaaaaaaaaaaaaaaaaaaaaaaaa"
+ if got := OfflineSealKey(host, op); got != "control/offline-moves/"+host+"/"+op+"/seal.json" {
+ t.Fatalf("seal key = %q", got)
+ }
+ for _, bad := range [][2]string{{"", op}, {"not a host", op}, {host, ""}, {host, "../x"}, {host, "a/b"}} {
+ if OfflineSealKey(bad[0], bad[1]) != "" || OfflineSealEvidence(bad[0], bad[1], []byte("x")) != "" {
+ t.Errorf("key for %q accepted", bad)
+ }
+ }
+ // The seal key is not a report key and sits under the report prefix.
+ if !strings.HasPrefix(OfflineSealKey(host, op), OfflineReportPrefix(host, op)) {
+ t.Error("the seal is outside the move's prefix")
+ }
+ items := map[string]Check{}
+ for _, it := range OfflineSourced() {
+ items[it.ID] = Check{OK: true}
+ }
+ good := OfflineReport{V: OfflineReportVersion, Op: op, Host: host, Box: "ie-box", Role: OfflineRoleSealer, Phase: OfflinePhaseSealed,
+ Items: items, TombstoneOwner: TombstoneOwner(op), At: time.Now()}
+ if err := ValidateOfflineSeal(good, host, op); err != nil {
+ t.Fatal(err)
+ }
+ for name, edit := range map[string]func(*OfflineReport){
+ "another host": func(r *OfflineReport) { r.Host = "other.go.tito.io" },
+ "another op": func(r *OfflineReport) { r.Op = "mv_bbbbbbbbbbbbbbbbbbbbbbbbbb" },
+ "a source role": func(r *OfflineReport) { r.Role = OfflineRoleSource },
+ "not sealed": func(r *OfflineReport) { r.Phase = OfflinePhaseStopped },
+ "no tombstone": func(r *OfflineReport) { r.TombstoneOwner = "" },
+ "an item not OK": func(r *OfflineReport) {
+ c := map[string]Check{}
+ for k, v := range items {
+ c[k] = v
+ }
+ c["files"] = Check{}
+ r.Items = c
+ },
+ "a missing item": func(r *OfflineReport) { r.Items = map[string]Check{} },
+ } {
+ r := good
+ edit(&r)
+ if ValidateOfflineSeal(r, host, op) == nil {
+ t.Errorf("%s: accepted", name)
+ }
+ }
+ // The evidence is the key and the hash of the bytes.
+ ev := OfflineSealEvidence(host, op, []byte("x"))
+ if !strings.HasPrefix(ev, OfflineSealKey(host, op)+"@") || len(ev) != len(OfflineSealKey(host, op))+1+64 {
+ t.Errorf("evidence = %q", ev)
+ }
+}