host: the source's finalization, fix round 1: the journal sweep, durable rename replay, env files, owners required
- A local sweep parks an op journal that reached the stamp step, whoever's record no longer routes here: a closed move proves the stamp landed (MoveClose refuses without source_parked_at). The stamp step is journalled pending before its CAS. - The rename helper syncs the source's parent and every destination directory on a replay that finds the rename done, as on a fresh one, and offers an interruption point between the rename and its syncs. - <host>.config.env and <host>-sidecar.env join the ownership-tracked retirement. - The finalization is armed only with the park engine's leases, view and authorizer; nothing stands in for a missing owner, and the tests inject theirs. The rename helper moves to movesretire.go.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5 files changed,
+651
−349
approved_moves_copy_test.go+1 −0internal/host/movesfinal.go+123 −334internal/host/movesfinal_test.go+185 −15internal/host/movespass.go+5 −0internal/host/movesretire.go+337 −0
CI passed — run details
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go| index 97371d3f9..bb7ec9b31 100644 |
| --- a/approved_moves_copy_test.go |
| +++ b/approved_moves_copy_test.go |
| @@ -586,6 +586,7 @@ var movesScanFiles = []string{ |
| "internal/host/movespolicy.go", |
| "internal/host/movesprobe.go", |
| "internal/host/movesreport.go", |
| + "internal/host/movesretire.go", |
| "internal/host/movesstage.go", |
| "internal/host/movesstore.go", |
| "internal/host/refusalwatch.go", |
diff --git a/internal/host/movesfinal.go b/internal/host/movesfinal.go| index 562786496..e02647890 100644 |
| --- a/internal/host/movesfinal.go |
| +++ b/internal/host/movesfinal.go |
| @@ -2,7 +2,6 @@ package host |
| import ( |
| "context" |
| - "encoding/json" |
| "errors" |
| "fmt" |
| "log" |
| @@ -77,7 +76,7 @@ const ( |
| finalStepLocal = "source-park-local" // parkLocalArtifacts' step: the retirement id |
| finalStepRowDeleted = "row-deleted" // "pending" before the delete, "done" after it |
| finalStepConfigAck = "config-ack-source" // the config ack is copied and deleted |
| - finalStepParked = "source-parked" // source_parked_at is stamped |
| + finalStepParked = "source-parked" // "pending" before the stamp's CAS, "done" after it |
| finalStepJournal = "source-park-journal" // where the journal is going, written just before it goes |
| ) |
| @@ -132,10 +131,6 @@ func moveJournalPresent(dataDir, host, op string) bool { |
| return err == nil && fi.IsDir() |
| } |
| -func journalComponent(s string) bool { |
| - return s != "" && s != "." && s != ".." && !strings.ContainsAny(s, "/\\\x00") |
| -} |
| - |
| // participantRound is a post-flip participant's step for a record the |
| // region rule did not admit (movesPass). Only the source's finalization of a |
| // committed move is built; the rest is Plan 4's. |
| @@ -147,10 +142,14 @@ func (w *movesWorker) participantRound(ctx context.Context, s release.Store, rec |
| } |
| // sourceFinalArmed reports whether this box can run the finalization: every |
| -// hook it calls is wired. In production ParkLeases is not (ruling G17). |
| +// hook it calls is wired, the park engine's leases and Plan 5's view and |
| +// authorizer included. Nothing stands in for a missing owner in production |
| +// (fix round 1): the tests inject their own. In production none of the |
| +// three is wired yet (ruling G17). |
| func sourceFinalArmed(cfg moveCfg) bool { |
| return cfg.Box != "" && cfg.DataDir != "" && cfg.Guard.DataDir != "" && cfg.Local.DataDir != "" && |
| - cfg.ParkLeases != nil && cfg.Versioned != nil && cfg.Route != nil && cfg.DeleteHeld != nil |
| + cfg.ParkLeases != nil && cfg.ParkView != nil && cfg.ParkAuthorize != nil && |
| + cfg.Versioned != nil && cfg.Route != nil && cfg.DeleteHeld != nil |
| } |
| // sourceLocationParkScope is the source region's location park the commit |
| @@ -249,8 +248,8 @@ func (f *sourceFinal) run(ctx context.Context) error { |
| // is only the journal's own park is left, and the claim may already |
| // have moved on to the close. |
| if f.rec.Move.SourceParkedAt != nil { |
| - if !j.Done(finalStepParked) { |
| - if err := f.mark(finalStepParked, ""); err != nil { |
| + if j.Steps[finalStepParked] != "done" { |
| + if err := f.mark(finalStepParked, "done"); err != nil { |
| return err |
| } |
| } |
| @@ -298,11 +297,23 @@ func (f *sourceFinal) run(ctx context.Context) error { |
| return err |
| } |
| } |
| - if !j.Done(finalStepParked) { |
| + if j.Steps[finalStepParked] != "done" { |
| + // "pending" is the stamp step reached: from here the journal is |
| + // the local sweep's to finish if the close overtakes this box |
| + // (sweepFinalJournals). |
| + if j.Steps[finalStepParked] != "pending" { |
| + j.Mark(finalStepParked, "pending") |
| + if err := j.Write(cfg.DataDir); err != nil { |
| + return err |
| + } |
| + if err := f.fault("after-source-parked-pending"); err != nil { |
| + return err |
| + } |
| + } |
| if err := f.stampParked(ctx); err != nil { |
| return err |
| } |
| - if err := f.mark(finalStepParked, ""); err != nil { |
| + if err := f.mark(finalStepParked, "done"); err != nil { |
| return err |
| } |
| } |
| @@ -311,7 +322,7 @@ func (f *sourceFinal) run(ctx context.Context) error { |
| // sourceOf is the claim this finalization runs under: this move's landing, |
| // committed, this box its source with its evidence journalled, and the |
| -// record's incarnation. The stand-in authorizers use the same rule. |
| +// record's incarnation. |
| func (f *sourceFinal) sourceOf(obs hostclaim.Observed) error { |
| cfg := f.cfg() |
| if obs.Absent || obs.Claim == nil { |
| @@ -333,59 +344,12 @@ func (f *sourceFinal) sourceOf(obs hostclaim.Observed) error { |
| return nil |
| } |
| -// authorizeChore is park.Local.Authorize's stand-in for the source until |
| -// Plan 5: this move's own chore, for this box or this region, under sourceOf. |
| -func (f *sourceFinal) authorizeChore(_ context.Context, c *hostclaim.Claim, o hostclaim.Obligation) error { |
| - if err := f.sourceOf(hostclaim.Observed{Host: f.rec.Host, Claim: c}); err != nil { |
| - return err |
| - } |
| - cfg := f.cfg() |
| - switch { |
| - case o.Op != f.g.ID: |
| - case o.Kind == "park-local" && o.Box == cfg.Box: |
| - return nil |
| - case o.Kind == "park-replica" && o.Region == cfg.Region: |
| - return nil |
| - } |
| - return fmt.Errorf("%w: not this source's chore", park.ErrUnsafe) |
| -} |
| - |
| -// authorizeObligation is park.Engine.Authorize's stand-in: the same rule |
| -// over an observation. |
| -func (f *sourceFinal) authorizeObligation(ctx context.Context, obs hostclaim.Observed, o hostclaim.Obligation) error { |
| - if obs.Absent { |
| - return park.ErrUnsafe |
| - } |
| - return f.authorizeChore(ctx, obs.Claim, o) |
| -} |
| - |
| -// view is park.Engine.View's stand-in for the source's location park: the |
| -// target's region is authoritative after the commit, so this region's whole |
| -// location is retired and every version under it is selected (D6, H24: in |
| -// Phase 1 the scope is the root, instances/<host>, with every incarnation's |
| -// residue under it). |
| -func (f *sourceFinal) view(_ context.Context, obs hostclaim.Observed) (park.View, error) { |
| - if obs.Absent || obs.Claim == nil || obs.Claim.Holder == nil { |
| - return park.View{}, park.ErrUnsafe |
| - } |
| - location := strings.TrimSuffix(obs.Claim.Holder.Location, "/") |
| - return park.View{ClaimETag: obs.ETag, Region: f.cfg().Region, AuthoritativeRegion: f.rec.Move.To, |
| - GenerationOf: func(r park.Ref) (string, bool) { return landingGenerationOf(location, r.Key) }}, nil |
| -} |
| - |
| -// engine is the park engine over this box's own bucket: the wired leases, |
| -// and the view and authorizer Plan 5 supplies, or the stand-ins. |
| +// engine is the park engine over this box's own bucket, with the wired |
| +// leases, view and authorizer (sourceFinalArmed requires all three). |
| func (f *sourceFinal) engine(vs lease.VersionedStore) park.Engine { |
| cfg := f.cfg() |
| - view, auth := cfg.ParkView, cfg.ParkAuthorize |
| - if view == nil { |
| - view = f.view |
| - } |
| - if auth == nil { |
| - auth = f.authorizeObligation |
| - } |
| return park.Engine{Store: vs, Claims: f.s, Bucket: cfg.OwnBucket, Region: cfg.Region, Leases: cfg.ParkLeases, |
| - Now: f.now, NewID: obligationID, View: view, Authorize: auth, After: cfg.parkFault} |
| + Now: f.now, NewID: obligationID, View: cfg.ParkView, Authorize: cfg.ParkAuthorize, After: cfg.parkFault} |
| } |
| // remote is step 1. done is true once the source region's location park has |
| @@ -483,7 +447,12 @@ func (f *sourceFinal) installation(ctx context.Context) (string, error) { |
| local.Box = cfg.Box |
| } |
| if local.Authorize == nil { |
| - local.Authorize = f.authorizeChore |
| + // park.Local asks the same owner the engine does, over the claim |
| + // it read under the guard. |
| + authorize := cfg.ParkAuthorize |
| + local.Authorize = func(ctx context.Context, c *hostclaim.Claim, o hostclaim.Obligation) error { |
| + return authorize(ctx, hostclaim.Observed{Host: host, Claim: c}, o) |
| + } |
| } |
| if err := local.RetireInstallation(ctx, host, hostguard.Captured{Incarnation: captured.Incarnation, Stamp: stamp}, chore.ID, id); err != nil { |
| return "", fmt.Errorf("retire the source's installation: %w", err) |
| @@ -506,7 +475,8 @@ func (f *sourceFinal) installation(ctx context.Context) (string, error) { |
| // quarantine and litestream shadow, the files directory and <host>.env. |
| func sourceArtifactPaths(host string) []string { |
| db := host + ".db" |
| - return []string{db, db + ".gen", db + "-wal", db + "-shm", db + ".corrupt", ".activator-" + db + "-litestream", host, host + ".env"} |
| + return []string{db, db + ".gen", db + "-wal", db + "-shm", db + ".corrupt", ".activator-" + db + "-litestream", host, host + ".env", |
| + host + ".config.env", host + "-sidecar.env"} |
| } |
| // deleteRow is step 3's row and timers delete, under the host's hold lock |
| @@ -625,311 +595,130 @@ func (f *sourceFinal) stampParked(ctx context.Context) error { |
| // parkJournal renames the op's journal directory into the local retirement, |
| // after source_parked_at is stamped: until then it is what brings a replay |
| // back here. A crash before the rename leaves the journal, and the next pass |
| -// renames it; after it nothing of the move is left under .moves/. |
| +// (or the local sweep) renames it; after it nothing of the move is left |
| +// under .moves/. |
| func (f *sourceFinal) parkJournal() error { |
| - cfg := f.cfg() |
| - host, id := f.rec.Host, f.g.ID |
| - retirement := f.j.Steps[finalStepLocal] |
| + return parkFinalJournal(f.cfg().DataDir, f.rec.Host, f.g.ID, f.j, f.mark) |
| +} |
| + |
| +// parkFinalJournal parks a finalized op's journal into the retirement its |
| +// own local park recorded, journalling the destination just before it goes. |
| +func parkFinalJournal(dataDir, host, op string, j *moves.Journal, mark func(step, value string) error) error { |
| + retirement := j.Steps[finalStepLocal] |
| if retirement == "" { |
| - return fmt.Errorf("move %s: the journal records no local retirement for its own park", id) |
| + return fmt.Errorf("move %s: the journal records no local retirement for its own park", op) |
| } |
| - plan, err := journalledLocalPlan(f.j, finalStepLocal) |
| + plan, err := journalledLocalPlan(j, finalStepLocal) |
| if err != nil { |
| return err |
| } |
| - dst := filepath.Join(parkedArtifactsDir(cfg.DataDir, plan.Incarnation, host, retirement), ".moves", host, id) |
| - if f.j.Steps[finalStepJournal] != dst { |
| - if err := f.mark(finalStepJournal, dst); err != nil { |
| + dst := filepath.Join(parkedArtifactsDir(dataDir, plan.Incarnation, host, retirement), ".moves", host, op) |
| + if j.Steps[finalStepJournal] != dst { |
| + if err := mark(finalStepJournal, dst); err != nil { |
| return err |
| } |
| } |
| - if err := parkOpJournal(cfg.DataDir, plan.Incarnation, host, id, retirement); err != nil { |
| + if err := parkOpJournal(dataDir, plan.Incarnation, host, op, retirement); err != nil { |
| return fmt.Errorf("park the op journal: %w", err) |
| } |
| - log.Printf("moves: %s: the source's finalization of %s is complete", host, id) |
| + log.Printf("moves: %s: the source's finalization of %s is complete", host, op) |
| return nil |
| } |
| -// ---- the moves-owned local rename (ruling H5(b)) ---- |
| - |
| -// localArtifactPark is one moves-owned local retirement (ruling H5(b), the |
| -// pattern of Plan 2's extraction staging): artifacts the claim's inventory |
| -// does not list, so park.Local cannot retire them, renamed into the park |
| -// engine's local layout, .parked/<incarnation>/<host>/<retirement>/artifacts/, |
| -// keeping their paths. Plan 4's rollback reuses it. |
| -type localArtifactPark struct { |
| - DataDir, Incarnation, Host, Op string |
| - // Paths are the candidates, relative to DataDir. Absent ones are recorded |
| - // as absent and never touched. |
| - Paths []string |
| - // Step is the journal step that records the retirement id once every |
| - // rename is durable; Step+"-plan" records the plan before the first one. |
| - Step string |
| - NewID func() string |
| - // Fault is the test seam, after the plan and after each rename. |
| - Fault func(point string) error |
| -} |
| - |
| -// localParkPlan is what is journalled before the first rename: the |
| -// retirement id and every candidate's identity ("dev:ino"; "" when absent). |
| -type localParkPlan struct { |
| - Retirement string `json:"retirement"` |
| - Incarnation string `json:"incarnation"` |
| - Items []localParkItem `json:"items"` |
| -} |
| - |
| -type localParkItem struct { |
| - Path string `json:"path"` |
| - ID string `json:"id,omitempty"` |
| -} |
| - |
| -// localParkReceipt sits beside artifacts/ in the retirement, so a later |
| -// reader can find a moves-owned retirement by its op (D6, I57). |
| -type localParkReceipt struct { |
| - V int `json:"v"` |
| - Op string `json:"op"` |
| - Host string `json:"host"` |
| - Incarnation string `json:"incarnation"` |
| - Retirement string `json:"retirement"` |
| - Paths []string `json:"paths"` |
| -} |
| - |
| -const localParkReceiptName = "moves-retirement.json" |
| - |
| -// parkedEventDir and parkedArtifactsDir are the engine's local layout |
| -// (park.Local's parkLocal): one directory per retirement event. |
| -func parkedEventDir(dataDir, incarnation, host, retirement string) string { |
| - return filepath.Join(dataDir, ".parked", incarnation, host, retirement) |
| -} |
| - |
| -func parkedArtifactsDir(dataDir, incarnation, host, retirement string) string { |
| - return filepath.Join(parkedEventDir(dataDir, incarnation, host, retirement), "artifacts") |
| -} |
| - |
| -func journalledLocalPlan(j *moves.Journal, step string) (localParkPlan, error) { |
| - var plan localParkPlan |
| - raw, ok := j.Steps[step+"-plan"] |
| - if !ok { |
| - return plan, fmt.Errorf("the journal has no %s plan", step) |
| - } |
| - if err := json.Unmarshal([]byte(raw), &plan); err != nil { |
| - return plan, fmt.Errorf("the journal's %s plan: %w", step, err) |
| - } |
| - if !journalComponent(plan.Retirement) || !journalComponent(plan.Incarnation) { |
| - return plan, fmt.Errorf("the journal's %s plan names an unusable retirement", step) |
| - } |
| - return plan, nil |
| -} |
| - |
| -// artifactPathOK is a relative path under the data directory that is not |
| -// the guard's or the park's own. |
| -func artifactPathOK(p string) bool { |
| - if p == "" || filepath.IsAbs(p) || filepath.Clean(p) != p || strings.ContainsAny(p, "\\\x00") { |
| - return false |
| - } |
| - for _, c := range strings.Split(p, "/") { |
| - if !journalComponent(c) { |
| - return false |
| - } |
| - } |
| - first, _, _ := strings.Cut(p, "/") |
| - return first != ".parked" && first != "hostguard" |
| -} |
| - |
| -// parkLocalArtifacts runs one moves-owned local retirement and returns its |
| -// retirement id. In order: the plan (a fresh retirement id and each |
| -// candidate's identity) is journalled before anything moves; the retirement's |
| -// directories and its receipt are made; each recorded artifact is renamed to |
| -// the same path under artifacts/ and both parents synced; then Step records |
| -// the retirement id. A replay resumes from the journal: |
| -// - an artifact already at its destination with its identity is done; |
| -// - one still at its live path with its identity is renamed; |
| -// - one recorded absent, or whose live path now holds something with |
| -// another identity, is not this park's: it is left where it is, never |
| -// moved or removed; |
| -// - a destination that holds anything else is an error, and nothing more |
| -// is moved. |
| -func parkLocalArtifacts(ctx context.Context, j *moves.Journal, mark func(step, value string) error, p localArtifactPark) (string, error) { |
| - if r, ok := j.Steps[p.Step]; ok { |
| - return r, nil |
| - } |
| - if p.DataDir == "" || !journalComponent(p.Incarnation) || !journalComponent(p.Host) || p.Op == "" || p.Step == "" || p.NewID == nil { |
| - return "", errors.New("a local park names its data directory, incarnation, host, op and step") |
| - } |
| - for _, path := range p.Paths { |
| - if !artifactPathOK(path) { |
| - return "", fmt.Errorf("a local park cannot retire %q", path) |
| - } |
| - } |
| - fault := func(point string) error { |
| - if p.Fault != nil { |
| - return p.Fault(point) |
| - } |
| - return nil |
| - } |
| - plan, err := journalledLocalPlan(j, p.Step) |
| +// sweepFinalJournals is the local sweep of this box's op journals (fix round |
| +// 1, ruling 1), independent of any active move: a crash after the stamp and |
| +// before the journal's park, then the close, leaves a journal no record |
| +// routes back here (movesParticipantRecord answers nothing for a record with |
| +// no move). Each journal under <DataDir>/.moves/<host>/<op>/ that has reached |
| +// the stamp step is finished here: |
| +// - the stamp journalled "done": the journal is parked; |
| +// - "pending", and the listed record still carries this op: with |
| +// source_parked_at set it is journalled done and parked; without, it is |
| +// left to the finalization's own pass; |
| +// - "pending", and the listed record no longer carries this op (closed, or |
| +// another move since): the stamp landed, because the only transition |
| +// that clears a committed move is MoveClose, which refuses unless |
| +// source_parked_at is set (the aborts end at cutting/requested); so the |
| +// stamp is journalled done and the journal parked; |
| +// - a host the listing does not name (its record deleted, or unreadable) |
| +// is left as it is. |
| +// |
| +// A journal that has not reached the stamp step (no source-parked step: a |
| +// landing's, a cut's, a finalization still under way) is never touched, and |
| +// nothing is ever deleted: a journal is only renamed into its retirement. |
| +func (w *movesWorker) sweepFinalJournals(recs []provision.Record) { |
| + dd := w.cfg.DataDir |
| + if dd == "" { |
| + return |
| + } |
| + hosts, err := os.ReadDir(filepath.Join(dd, ".moves")) |
| if err != nil { |
| - if _, planned := j.Steps[p.Step+"-plan"]; planned { |
| - return "", err |
| - } |
| - if plan, err = p.plan(); err != nil { |
| - return "", err |
| - } |
| - body, err := json.Marshal(plan) |
| - if err != nil { |
| - return "", err |
| - } |
| - if err := mark(p.Step+"-plan", string(body)); err != nil { |
| - return "", err |
| + if !errors.Is(err, os.ErrNotExist) { |
| + w.notice("#final-sweep", "moves: the op journal sweep cannot list %s: %v", filepath.Join(dd, ".moves"), err) |
| } |
| + return |
| } |
| - if plan.Incarnation != p.Incarnation || len(plan.Items) != len(p.Paths) { |
| - return "", fmt.Errorf("the journal's %s plan is not this park's", p.Step) |
| + byHost := map[string][]provision.Record{} |
| + for _, rec := range recs { |
| + byHost[rec.Host] = append(byHost[rec.Host], rec) |
| } |
| - for i, it := range plan.Items { |
| - if it.Path != p.Paths[i] { |
| - return "", fmt.Errorf("the journal's %s plan is not this park's", p.Step) |
| - } |
| - } |
| - dest := parkedArtifactsDir(p.DataDir, p.Incarnation, p.Host, plan.Retirement) |
| - if err := mkdirSynced(p.DataDir, dest); err != nil { |
| - return "", err |
| - } |
| - receipt, err := json.Marshal(localParkReceipt{V: 1, Op: p.Op, Host: p.Host, Incarnation: p.Incarnation, Retirement: plan.Retirement, Paths: p.Paths}) |
| - if err != nil { |
| - return "", err |
| - } |
| - event := parkedEventDir(p.DataDir, p.Incarnation, p.Host, plan.Retirement) |
| - if err := writeFileAtomic(filepath.Join(event, localParkReceiptName), receipt, 0o640); err != nil { |
| - return "", err |
| - } |
| - for _, it := range plan.Items { |
| - if err := ctx.Err(); err != nil { |
| - return "", err |
| - } |
| - src, dst := filepath.Join(p.DataDir, it.Path), filepath.Join(dest, it.Path) |
| - if it.ID == "" { |
| - if _, err := os.Lstat(src); err == nil { |
| - log.Printf("moves: %s: %s appeared after the park recorded it absent; it is not this park's and is left where it is", p.Host, it.Path) |
| - } |
| - continue |
| - } |
| - if done, err := sameIdentity(dst, it.ID); err != nil { |
| - return "", err |
| - } else if done { |
| + for _, hd := range hosts { |
| + host := hd.Name() |
| + if !hd.IsDir() || !journalComponent(host) { |
| continue |
| } |
| - if _, err := os.Lstat(dst); err == nil { |
| - return "", fmt.Errorf("%s holds what this park did not put there", dst) |
| - } else if !errors.Is(err, os.ErrNotExist) { |
| - return "", err |
| - } |
| - ours, err := sameIdentity(src, it.ID) |
| + ops, err := os.ReadDir(filepath.Join(dd, ".moves", host)) |
| if err != nil { |
| - return "", err |
| - } |
| - if !ours { |
| - log.Printf("moves: %s: %s is no longer the artifact the park recorded; what is there is left where it is", p.Host, it.Path) |
| + w.notice(host+"#final-sweep", "moves: %s: the op journal sweep cannot list its journals: %v", host, err) |
| continue |
| } |
| - if err := mkdirSynced(p.DataDir, filepath.Dir(dst)); err != nil { |
| - return "", err |
| - } |
| - if err := os.Rename(src, dst); err != nil { |
| - return "", fmt.Errorf("park %s: %w", it.Path, err) |
| - } |
| - if err := syncPath(filepath.Dir(src)); err != nil { |
| - return "", err |
| - } |
| - if err := syncPath(filepath.Dir(dst)); err != nil { |
| - return "", err |
| - } |
| - if err := fault("after-rename:" + it.Path); err != nil { |
| - return "", err |
| + for _, od := range ops { |
| + op := od.Name() |
| + if !od.IsDir() || !journalComponent(op) { |
| + continue |
| + } |
| + if err := w.sweepFinalJournal(dd, host, op, byHost[host]); err != nil { |
| + w.logTrouble(host, fmt.Errorf("the op journal sweep, %s: %w", op, err)) |
| + } |
| } |
| } |
| - if err := syncPath(dest); err != nil { |
| - return "", err |
| - } |
| - if err := mark(p.Step, plan.Retirement); err != nil { |
| - return "", err |
| - } |
| - log.Printf("moves: %s: the local artifacts of %s are parked in %s", p.Host, p.Op, event) |
| - return plan.Retirement, nil |
| } |
| -// plan records a fresh retirement id and each candidate's identity. The |
| -// retirement's directory must not exist yet: the id is this park's alone. |
| -func (p localArtifactPark) plan() (localParkPlan, error) { |
| - plan := localParkPlan{Retirement: p.NewID(), Incarnation: p.Incarnation} |
| - if !journalComponent(plan.Retirement) { |
| - return plan, errors.New("no retirement id") |
| +// sweepFinalJournal is one journal of sweepFinalJournals. |
| +func (w *movesWorker) sweepFinalJournal(dataDir, host, op string, recs []provision.Record) error { |
| + j, err := moves.ReadJournal(dataDir, host, op) |
| + if err != nil { |
| + return err |
| } |
| - if _, err := os.Lstat(parkedEventDir(p.DataDir, p.Incarnation, p.Host, plan.Retirement)); err == nil { |
| - return plan, fmt.Errorf("retirement %s already exists", plan.Retirement) |
| - } else if !errors.Is(err, os.ErrNotExist) { |
| - return plan, err |
| + stamp, reached := j.Steps[finalStepParked] |
| + if !reached { |
| + return nil |
| } |
| - for _, path := range p.Paths { |
| - id, err := pathIdentity(filepath.Join(p.DataDir, path)) |
| - if errors.Is(err, os.ErrNotExist) { |
| - id, err = "", nil |
| + if stamp != "done" { |
| + if len(recs) == 0 { |
| + w.notice(host+"#final-sweep-"+op, "moves: %s: the journal of %s reached its stamp, and no listed record names the host; it is left", host, op) |
| + return nil |
| } |
| - if err != nil { |
| - return plan, err |
| + for _, rec := range recs { |
| + if m := rec.Move; m != nil && m.ID == op && m.SourceParkedAt == nil { |
| + return nil // the finalization's own pass stamps it |
| + } |
| } |
| - plan.Items = append(plan.Items, localParkItem{Path: path, ID: id}) |
| } |
| - return plan, nil |
| -} |
| - |
| -// mkdirSynced makes dir (under root) and syncs the parent of every directory |
| -// it made, so a rename into it survives a crash. |
| -func mkdirSynced(root, dir string) error { |
| - var made []string |
| - for d := dir; d != root && d != filepath.Dir(d); d = filepath.Dir(d) { |
| - if _, err := os.Lstat(d); err == nil { |
| - break |
| - } else if !errors.Is(err, os.ErrNotExist) { |
| + mark := func(step, value string) error { |
| + j.Mark(step, value) |
| + if err := j.Write(dataDir); err != nil { |
| return err |
| } |
| - made = append(made, d) |
| - } |
| - if err := os.MkdirAll(dir, 0o770); err != nil { |
| - return err |
| - } |
| - for _, d := range made { |
| - if err := syncPath(filepath.Dir(d)); err != nil { |
| - return err |
| + if fn := w.cfg.finalFault; fn != nil { |
| + return fn("after-" + step) |
| } |
| - } |
| - return nil |
| -} |
| - |
| -// parkOpJournal renames the op's journal directory into the retirement's |
| -// artifacts, at .moves/<host>/<op>. The directory is the op's own (its name |
| -// is the op id). Absent is done; a destination already there is refused. |
| -func parkOpJournal(dataDir, incarnation, host, op, retirement string) error { |
| - src := moves.JournalDir(dataDir, host, op) |
| - dst := filepath.Join(parkedArtifactsDir(dataDir, incarnation, host, retirement), ".moves", host, op) |
| - if _, err := os.Lstat(src); errors.Is(err, os.ErrNotExist) { |
| return nil |
| - } else if err != nil { |
| - return err |
| } |
| - if _, err := os.Lstat(dst); err == nil { |
| - return fmt.Errorf("%s already holds a journal", dst) |
| - } else if !errors.Is(err, os.ErrNotExist) { |
| - return err |
| - } |
| - if err := mkdirSynced(dataDir, filepath.Dir(dst)); err != nil { |
| - return err |
| - } |
| - if err := os.Rename(src, dst); err != nil { |
| - return err |
| - } |
| - if err := syncPath(filepath.Dir(src)); err != nil { |
| - return err |
| + if stamp != "done" { |
| + if err := mark(finalStepParked, "done"); err != nil { |
| + return err |
| + } |
| } |
| - return syncPath(filepath.Dir(dst)) |
| + return parkFinalJournal(dataDir, host, op, j, mark) |
| } |
diff --git a/internal/host/movesfinal_test.go b/internal/host/movesfinal_test.go| index 168ffc9a2..236dc2510 100644 |
| --- a/internal/host/movesfinal_test.go |
| +++ b/internal/host/movesfinal_test.go |
| @@ -69,7 +69,8 @@ func newFinalHarness(t *testing.T) *finalHarness { |
| } |
| } |
| dd := h.cfg.DataDir |
| - for name, body := range map[string]string{p1Host + ".db.gen": "cur", p1Host + ".db.corrupt": "quarantined"} { |
| + for name, body := range map[string]string{p1Host + ".db.gen": "cur", p1Host + ".db.corrupt": "quarantined", |
| + p1Host + ".config.env": "SECRET=1\n", p1Host + "-sidecar.env": "SIDE=1\n"} { |
| if err := os.WriteFile(filepath.Join(dd, name), []byte(body), 0o600); err != nil { |
| t.Fatal(err) |
| } |
| @@ -92,6 +93,7 @@ func newFinalHarness(t *testing.T) *finalHarness { |
| h.cfg.Guard = hostguard.Guard{DataDir: dd, LockDir: t.TempDir()} |
| h.cfg.Local = park.Local{DataDir: dd, Box: "ie-box"} |
| h.cfg.ParkLeases = h.leases |
| + h.cfg.ParkView, h.cfg.ParkAuthorize = sourceTestView, sourceTestAuthorize |
| h.cfg.parkFault = h.lcfg.parkFault |
| h.cfg.finalFault = func(point string) error { |
| h.finalHits = append(h.finalHits, point) |
| @@ -189,7 +191,7 @@ func (h *finalHarness) sourceJournal(t *testing.T) *moves.Journal { |
| func (h *finalHarness) live(t *testing.T) []string { |
| t.Helper() |
| var out []string |
| - for _, p := range append(sourceArtifactPaths(p1Host), filepath.Join(".moves", p1Host, p1ID)) { |
| + for _, p := range append(testSourcePaths(), filepath.Join(".moves", p1Host, p1ID)) { |
| if _, err := os.Lstat(filepath.Join(h.cfg.DataDir, p)); err == nil { |
| out = append(out, p) |
| } else if !errors.Is(err, os.ErrNotExist) { |
| @@ -219,7 +221,7 @@ func (h *finalHarness) parked(t *testing.T) []string { |
| // untouched asserts that nothing of the source's was finalized. |
| func (h *finalHarness) untouched(t *testing.T, why string) { |
| t.Helper() |
| - if got, want := h.live(t), []string{p1Host + ".db", p1Host + ".db.gen", p1Host + ".db.corrupt", p1Host, p1Host + ".env", filepath.Join(".moves", p1Host, p1ID)}; strings.Join(got, " ") != strings.Join(want, " ") { |
| + if got, want := h.live(t), []string{p1Host + ".db", p1Host + ".db.gen", p1Host + ".db.corrupt", p1Host, p1Host + ".env", p1Host + ".config.env", p1Host + "-sidecar.env", filepath.Join(".moves", p1Host, p1ID)}; strings.Join(got, " ") != strings.Join(want, " ") { |
| t.Fatalf("%s: live artifacts %v, want %v", why, got, want) |
| } |
| if got := h.parked(t); len(got) != 0 { |
| @@ -272,7 +274,8 @@ func (h *finalHarness) finalized(t *testing.T) string { |
| rest = append(rest, tail) |
| } |
| jpath := "artifacts/.moves/" + p1Host + "/" + p1ID + "/journal.json" |
| - want := []string{"artifacts/" + p1Host + "/upload.txt", "artifacts/" + p1Host + ".db", "artifacts/" + p1Host + ".db.corrupt", "artifacts/" + p1Host + ".db.gen", "artifacts/" + p1Host + ".env", localParkReceiptName} |
| + want := []string{"artifacts/" + p1Host + "/upload.txt", "artifacts/" + p1Host + ".db", "artifacts/" + p1Host + ".db.corrupt", "artifacts/" + p1Host + ".db.gen", "artifacts/" + p1Host + ".env", |
| + "artifacts/" + p1Host + ".config.env", "artifacts/" + p1Host + "-sidecar.env", localParkReceiptName} |
| var got []string |
| sawJournal := false |
| for _, r := range rest { |
| @@ -382,7 +385,7 @@ func TestSourceParkWaitsForCommit(t *testing.T) { |
| t.Fatal(err) |
| } |
| h.sourcePass(t) |
| - if got := h.live(t); len(got) != 6 || len(h.parked(t)) != 0 || h.read(t, p1Host).Move.SourceParkedAt != nil { |
| + if got := h.live(t); len(got) != 8 || len(h.parked(t)) != 0 || h.read(t, p1Host).Move.SourceParkedAt != nil { |
| t.Fatalf("finalized without the row: live %v parked %v", got, h.parked(t)) |
| } |
| }) |
| @@ -447,7 +450,7 @@ func TestSourceParkWaitsForTheRemoteHalf(t *testing.T) { |
| stuck := true |
| h.vs.skipDelete = func(key string) bool { return stuck && strings.HasPrefix(key, cutRoot+"/") } |
| h.sourcePass(t) |
| - if got := h.live(t); len(got) != 6 { |
| + if got := h.live(t); len(got) != 8 { |
| t.Fatalf("the local half ran before the remote: live %v", got) |
| } |
| if j := h.sourceJournal(t); j.Done(finalStepRemote) || j.Done(finalStepLocal+"-plan") { |
| @@ -513,10 +516,12 @@ func TestSourceParkReplayIsIdempotent(t *testing.T) { |
| t.Fatalf("deleted %d times", h.deletes) |
| } |
| want := []string{"after-" + finalStepRemote, "after-" + finalStepInstallation, "after-" + finalStepLocal + "-plan", |
| - "after-rename:" + p1Host + ".db", "after-rename:" + p1Host + ".db.gen", "after-rename:" + p1Host + ".db.corrupt", |
| - "after-rename:" + p1Host, "after-rename:" + p1Host + ".env", "after-" + finalStepLocal, |
| + "after-" + finalStepLocal, |
| "after-row-pending", "after-row-delete", "after-" + finalStepRowDeleted, "after-config-ack-copy", "after-" + finalStepConfigAck, |
| - "after-source-parked-record", "after-" + finalStepParked, "after-" + finalStepJournal} |
| + "after-source-parked-pending", "after-source-parked-record", "after-" + finalStepParked, "after-" + finalStepJournal} |
| + for _, p := range []string{p1Host + ".db", p1Host + ".db.gen", p1Host + ".db.corrupt", p1Host, p1Host + ".env", p1Host + ".config.env", p1Host + "-sidecar.env"} { |
| + want = append(want, "after-rename-unsynced:"+p, "after-rename:"+p) |
| + } |
| for _, p := range want { |
| if !h.crashAll[p] { |
| t.Errorf("never crashed at %s (crashed at %v)", p, h.crashAll) |
| @@ -745,12 +750,20 @@ func TestMovesParticipantRecord(t *testing.T) { |
| func TestSourceParkWaitsUnarmedInProduction(t *testing.T) { |
| h := newFinalHarness(t) |
| h.commit(t) |
| - h.cfg.ParkLeases = nil |
| - if sourceFinalArmed(h.cfg) { |
| - t.Fatal("armed without park leases") |
| + for name, unarm := range map[string]func(*moveCfg){ |
| + "no park leases": func(c *moveCfg) { c.ParkLeases = nil }, |
| + "no park view": func(c *moveCfg) { c.ParkView = nil }, |
| + "no park authorizer": func(c *moveCfg) { c.ParkAuthorize = nil }, |
| + } { |
| + saved := h.cfg |
| + unarm(&h.cfg) |
| + if sourceFinalArmed(h.cfg) { |
| + t.Fatalf("%s: armed", name) |
| + } |
| + h.sourcePass(t) |
| + h.untouched(t, name) |
| + h.cfg = saved |
| } |
| - h.sourcePass(t) |
| - h.untouched(t, "unarmed") |
| var prod moveCfg |
| reg, err := registry.Open(filepath.Join(t.TempDir(), "r.db")) |
| if err != nil { |
| @@ -763,7 +776,7 @@ func TestSourceParkWaitsUnarmedInProduction(t *testing.T) { |
| t.Fatalf("production wiring: %+v", prod) |
| } |
| prod.Route = func(string) (*registry.Route, error) { return nil, nil } |
| - if prod.ParkLeases != nil || sourceFinalArmed(prod) { |
| + if prod.ParkLeases != nil || prod.ParkView != nil || prod.ParkAuthorize != nil || sourceFinalArmed(prod) { |
| t.Fatal("production armed the finalization") |
| } |
| } |
| @@ -826,3 +839,160 @@ func TestParkLocalArtifactsRefusals(t *testing.T) { |
| t.Fatal("a plan for other paths was accepted") |
| } |
| } |
| + |
| +// testSourcePaths is what the tests expect the source to keep at its flat |
| +// live paths and park: the database with its marker, journal, quarantine |
| +// and litestream shadow, the files directory, <host>.env, and the two |
| +// rendered env files (fix round 1, ruling 3). |
| +func testSourcePaths() []string { |
| + db := p1Host + ".db" |
| + return []string{db, db + ".gen", db + "-wal", db + "-shm", db + ".corrupt", ".activator-" + db + "-litestream", p1Host, p1Host + ".env", |
| + p1Host + ".config.env", p1Host + "-sidecar.env"} |
| +} |
| + |
| +// sourceTestView and sourceTestAuthorize are the park view and authorizer |
| +// the tests inject for the source's location park, in place of Plan 5's |
| +// owners (fix round 1, ruling 4: production never substitutes stand-ins). |
| +// The target's region is authoritative after the commit, so every version |
| +// under the location is selected; a chore is authorized only when it is |
| +// this move's, for ie-box or ie, under the move's committed landing with |
| +// ie-box its source. |
| +func sourceTestView(_ context.Context, obs hostclaim.Observed) (park.View, error) { |
| + if obs.Absent || obs.Claim == nil || obs.Claim.Holder == nil { |
| + return park.View{}, park.ErrUnsafe |
| + } |
| + location := strings.TrimSuffix(obs.Claim.Holder.Location, "/") |
| + return park.View{ClaimETag: obs.ETag, Region: "ie", AuthoritativeRegion: "uk", |
| + GenerationOf: func(r park.Ref) (string, bool) { return landingGenerationOf(location, r.Key) }}, nil |
| +} |
| + |
| +func sourceTestAuthorize(_ context.Context, obs hostclaim.Observed, o hostclaim.Obligation) error { |
| + c := obs.Claim |
| + if obs.Absent || c == nil || c.Op.ID != p1ID || c.Op.Kind != hostclaim.KindLanding || c.Op.Phase != hostclaim.PhaseCommitted || |
| + len(c.Op.Participants) != 2 || c.Op.Participants[0] != "ie-box" || o.Op != p1ID { |
| + return park.ErrUnsafe |
| + } |
| + if (o.Kind == "park-local" && o.Box == "ie-box") || (o.Kind == "park-replica" && o.Region == "ie") { |
| + return nil |
| + } |
| + return park.ErrUnsafe |
| +} |
| + |
| +// Fix round 1, ruling 1: a crash after source_parked_at is stamped and |
| +// before the journal is parked, then the close (which clears the move, so |
| +// no record routes here any more), then a fresh source pass: the local |
| +// sweep parks the journal, because the close proves the stamp landed. |
| +func TestSourceParkJournalSurvivesTheClose(t *testing.T) { |
| + h := newFinalHarness(t) |
| + h.commit(t) |
| + h.finalAt = "after-source-parked-record" |
| + h.crashSource(t) |
| + if j := h.sourceJournal(t); j.Done(finalStepParked) && j.Steps[finalStepParked] == "done" { |
| + t.Fatalf("fixture: the stamp is journalled done: %v", j.Steps) |
| + } |
| + ctx := context.Background() |
| + g := provision.MoveGuard{ID: p1ID, Incarnation: h.inc} |
| + for _, mut := range []func(*provision.Record) error{provision.MoveOpened(g, h.clock.Now()), provision.MoveClose(g, h.clock.Now())} { |
| + if _, err := provision.SetRecordMove(ctx, h.s, "bab", "app", p1Host, mut); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if rec := h.read(t, p1Host); rec.Move != nil || rec.LastMove == nil || rec.LastMove.ID != p1ID { |
| + t.Fatalf("fixture: not closed: %+v", rec) |
| + } |
| + if movesParticipantRecord(h.cfg, h.read(t, p1Host)) != roleNone { |
| + t.Fatal("fixture: a closed record still routes here") |
| + } |
| + h.freshWorker() |
| + h.sourcePass(t) |
| + if got := h.live(t); len(got) != 0 { |
| + t.Fatalf("still live after the close: %v", got) |
| + } |
| + j := h.parkedJournal(t) |
| + if j.Steps[finalStepParked] != "done" { |
| + t.Fatalf("the parked journal's stamp: %v", j.Steps) |
| + } |
| +} |
| + |
| +// The sweep never touches a journal that has not reached the stamp, nor one |
| +// whose stamp is pending while the record still carries the op unstamped: a |
| +// journal of another op (a landing's, say) under a record with no move, and |
| +// this op's journal stopped right after it journalled the stamp pending, |
| +// before the stamp's CAS. |
| +func TestSourceParkSweepLeavesUnstampedJournals(t *testing.T) { |
| + h := newFinalHarness(t) |
| + h.commit(t) |
| + // Another op's journal that would park cleanly if the sweep took it: a |
| + // complete local plan and retirement, everything but the stamp step. |
| + plan, err := json.Marshal(localParkPlan{Retirement: "r0", Incarnation: h.inc}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + otherSteps := map[string]string{finalStepLocal + "-plan": string(plan), finalStepLocal: "r0", finalStepRowDeleted: "done"} |
| + other := &moves.Journal{Host: p1Host, Op: p1ID2, Steps: otherSteps} |
| + if err := other.Write(h.cfg.DataDir); err != nil { |
| + t.Fatal(err) |
| + } |
| + h.finalAt = "after-source-parked-pending" |
| + h.crashSource(t) |
| + if j := h.sourceJournal(t); j.Steps[finalStepParked] != "pending" || h.read(t, p1Host).Move.SourceParkedAt != nil { |
| + t.Fatalf("fixture: %v", j.Steps) |
| + } |
| + h.cfg.ParkLeases = nil // unarmed: only the sweep runs |
| + h.freshWorker() |
| + h.sourcePass(t) |
| + for _, op := range []string{p1ID, p1ID2} { |
| + if !moveJournalPresent(h.cfg.DataDir, p1Host, op) { |
| + t.Fatalf("the sweep took %s's journal before its stamp", op) |
| + } |
| + } |
| + if j, err := moves.ReadJournal(h.cfg.DataDir, p1Host, p1ID2); err != nil || len(j.Steps) != len(otherSteps) { |
| + t.Fatalf("the sweep wrote into another op's journal: %v %v", j.Steps, err) |
| + } |
| +} |
| + |
| +// Fix round 1, ruling 2: a replay that finds an artifact already at its |
| +// destination (a stop after the rename, before either sync) syncs the |
| +// source's parent and every destination parent before it goes on; and the |
| +// rename offers an interruption point between the rename and the syncs. |
| +func TestParkLocalArtifactsSyncsOnReplay(t *testing.T) { |
| + dd := t.TempDir() |
| + if err := os.MkdirAll(filepath.Join(dd, "d", "e"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(filepath.Join(dd, "d", "e", "a"), []byte("a"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + j := &moves.Journal{Host: p1Host, Op: p1ID, Steps: map[string]string{}} |
| + mark := func(step, value string) error { j.Mark(step, value); return nil } |
| + var points []string |
| + p := localArtifactPark{DataDir: dd, Incarnation: "inc", Host: p1Host, Op: p1ID, Step: "s", Paths: []string{"d/e/a"}, NewID: func() string { return "r1" }, |
| + Fault: func(point string) error { |
| + points = append(points, point) |
| + if point == "after-rename-unsynced:d/e/a" { |
| + return errCrash |
| + } |
| + return nil |
| + }} |
| + if _, err := parkLocalArtifacts(context.Background(), j, mark, p); !errors.Is(err, errCrash) { |
| + t.Fatalf("no interruption between the rename and its syncs: %v (points %v)", err, points) |
| + } |
| + var synced []string |
| + saved := retireSync |
| + retireSync = func(path string) error { synced = append(synced, path); return saved(path) } |
| + t.Cleanup(func() { retireSync = saved }) |
| + p.Fault = nil |
| + if r, err := parkLocalArtifacts(context.Background(), j, mark, p); err != nil || r != "r1" { |
| + t.Fatalf("replay: %q %v", r, err) |
| + } |
| + art := parkedArtifactsDir(dd, "inc", p1Host, "r1") |
| + for _, want := range []string{filepath.Join(dd, "d", "e"), filepath.Join(art, "d", "e"), filepath.Join(art, "d"), art, filepath.Dir(art), filepath.Join(dd, ".parked")} { |
| + found := false |
| + for _, s := range synced { |
| + found = found || s == want |
| + } |
| + if !found { |
| + t.Errorf("the replay did not sync %s (synced %v)", want, synced) |
| + } |
| + } |
| +} |
diff --git a/internal/host/movespass.go b/internal/host/movespass.go| index dc823a37f..12f4ba48b 100644 |
| --- a/internal/host/movespass.go |
| +++ b/internal/host/movespass.go |
| @@ -704,6 +704,11 @@ func movesPass(ctx context.Context, s release.Store, w *movesWorker) error { |
| w.evictArchives(nil) // no move into this box: no verdict to keep |
| w.retireHeldRefusals(nil) |
| } |
| + if cfg.Enabled { |
| + // The source's op journals that reached the stamp, whoever's record |
| + // no longer routes here (movesfinal.go, fix round 1). |
| + w.sweepFinalJournals(recs) |
| + } |
| w.liftFences(recs) |
| return nil |
| } |
diff --git a/internal/host/movesretire.go b/internal/host/movesretire.go| new file mode 100644 |
| index 000000000..9dd91bf26 |
| --- /dev/null |
| +++ b/internal/host/movesretire.go |
| @@ -0,0 +1,337 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "log" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + |
| + "github.com/carlosframework/platform/internal/moves" |
| +) |
| + |
| +// The moves-owned local retirement (ruling H5(b), the pattern of Plan 2's |
| +// extraction staging): artifacts the claim's inventory does not list, so |
| +// park.Local cannot retire them, renamed into the park engine's local |
| +// layout under a journalled plan, and an op's own journal directory parked |
| +// after them. The source's finalization (movesfinal.go) uses it; Plan 4's |
| +// rollback reuses it. |
| + |
| +func journalComponent(s string) bool { |
| + return s != "" && s != "." && s != ".." && !strings.ContainsAny(s, "/\\\x00") |
| +} |
| + |
| +// localArtifactPark is one moves-owned local retirement (ruling H5(b), the |
| +// pattern of Plan 2's extraction staging): artifacts the claim's inventory |
| +// does not list, so park.Local cannot retire them, renamed into the park |
| +// engine's local layout, .parked/<incarnation>/<host>/<retirement>/artifacts/, |
| +// keeping their paths. Plan 4's rollback reuses it. |
| +type localArtifactPark struct { |
| + DataDir, Incarnation, Host, Op string |
| + // Paths are the candidates, relative to DataDir. Absent ones are recorded |
| + // as absent and never touched. |
| + Paths []string |
| + // Step is the journal step that records the retirement id once every |
| + // rename is durable; Step+"-plan" records the plan before the first one. |
| + Step string |
| + NewID func() string |
| + // Fault is the test seam, after the plan and after each rename. |
| + Fault func(point string) error |
| +} |
| + |
| +// localParkPlan is what is journalled before the first rename: the |
| +// retirement id and every candidate's identity ("dev:ino"; "" when absent). |
| +type localParkPlan struct { |
| + Retirement string `json:"retirement"` |
| + Incarnation string `json:"incarnation"` |
| + Items []localParkItem `json:"items"` |
| +} |
| + |
| +type localParkItem struct { |
| + Path string `json:"path"` |
| + ID string `json:"id,omitempty"` |
| +} |
| + |
| +// localParkReceipt sits beside artifacts/ in the retirement, so a later |
| +// reader can find a moves-owned retirement by its op (D6, I57). |
| +type localParkReceipt struct { |
| + V int `json:"v"` |
| + Op string `json:"op"` |
| + Host string `json:"host"` |
| + Incarnation string `json:"incarnation"` |
| + Retirement string `json:"retirement"` |
| + Paths []string `json:"paths"` |
| +} |
| + |
| +const localParkReceiptName = "moves-retirement.json" |
| + |
| +// retireSync is the fsync every moves-owned retirement makes durable with |
| +// (syncPath); a variable so a test can see which directories were synced. |
| +var retireSync = syncPath |
| + |
| +// parkedEventDir and parkedArtifactsDir are the engine's local layout |
| +// (park.Local's parkLocal): one directory per retirement event. |
| +func parkedEventDir(dataDir, incarnation, host, retirement string) string { |
| + return filepath.Join(dataDir, ".parked", incarnation, host, retirement) |
| +} |
| + |
| +func parkedArtifactsDir(dataDir, incarnation, host, retirement string) string { |
| + return filepath.Join(parkedEventDir(dataDir, incarnation, host, retirement), "artifacts") |
| +} |
| + |
| +func journalledLocalPlan(j *moves.Journal, step string) (localParkPlan, error) { |
| + var plan localParkPlan |
| + raw, ok := j.Steps[step+"-plan"] |
| + if !ok { |
| + return plan, fmt.Errorf("the journal has no %s plan", step) |
| + } |
| + if err := json.Unmarshal([]byte(raw), &plan); err != nil { |
| + return plan, fmt.Errorf("the journal's %s plan: %w", step, err) |
| + } |
| + if !journalComponent(plan.Retirement) || !journalComponent(plan.Incarnation) { |
| + return plan, fmt.Errorf("the journal's %s plan names an unusable retirement", step) |
| + } |
| + return plan, nil |
| +} |
| + |
| +// artifactPathOK is a relative path under the data directory that is not |
| +// the guard's or the park's own. |
| +func artifactPathOK(p string) bool { |
| + if p == "" || filepath.IsAbs(p) || filepath.Clean(p) != p || strings.ContainsAny(p, "\\\x00") { |
| + return false |
| + } |
| + for _, c := range strings.Split(p, "/") { |
| + if !journalComponent(c) { |
| + return false |
| + } |
| + } |
| + first, _, _ := strings.Cut(p, "/") |
| + return first != ".parked" && first != "hostguard" |
| +} |
| + |
| +// parkLocalArtifacts runs one moves-owned local retirement and returns its |
| +// retirement id. In order: the plan (a fresh retirement id and each |
| +// candidate's identity) is journalled before anything moves; the retirement's |
| +// directories and its receipt are made; each recorded artifact is renamed to |
| +// the same path under artifacts/ and its source's parent and every |
| +// destination directory up to the data directory synced; then Step records |
| +// the retirement id. A replay resumes from the journal: |
| +// - an artifact already at its destination with its identity is synced as |
| +// a fresh rename is (a stop between the rename and its syncs leaves the |
| +// rename undurable, fix round 1), and is then done; |
| +// - one still at its live path with its identity is renamed; |
| +// - one recorded absent, or whose live path now holds something with |
| +// another identity, is not this park's: it is left where it is, never |
| +// moved or removed; |
| +// - a destination that holds anything else is an error, and nothing more |
| +// is moved. |
| +func parkLocalArtifacts(ctx context.Context, j *moves.Journal, mark func(step, value string) error, p localArtifactPark) (string, error) { |
| + if r, ok := j.Steps[p.Step]; ok { |
| + return r, nil |
| + } |
| + if p.DataDir == "" || !journalComponent(p.Incarnation) || !journalComponent(p.Host) || p.Op == "" || p.Step == "" || p.NewID == nil { |
| + return "", errors.New("a local park names its data directory, incarnation, host, op and step") |
| + } |
| + for _, path := range p.Paths { |
| + if !artifactPathOK(path) { |
| + return "", fmt.Errorf("a local park cannot retire %q", path) |
| + } |
| + } |
| + fault := func(point string) error { |
| + if p.Fault != nil { |
| + return p.Fault(point) |
| + } |
| + return nil |
| + } |
| + plan, err := journalledLocalPlan(j, p.Step) |
| + if err != nil { |
| + if _, planned := j.Steps[p.Step+"-plan"]; planned { |
| + return "", err |
| + } |
| + if plan, err = p.plan(); err != nil { |
| + return "", err |
| + } |
| + body, err := json.Marshal(plan) |
| + if err != nil { |
| + return "", err |
| + } |
| + if err := mark(p.Step+"-plan", string(body)); err != nil { |
| + return "", err |
| + } |
| + } |
| + if plan.Incarnation != p.Incarnation || len(plan.Items) != len(p.Paths) { |
| + return "", fmt.Errorf("the journal's %s plan is not this park's", p.Step) |
| + } |
| + for i, it := range plan.Items { |
| + if it.Path != p.Paths[i] { |
| + return "", fmt.Errorf("the journal's %s plan is not this park's", p.Step) |
| + } |
| + } |
| + dest := parkedArtifactsDir(p.DataDir, p.Incarnation, p.Host, plan.Retirement) |
| + if err := mkdirSynced(p.DataDir, dest); err != nil { |
| + return "", err |
| + } |
| + receipt, err := json.Marshal(localParkReceipt{V: 1, Op: p.Op, Host: p.Host, Incarnation: p.Incarnation, Retirement: plan.Retirement, Paths: p.Paths}) |
| + if err != nil { |
| + return "", err |
| + } |
| + event := parkedEventDir(p.DataDir, p.Incarnation, p.Host, plan.Retirement) |
| + if err := writeFileAtomic(filepath.Join(event, localParkReceiptName), receipt, 0o640); err != nil { |
| + return "", err |
| + } |
| + for _, it := range plan.Items { |
| + if err := ctx.Err(); err != nil { |
| + return "", err |
| + } |
| + src, dst := filepath.Join(p.DataDir, it.Path), filepath.Join(dest, it.Path) |
| + if it.ID == "" { |
| + if _, err := os.Lstat(src); err == nil { |
| + log.Printf("moves: %s: %s appeared after the park recorded it absent; it is not this park's and is left where it is", p.Host, it.Path) |
| + } |
| + continue |
| + } |
| + if done, err := sameIdentity(dst, it.ID); err != nil { |
| + return "", err |
| + } else if done { |
| + if err := syncRename(p.DataDir, src, dst); err != nil { |
| + return "", err |
| + } |
| + if err := fault("after-rename:" + it.Path); err != nil { |
| + return "", err |
| + } |
| + continue |
| + } |
| + if _, err := os.Lstat(dst); err == nil { |
| + return "", fmt.Errorf("%s holds what this park did not put there", dst) |
| + } else if !errors.Is(err, os.ErrNotExist) { |
| + return "", err |
| + } |
| + ours, err := sameIdentity(src, it.ID) |
| + if err != nil { |
| + return "", err |
| + } |
| + if !ours { |
| + log.Printf("moves: %s: %s is no longer the artifact the park recorded; what is there is left where it is", p.Host, it.Path) |
| + continue |
| + } |
| + if err := mkdirSynced(p.DataDir, filepath.Dir(dst)); err != nil { |
| + return "", err |
| + } |
| + if err := os.Rename(src, dst); err != nil { |
| + return "", fmt.Errorf("park %s: %w", it.Path, err) |
| + } |
| + if err := fault("after-rename-unsynced:" + it.Path); err != nil { |
| + return "", err |
| + } |
| + if err := syncRename(p.DataDir, src, dst); err != nil { |
| + return "", err |
| + } |
| + if err := fault("after-rename:" + it.Path); err != nil { |
| + return "", err |
| + } |
| + } |
| + if err := retireSync(dest); err != nil { |
| + return "", err |
| + } |
| + if err := mark(p.Step, plan.Retirement); err != nil { |
| + return "", err |
| + } |
| + log.Printf("moves: %s: the local artifacts of %s are parked in %s", p.Host, p.Op, event) |
| + return plan.Retirement, nil |
| +} |
| + |
| +// plan records a fresh retirement id and each candidate's identity. The |
| +// retirement's directory must not exist yet: the id is this park's alone. |
| +func (p localArtifactPark) plan() (localParkPlan, error) { |
| + plan := localParkPlan{Retirement: p.NewID(), Incarnation: p.Incarnation} |
| + if !journalComponent(plan.Retirement) { |
| + return plan, errors.New("no retirement id") |
| + } |
| + if _, err := os.Lstat(parkedEventDir(p.DataDir, p.Incarnation, p.Host, plan.Retirement)); err == nil { |
| + return plan, fmt.Errorf("retirement %s already exists", plan.Retirement) |
| + } else if !errors.Is(err, os.ErrNotExist) { |
| + return plan, err |
| + } |
| + for _, path := range p.Paths { |
| + id, err := pathIdentity(filepath.Join(p.DataDir, path)) |
| + if errors.Is(err, os.ErrNotExist) { |
| + id, err = "", nil |
| + } |
| + if err != nil { |
| + return plan, err |
| + } |
| + plan.Items = append(plan.Items, localParkItem{Path: path, ID: id}) |
| + } |
| + return plan, nil |
| +} |
| + |
| +// mkdirSynced makes dir (under root) and syncs it and every directory above |
| +// it up to root. Every call syncs the whole chain, not only the directories |
| +// it made: a replay after a stop between MkdirAll and the syncs finds the |
| +// directories there and must still make their entries durable. |
| +func mkdirSynced(root, dir string) error { |
| + if err := os.MkdirAll(dir, 0o770); err != nil { |
| + return err |
| + } |
| + return syncChain(root, dir) |
| +} |
| + |
| +// syncChain syncs dir and every directory above it up to and including root. |
| +// dir must be root or under it. |
| +func syncChain(root, dir string) error { |
| + root, dir = filepath.Clean(root), filepath.Clean(dir) |
| + if rel, err := filepath.Rel(root, dir); err != nil || rel == ".." || strings.HasPrefix(rel, "../") { |
| + return fmt.Errorf("%s is not under %s", dir, root) |
| + } |
| + for d := dir; ; d = filepath.Dir(d) { |
| + if err := retireSync(d); err != nil { |
| + return err |
| + } |
| + if d == root || d == filepath.Dir(d) { |
| + return nil |
| + } |
| + } |
| +} |
| + |
| +// syncRename makes a rename from src to dst durable: the source's parent, |
| +// and every destination directory up to root. |
| +func syncRename(root, src, dst string) error { |
| + if err := retireSync(filepath.Dir(src)); err != nil { |
| + return err |
| + } |
| + return syncChain(root, filepath.Dir(dst)) |
| +} |
| + |
| +// parkOpJournal renames the op's journal directory into the retirement's |
| +// artifacts, at .moves/<host>/<op>. The directory is the op's own (its name |
| +// is the op id). Absent with the destination there is a replay after the |
| +// rename, which is synced again and done; absent with no destination is |
| +// nothing to park; a destination already there beside the journal is |
| +// refused. |
| +func parkOpJournal(dataDir, incarnation, host, op, retirement string) error { |
| + src := moves.JournalDir(dataDir, host, op) |
| + dst := filepath.Join(parkedArtifactsDir(dataDir, incarnation, host, retirement), ".moves", host, op) |
| + if _, err := os.Lstat(src); errors.Is(err, os.ErrNotExist) { |
| + if _, err := os.Lstat(dst); err == nil { |
| + return syncRename(dataDir, src, dst) |
| + } |
| + return nil |
| + } else if err != nil { |
| + return err |
| + } |
| + if _, err := os.Lstat(dst); err == nil { |
| + return fmt.Errorf("%s already holds a journal", dst) |
| + } else if !errors.Is(err, os.ErrNotExist) { |
| + return err |
| + } |
| + if err := mkdirSynced(dataDir, filepath.Dir(dst)); err != nil { |
| + return err |
| + } |
| + if err := os.Rename(src, dst); err != nil { |
| + return err |
| + } |
| + return syncRename(dataDir, src, dst) |
| +} |