okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

Aly Raffaufrefactor(cli): invoke daemon storage helpersf6da61d

main
6.8 KiB198 linesraw
1// Package storage inspects local usage and applies approved, revalidated cleanup plans.
2package storage
3
4import (
5	"albedo/cli/internal/daemon"
6	"context"
7	"errors"
8	"fmt"
9	"os"
10	"os/exec"
11	"regexp"
12	"slices"
13	"time"
14)
15
16// Service keeps offline inspection separate from daemon-backed session deletion.
17type Service struct {
18	Command        func(context.Context, ...string) (*exec.Cmd, error)
19	OnlineReport   func(context.Context) (*daemon.StorageReport, error)
20	Now            func() time.Time
21	Running        func() (bool, error)
22	DeleteSessions func(context.Context, []string) error
23	Home           string
24}
25type CleanupOptions struct {
26	Sessions                         []string
27	All, OldKernels, Backups, Vacuum bool
28}
29
30// CleanupPlan exposes a copy of the preview; execution uses its private approved values.
31type CleanupPlan struct {
32	files   map[string]os.FileInfo
33	home    string
34	options CleanupOptions
35	preview daemon.StorageReport
36}
37
38func (p CleanupPlan) Preview() daemon.StorageReport {
39	result := p.preview
40	result.DB.Sessions = slices.Clone(result.DB.Sessions)
41	result.OldKernels = slices.Clone(result.OldKernels)
42	result.OldBackups = slices.Clone(result.OldBackups)
43	return result
44}
45func (p CleanupPlan) Options() CleanupOptions {
46	options := p.options
47	options.Sessions = slices.Clone(options.Sessions)
48	return options
49}
50
51type CleanupResult struct {
52	VacuumSkipped bool
53	Vacuumed      bool
54	Before, After int64
55}
56
57var storageID = regexp.MustCompile(`^(?:[0-9a-f]{32}|[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$`)
58
59func (s *Service) PlanCleanup(ctx context.Context, options CleanupOptions) (CleanupPlan, error) {
60	var plan CleanupPlan
61	options.Sessions = slices.Clone(options.Sessions)
62	for i, id := range options.Sessions {
63		if !storageID.MatchString(id) {
64			return plan, errors.New("--session needs a full session ID; find it with albedo sessions")
65		}
66		if slices.Contains(options.Sessions[:i], id) {
67			return plan, fmt.Errorf("session %s was selected more than once", id)
68		}
69	}
70	if options.All {
71		if len(options.Sessions) > 0 || options.OldKernels || options.Backups || options.Vacuum {
72			return plan, errors.New("--all cannot be combined with other cleanup options; use --all on its own or choose individual options")
73		}
74		options.OldKernels, options.Backups, options.Vacuum = true, true, true
75	}
76	if len(options.Sessions) == 0 && !options.OldKernels && !options.Backups && !options.Vacuum {
77		return plan, errors.New("choose what to clean up: --all, --session, --old-kernels, --backups or --vacuum")
78	}
79	if len(options.Sessions) > 0 && (options.OldKernels || options.Backups || options.Vacuum) {
80		return plan, errors.New("delete sessions and clean up local files in separate commands; session deletion needs Albedo running, file cleanup needs it stopped")
81	}
82	var preview daemon.StorageReport
83	var err error
84	if len(options.Sessions) > 0 {
85		if s.OnlineReport == nil {
86			return plan, errors.New("session deletion needs a running daemon; start Albedo, then try again")
87		}
88		online, reportErr := s.OnlineReport(ctx)
89		if reportErr != nil {
90			return plan, reportErr
91		}
92		if online == nil {
93			return plan, errors.New("session deletion needs a running daemon; start Albedo, then try again")
94		}
95		preview = *online
96	} else {
97		preview, err = s.OfflineReport(ctx)
98	}
99	if err != nil {
100		return plan, err
101	}
102	if len(options.Sessions) == 0 {
103		if options.OldKernels && preview.Database == 0 {
104			return plan, errors.New("cannot identify unused Python state files without the SQLite database; no files have been removed")
105		}
106		running, err := s.Running()
107		if err != nil {
108			return plan, err
109		}
110		if running {
111			return plan, errors.New("cleanup needs Albedo stopped; no files have been removed; when its work has finished, run albedo daemon --stop, then try again")
112		}
113	} else {
114		for _, id := range options.Sessions {
115			if !slices.ContainsFunc(preview.DB.Sessions, func(session daemon.StorageSession) bool { return session.ID == id }) {
116				return plan, fmt.Errorf("no session has the ID %s; use the full ID from albedo sessions", id)
117			}
118		}
119	}
120	plan = CleanupPlan{preview: preview, options: options, home: s.Home, files: make(map[string]os.FileInfo)}
121	if len(options.Sessions) > 0 {
122		return plan, nil
123	}
124	for _, file := range append(slices.Clone(preview.OldKernels), preview.OldBackups...) {
125		if err := ctx.Err(); err != nil {
126			return CleanupPlan{}, err
127		}
128		info, err := os.Lstat(file.Path)
129		if err != nil {
130			return CleanupPlan{}, err
131		}
132		plan.files[file.Path] = info
133	}
134	return plan, nil
135}
136func (s *Service) ApplyCleanup(ctx context.Context, plan CleanupPlan) (CleanupResult, error) {
137	var result CleanupResult
138	if plan.home != s.Home || plan.files == nil {
139		return result, errors.New("cleanup plan does not belong to this storage directory")
140	}
141	options, preview := plan.options, plan.preview
142	if len(options.Sessions) > 0 {
143		return result, s.DeleteSessions(ctx, options.Sessions)
144	}
145	running, err := s.Running()
146	if err != nil {
147		return result, err
148	}
149	if running {
150		return result, errors.New("daemon started while you were confirming cleanup; no files have been removed; stop Albedo and try again")
151	}
152	var files []daemon.StorageFile
153	if options.OldKernels {
154		files = append(files, preview.OldKernels...)
155	}
156	if options.Backups {
157		files = append(files, preview.OldBackups...)
158	}
159	helper, err := s.startMaintenance(ctx, files)
160	if err != nil {
161		return result, err
162	}
163	defer helper.close()
164	fresh, err := s.OfflineReport(ctx)
165	if err != nil {
166		if ctx.Err() != nil {
167			return result, ctx.Err()
168		}
169		return result, err
170	}
171	if options.OldKernels && fresh.Database == 0 {
172		return result, errors.New("database disappeared after the preview; no Python state files have been removed; restore the database and retry cleanup")
173	}
174	if options.OldKernels && !slices.Equal(preview.OldKernels, fresh.OldKernels) {
175		return result, errors.New("unused Python state files changed after the preview; no files have been removed; run cleanup again to review the new list")
176	}
177	if options.Backups && !slices.Equal(preview.OldBackups, fresh.OldBackups) {
178		return result, errors.New("backups changed after the preview; no backups have been removed; run cleanup again to review the new list")
179	}
180	// Recheck all approved candidates before deleting any, including replacements of equal size.
181	for _, file := range files {
182		if err := ctx.Err(); err != nil {
183			return result, err
184		}
185		info, err := os.Lstat(file.Path)
186		if err != nil {
187			return result, err
188		}
189		approved := plan.files[file.Path]
190		if !info.Mode().IsRegular() || !os.SameFile(approved, info) || info.Size() != approved.Size() || !info.ModTime().Equal(approved.ModTime()) {
191			return result, fmt.Errorf("file changed after the preview; cleanup stopped: %s", file.Path)
192		}
193	}
194	if err := ctx.Err(); err != nil {
195		return result, err
196	}
197	return helper.apply(ctx, options.Vacuum, fresh)
198}