| index 6b287b0b1..fa6e582e9 100644 |
| --- a/internal/host/movesrecoveryowners.go |
| +++ b/internal/host/movesrecoveryowners.go |
| @@ -3,7 +3,9 @@ package host |
| import ( |
| "context" |
| "encoding/json" |
| + "errors" |
| "fmt" |
| + "io/fs" |
| "path/filepath" |
| "reflect" |
| "strconv" |
| @@ -486,6 +488,10 @@ func (r movesRecoveryOwners) Materialize(ctx context.Context, o hostclaim.Observ |
| return recoveryInputsChanged("launch environment") |
| } |
| m.recordFence(o.Host, h.Session, work) |
| + // The tree the recovery restored, as placed, before anything runs on it. |
| + if err := r.recordPlacement(ctx, o.Host, v, work, in.Identity); err != nil { |
| + return err |
| + } |
| if v.dirty { |
| receipt, err := m.preparedMarker(ctx, o.Host, v.prepared(), fence) |
| if err != nil { |
| @@ -561,18 +567,48 @@ func (r movesRecoveryOwners) PublishFenced(ctx context.Context, o hostclaim.Obse |
| if work == nil { |
| return ClaimPublicationReceipt{}, fmt.Errorf("%w: no admitted writer's fence for session %d", hostbox.ErrChanged, h.Session) |
| } |
| - return m.publishFresh(ctx, o.Host, h, pub, func() error { return r.refreshFilesGen(ctx, o.Host, h, work, in.Identity) }) |
| + // The files on disk must be the tree the recovery placed before anything |
| + // is published, and their content is recorded as this generation's (the |
| + // landing's fix round 3 rule): the digest the relabel compares. |
| + placed, ok, err := r.placement(o.Host, v) |
| + if err != nil { |
| + return ClaimPublicationReceipt{}, err |
| + } |
| + if !ok { |
| + return ClaimPublicationReceipt{}, fmt.Errorf("%w: the recovery recorded no placed tree", hostbox.ErrChanged) |
| + } |
| + var digest string |
| + if err := work.With(ctx, in.Identity, func() error { |
| + if err := r.verifyPlaced(o.Host, placed); err != nil { |
| + return err |
| + } |
| + var err error |
| + digest, err = m.filesDigest(o.Host) |
| + return err |
| + }); err != nil { |
| + return ClaimPublicationReceipt{}, err |
| + } |
| + if err := m.recordFilesDigest(o.Host, v.journalOp(), h.Generation, digest); err != nil { |
| + return ClaimPublicationReceipt{}, err |
| + } |
| + return m.publishFresh(ctx, o.Host, h, pub, func() error { return r.refreshFilesGen(ctx, o.Host, h, placed, digest, work, in.Identity) }) |
| } |
| |
| // refreshFilesGen points .files-gen at the published generation, inside the |
| -// admitted writer's fence, when it names that generation's cold predecessor |
| -// for this incarnation: the files the start check accepted for this |
| -// session, which the session has served since. Absent, or anything else, is |
| -// left alone for the start check to decide. |
| -func (r movesRecoveryOwners) refreshFilesGen(ctx context.Context, host string, h *hostclaim.Holder, work hostwrite.Work, id hostwrite.Identity) error { |
| +// admitted writer's fence, after the files on disk are proved the tree the |
| +// recovery placed (its identity) with the content just recorded for the |
| +// generation (digest): never by identity alone, and a mismatch fails the |
| +// publication and relabels nothing. It relabels only a .files-gen naming the |
| +// generation's cold predecessor for this incarnation (the files the start |
| +// check accepted for this session); absent, or anything else, is left for |
| +// the start check to decide. |
| +func (r movesRecoveryOwners) refreshFilesGen(ctx context.Context, host string, h *hostclaim.Holder, placed recoveryPlacement, digest string, work hostwrite.Work, id hostwrite.Identity) error { |
| m := r.m |
| dir, _ := m.FilesDir(host) |
| return work.With(ctx, id, func() error { |
| + if err := r.verifyContent(host, placed, digest); err != nil { |
| + return err |
| + } |
| files, _, err := m.localMarkers(host, filepath.Join(dir, ".none")) |
| if err != nil || files == nil || files.Incarnation != h.Incarnation || files.Op == h.Generation { |
| return err |
| @@ -605,3 +641,138 @@ func (r movesRecoveryOwners) PublicationEvidence(ctx context.Context, o hostclai |
| } |
| return r.m.publicationEvidence(ctx, o.Host, v.holder, prepared, v.manifest) |
| } |
| + |
| +// rcStepFilesPlaced is the recovery journal's record of the tree it placed |
| +// (JSON recoveryPlacement): the files directory's and <host>.env's |
| +// identities and the tree's plain digest before the recovery's first start. |
| +// The recovery journal is the rollback's (a return) or the move's (a |
| +// revert). |
| +const rcStepFilesPlaced = "rc_files_placed" |
| + |
| +// recoveryPlacement is what rcStepFilesPlaced records. |
| +type recoveryPlacement struct { |
| + Files string `json:"files"` |
| + Env string `json:"env,omitempty"` |
| + Plain string `json:"plain"` |
| +} |
| + |
| +// journalOp is the recovery's journal: the rollback's for a return, the |
| +// move's for a revert. |
| +func (v recoveryProbe) journalOp() string { |
| + if v.kind == hostclaim.KindRestoring { |
| + return v.rb |
| + } |
| + return v.op |
| +} |
| + |
| +// placement reads the recorded placed tree; false when none is recorded. |
| +func (r movesRecoveryOwners) placement(host string, v recoveryProbe) (recoveryPlacement, bool, error) { |
| + j, err := moves.ReadJournal(r.m.DataDir, host, v.journalOp()) |
| + if err != nil { |
| + return recoveryPlacement{}, false, err |
| + } |
| + raw, ok := j.Steps[rcStepFilesPlaced] |
| + if !ok { |
| + return recoveryPlacement{}, false, nil |
| + } |
| + var p recoveryPlacement |
| + if err := json.Unmarshal([]byte(raw), &p); err != nil || p.Files == "" || p.Plain == "" { |
| + return recoveryPlacement{}, false, fmt.Errorf("%w: the recorded placed tree does not read (%v)", hostbox.ErrChanged, err) |
| + } |
| + return p, true, nil |
| +} |
| + |
| +// recordPlacement journals the tree the recovery restored, as placed, under |
| +// the start's work, before the recovery's first start: the files directory's |
| +// and <host>.env's identities and the plain digest. A dirty return's tree is |
| +// the one its install placed (landStepFilesPlaced), and must still be; a |
| +// clean return's and a revert's is the source's own tree the recovery |
| +// verified. Once recorded it is never rewritten. |
| +func (r movesRecoveryOwners) recordPlacement(ctx context.Context, host string, v recoveryProbe, work hostwrite.Work, id hostwrite.Identity) error { |
| + m := r.m |
| + if _, ok, err := r.placement(host, v); err != nil || ok { |
| + return err |
| + } |
| + dir, env := m.FilesDir(host) |
| + var p recoveryPlacement |
| + if err := work.With(ctx, id, func() error { |
| + var err error |
| + if p.Files, err = pathIdentity(dir); err != nil { |
| + return err |
| + } |
| + switch envID, err := pathIdentity(env); { |
| + case err == nil: |
| + p.Env = envID |
| + case !errors.Is(err, fs.ErrNotExist): |
| + return err |
| + } |
| + if v.dirty { |
| + j, err := moves.ReadJournal(m.DataDir, host, v.rb) |
| + if err != nil { |
| + return err |
| + } |
| + installed, err := journalledStaged(j, landStepFilesPlaced) |
| + if err != nil { |
| + return fmt.Errorf("%w: %v", hostbox.ErrChanged, err) |
| + } |
| + if installed.Files != p.Files || installed.Env != p.Env { |
| + return fmt.Errorf("%w: the files are not the tree the return installed", hostbox.ErrChanged) |
| + } |
| + } |
| + p.Plain, err = m.filesDigest(host) |
| + return err |
| + }); err != nil { |
| + return err |
| + } |
| + body, err := json.Marshal(p) |
| + if err != nil { |
| + return err |
| + } |
| + m.mu.Lock() |
| + defer m.mu.Unlock() |
| + err = moves.UpdateJournal(m.DataDir, host, v.journalOp(), func(j *moves.Journal) error { |
| + if _, ok := j.Steps[rcStepFilesPlaced]; ok { |
| + return errJournalUnchanged |
| + } |
| + j.Mark(rcStepFilesPlaced, string(body)) |
| + return nil |
| + }) |
| + if errors.Is(err, errJournalUnchanged) { |
| + return nil |
| + } |
| + return err |
| +} |
| + |
| +// verifyPlaced requires the files directory and <host>.env on disk to be |
| +// the ones the recovery placed. |
| +func (r movesRecoveryOwners) verifyPlaced(host string, p recoveryPlacement) error { |
| + dir, env := r.m.FilesDir(host) |
| + if ok, err := sameIdentity(dir, p.Files); err != nil || !ok { |
| + return fmt.Errorf("%w: %s is not the files directory the recovery placed (%v)", hostbox.ErrChanged, dir, err) |
| + } |
| + if p.Env != "" { |
| + if ok, err := sameIdentity(env, p.Env); err != nil || !ok { |
| + return fmt.Errorf("%w: %s is not the environment file the recovery placed (%v)", hostbox.ErrChanged, env, err) |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +// verifyContent requires both the placement (identity) and the recorded |
| +// content (the plain digest): never identity alone. |
| +func (r movesRecoveryOwners) verifyContent(host string, p recoveryPlacement, want string) error { |
| + if want == "" { |
| + return fmt.Errorf("%w: no recorded files digest", hostbox.ErrChanged) |
| + } |
| + if err := r.verifyPlaced(host, p); err != nil { |
| + return err |
| + } |
| + got, err := r.m.filesDigest(host) |
| + if err != nil { |
| + return err |
| + } |
| + if got != want { |
| + return fmt.Errorf("%w: the files on disk are not the recorded ones", hostbox.ErrChanged) |
| + } |
| + return nil |
| +} |