// SPDX-License-Identifier: GPL-3.0-or-later package engine import ( "context" "fmt" "git.labunix.xyz/krino/internal/journal" "git.labunix.xyz/krino/internal/lock" "git.labunix.xyz/krino/internal/plan" ) // Session is one run of krino over one or more directories: the log it // writes, the run id every entry carries, and the claims its directories // share. Both the command line and the GUI go through it, so the order a // directory is locked, planned, applied and released in - and what a later // directory may still claim - is written once (GUI design §1.3). // // A dry session opens no log and takes no run id: journal.Open creates the // state directory and an empty krino.log merely by being called, and a dry // run must not (spec §11). type Session struct { e *Engine j *journal.Writer run string claims *plan.Claims landed []string // where this run's applied files ended up, in order dry bool } // NewSession starts a run. The log is opened by OpenLog, or by the first // Apply or ApplyUndo: sorting opens it before it plans anything, while undo // opens it only once it knows it will reverse something, and neither must // create a log a dry run would not. The caller closes the session when the // run is over. func (e *Engine) NewSession(dry bool) (*Session, error) { return &Session{e: e, claims: plan.NewClaims(), dry: dry}, nil } // OpenLog opens the log and takes the run id, once. A dry session does // neither: journal.Open creates the state directory and an empty krino.log // merely by being called (spec §11). func (s *Session) OpenLog() error { if s.dry || s.j != nil { return nil } j, err := journal.Open(s.e.Config.LogFile()) if err != nil { return err } s.j, s.run = j, journal.NewRunID(s.e.Now()) return nil } // Run is the run id every entry of this session carries; "" for a dry one, // and until the log is open. func (s *Session) Run() string { return s.run } // Journal is the log this session writes, nil for a dry one. func (s *Session) Journal() *journal.Writer { return s.j } // Lock takes d's lock (spec §3, §11). wait blocks until the holder is gone, // or until ctx is cancelled; without it a held lock returns lock.ErrHeld at // once, so a cron job never piles up behind a stuck run. The caller // releases it. func (s *Session) Lock(ctx context.Context, d *Dir, wait bool) (*lock.Lock, error) { return lock.Acquire(ctx, s.e.Config.LockFile(d.Name), wait) } // LockDirs takes the locks of several directories, in the order given, and // releases every one it took if any of them cannot be had - so an undo // spanning directories never holds half of them (spec §10). func (s *Session) LockDirs(ctx context.Context, names []string, wait bool) ([]*lock.Lock, error) { var held []*lock.Lock for _, name := range names { l, err := lock.Acquire(ctx, s.e.Config.LockFile(name), wait) if err != nil { for _, h := range held { h.Release() } return nil, fmt.Errorf("%s: %w", name, err) } held = append(held, l) } return held, nil } // Plan builds d's plan with the run's claims. func (s *Session) Plan(ctx context.Context, d *Dir) (*DirPlan, error) { return s.e.Plan(ctx, d, s.claims) } // Apply carries out the approved files of dp and logs the run's steps, // remembering where they ended up for FinishDirectory. func (s *Session) Apply(ctx context.Context, dp *DirPlan, approved map[string]bool) (*ApplyResult, error) { if err := s.OpenLog(); err != nil { return nil, err } res, err := s.e.Apply(ctx, dp, approved, s.j, s.run) s.landed = append(s.landed, landedAt(res)...) return res, err } // FinishDirectory ends one directory of a real run: the disk is now the // truth for the next one, so the claims start again from where this run's // files have actually ended up - every directory's, not just this one's // (spec §7.4). A path this directory planned but did not apply is free // again; a path it did apply stays protected from a later (on-conflict // overwrite) for the rest of the run, even across a directory that applies // nothing. A dry run applies nothing and keeps every claim. func (s *Session) FinishDirectory() { if s.dry { return } s.claims = plan.NewClaims() for _, p := range s.landed { s.claims.Claim(p) } } // landedAt is where res's files ended up: each copy, and the last place a // move or rename put a file - not a path it passed through and left, and // nothing at all for a file deleted for good. func landedAt(res *ApplyResult) []string { if res == nil { return nil } var out []string for _, fr := range res.Files { final := "" for _, sr := range fr.Steps { switch { case sr.Status != "ok": case sr.Step.Kind == plan.DeletePermanent: final = "" case sr.Dst == "": case sr.Step.Kind == plan.Copy: out = append(out, sr.Dst) default: final = sr.Dst } } if final != "" { out = append(out, final) } } return out } // PlanUndo builds the reversal of runID (spec §10). func (s *Session) PlanUndo(runID string) (*UndoPlan, error) { return s.e.PlanUndo(runID) } // ApplyUndo carries out up and logs it under this session's run id. func (s *Session) ApplyUndo(ctx context.Context, up *UndoPlan) (*ApplyResult, error) { if err := s.OpenLog(); err != nil { return nil, err } return s.e.ApplyUndo(ctx, up, s.j, s.run) } // Close closes the log. A dry session has nothing to close. func (s *Session) Close() error { if s.j == nil { return nil } err := s.j.Close() s.j = nil return err }