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