1package agent
2
3import (
4 "bufio"
5 "errors"
6 "fmt"
7 "io"
8 "io/fs"
9 "os"
10 "path/filepath"
11 "sort"
12 "strconv"
13 "strings"
14 "time"
15
16 "github.com/patriceckhart/zot/packages/core"
17 "github.com/patriceckhart/zot/packages/provider"
18)
19
20type sessionsPruneOptions struct {
21 dryRun bool
22 all bool
23 yes bool
24 olderThan *sessionAge
25 cwd string
26}
27
28type sessionAge struct {
29 value int
30 unit string
31}
32
33type statPathFunc func(string) (fs.FileInfo, error)
34
35// runSessionsCommand dispatches standalone session management commands before
36// normal agent startup, so they do not require a provider or credentials.
37func runSessionsCommand(rawArgs []string) (handled bool, err error) {
38 if len(rawArgs) == 0 || rawArgs[0] != "sessions" {
39 return false, nil
40 }
41 if len(rawArgs) == 1 {
42 printSessionsHelp(os.Stdout)
43 return true, nil
44 }
45 switch rawArgs[1] {
46 case "help", "-h", "--help":
47 printSessionsHelp(os.Stdout)
48 return true, nil
49 case "prune":
50 opts, err := parseSessionsPruneOptions(rawArgs[2:])
51 if errors.Is(err, errSessionsPruneHelp) {
52 printSessionsHelp(os.Stdout)
53 return true, nil
54 }
55 if err != nil {
56 printSessionsHelp(os.Stderr)
57 return true, err
58 }
59 return true, runSessionsPrune(opts, os.Stdin, os.Stdout, os.Stderr, os.Stat)
60 default:
61 printSessionsHelp(os.Stderr)
62 return true, fmt.Errorf("unknown sessions subcommand: %s", rawArgs[1])
63 }
64}
65
66func parseSessionsPruneOptions(args []string) (sessionsPruneOptions, error) {
67 var opts sessionsPruneOptions
68 for idx := 0; idx < len(args); idx++ {
69 arg := args[idx]
70 switch arg {
71 case "--dry-run":
72 opts.dryRun = true
73 case "--all":
74 opts.all = true
75 case "-y", "--yes":
76 opts.yes = true
77 case "--older-than":
78 if idx+1 >= len(args) {
79 return opts, fmt.Errorf("--older-than requires an age")
80 }
81 idx++
82 age, err := parseSessionAge(args[idx])
83 if err != nil {
84 return opts, err
85 }
86 opts.olderThan = &age
87 case "--cwd":
88 if idx+1 >= len(args) {
89 return opts, fmt.Errorf("--cwd requires a path")
90 }
91 idx++
92 if strings.TrimSpace(args[idx]) == "" {
93 return opts, fmt.Errorf("--cwd requires a non-empty path")
94 }
95 opts.cwd = args[idx]
96 case "-h", "--help":
97 return opts, errSessionsPruneHelp
98 default:
99 return opts, fmt.Errorf("unknown sessions prune flag: %s", arg)
100 }
101 }
102 if opts.yes && !opts.all {
103 return opts, fmt.Errorf("--yes requires --all")
104 }
105 if opts.yes && opts.dryRun {
106 return opts, fmt.Errorf("--yes cannot be used with --dry-run")
107 }
108 if opts.cwd != "" && opts.olderThan == nil {
109 return opts, fmt.Errorf("--cwd requires --older-than")
110 }
111 return opts, nil
112}
113
114func parseSessionAge(value string) (sessionAge, error) {
115 value = strings.TrimSpace(value)
116 digitEnd := 0
117 for digitEnd < len(value) && value[digitEnd] >= '0' && value[digitEnd] <= '9' {
118 digitEnd++
119 }
120 if digitEnd == 0 || digitEnd == len(value) {
121 return sessionAge{}, fmt.Errorf("invalid age %q (use m, h, d, w, mo, or y)", value)
122 }
123 amount, err := strconv.ParseInt(value[:digitEnd], 10, 32)
124 if err != nil || amount <= 0 {
125 return sessionAge{}, fmt.Errorf("invalid age %q (value must be a positive whole number)", value)
126 }
127 unit := value[digitEnd:]
128 switch unit {
129 case "m", "h", "d", "w", "mo", "y":
130 default:
131 return sessionAge{}, fmt.Errorf("invalid age unit %q (use m, h, d, w, mo, or y)", unit)
132 }
133 age := sessionAge{value: int(amount), unit: unit}
134 if _, err := age.cutoff(time.Unix(0, 0)); err != nil {
135 return sessionAge{}, fmt.Errorf("invalid age %q: %w", value, err)
136 }
137 return age, nil
138}
139
140func (age sessionAge) cutoff(now time.Time) (time.Time, error) {
141 var unit time.Duration
142 switch age.unit {
143 case "m":
144 unit = time.Minute
145 case "h":
146 unit = time.Hour
147 case "d":
148 unit = 24 * time.Hour
149 case "w":
150 unit = 7 * 24 * time.Hour
151 case "mo":
152 return subtractCalendarAge(now, 0, age.value), nil
153 case "y":
154 return subtractCalendarAge(now, age.value, 0), nil
155 default:
156 return time.Time{}, fmt.Errorf("unsupported unit %q", age.unit)
157 }
158 if int64(age.value) > int64((time.Duration(1<<63-1))/unit) {
159 return time.Time{}, fmt.Errorf("duration is too large")
160 }
161 return now.Add(-time.Duration(age.value) * unit), nil
162}
163
164// subtractCalendarAge clamps the day to the target month's final day. This
165// makes one month before March 31 the end of February rather than early March.
166func subtractCalendarAge(now time.Time, years, months int) time.Time {
167 first := time.Date(now.Year(), now.Month(), 1, now.Hour(), now.Minute(), now.Second(), now.Nanosecond(), now.Location())
168 target := first.AddDate(-years, -months, 0)
169 lastDay := target.AddDate(0, 1, -1).Day()
170 day := now.Day()
171 if day > lastDay {
172 day = lastDay
173 }
174 return time.Date(target.Year(), target.Month(), day, now.Hour(), now.Minute(), now.Second(), now.Nanosecond(), now.Location())
175}
176
177var errSessionsPruneHelp = errors.New("sessions prune help requested")
178
179func printSessionsHelp(out io.Writer) {
180 fmt.Fprintln(out, `zot sessions - manage stored sessions
181
182usage:
183 zot sessions prune select missing directory groups
184 zot sessions prune --older-than 30d select sessions inactive for 30 days
185 zot sessions prune --older-than 1mo --cwd PATH
186 zot sessions prune --dry-run list matches without deleting
187 zot sessions prune --all [--yes] select every match, optionally without prompting
188
189Age units are m (minutes), h (hours), d (24-hour days), w (7-day weeks),
190mo (calendar months), and y (calendar years). Age uses last session activity.
191Without --older-than, only sessions whose recorded directory is missing match.
192A missing directory cannot be distinguished from some unmounted filesystems.
193Unreadable and malformed entries are preserved.`)
194}
195
196func runSessionsPrune(opts sessionsPruneOptions, in io.Reader, out, errOut io.Writer, stat statPathFunc) error {
197 groups, scanIssues := core.ScanStoredSessionGroups(SessionsPath())
198 for _, issue := range scanIssues {
199 fmt.Fprintf(errOut, "warning: preserving %s: %v\n", issue.Path, issue.Err)
200 }
201
202 candidates := make([]core.StoredSessionGroup, 0, len(groups))
203 var cutoff time.Time
204 if opts.olderThan != nil {
205 var err error
206 cutoff, err = opts.olderThan.cutoff(time.Now())
207 if err != nil {
208 return err
209 }
210 cwd := opts.cwd
211 if cwd != "" {
212 cwd, err = filepath.Abs(cwd)
213 if err != nil {
214 return fmt.Errorf("resolve --cwd: %w", err)
215 }
216 cwd = filepath.Clean(cwd)
217 }
218 for _, group := range groups {
219 if cwd != "" && filepath.Clean(group.CWD) != cwd {
220 continue
221 }
222 matching := core.StoredSessionGroup{CWD: group.CWD}
223 for _, path := range group.Paths {
224 info, err := stat(path)
225 if err != nil {
226 fmt.Fprintf(errOut, "warning: preserving %s: %v\n", path, err)
227 continue
228 }
229 if info.ModTime().Before(cutoff) {
230 matching.Paths = append(matching.Paths, path)
231 matching.SizeBytes += info.Size()
232 }
233 }
234 if len(matching.Paths) > 0 {
235 candidates = append(candidates, matching)
236 }
237 }
238 } else {
239 for _, group := range groups {
240 if !filepath.IsAbs(group.CWD) {
241 fmt.Fprintf(errOut, "warning: preserving sessions with non-absolute cwd %q\n", group.CWD)
242 continue
243 }
244 _, err := stat(group.CWD)
245 switch {
246 case err == nil:
247 continue
248 case errors.Is(err, fs.ErrNotExist):
249 candidates = append(candidates, group)
250 default:
251 fmt.Fprintf(errOut, "warning: preserving sessions for %s: %v\n", group.CWD, err)
252 }
253 }
254 }
255 if len(candidates) == 0 {
256 if opts.olderThan != nil {
257 fmt.Fprintln(out, "no sessions older than the requested age found")
258 } else {
259 fmt.Fprintln(out, "no stale session directories found")
260 }
261 return nil
262 }
263
264 if opts.olderThan != nil {
265 fmt.Fprintln(out, "session directories with inactive sessions:")
266 } else {
267 fmt.Fprintln(out, "missing session directories:")
268 }
269 for idx, group := range candidates {
270 fmt.Fprintf(out, " %d. %s (%s, %s)\n", idx+1, group.CWD, sessionCount(len(group.Paths)), provider.FormatBytes(group.SizeBytes))
271 }
272 if opts.dryRun {
273 fmt.Fprintf(out, "dry run: %s in %s would be deleted\n", sessionCount(totalSessions(candidates)), directoryCount(len(candidates)))
274 return nil
275 }
276
277 reader := bufio.NewReader(in)
278 selected := make([]int, 0, len(candidates))
279 if opts.all {
280 for idx := range candidates {
281 selected = append(selected, idx)
282 }
283 } else {
284 fmt.Fprint(out, "select groups to delete (for example 1,3-5; all; none): ")
285 line, err := reader.ReadString('\n')
286 if err != nil && !errors.Is(err, io.EOF) {
287 return fmt.Errorf("read selection: %w", err)
288 }
289 selected, err = parseSessionSelection(line, len(candidates))
290 if err != nil {
291 return err
292 }
293 if len(selected) == 0 {
294 fmt.Fprintln(out, "no sessions deleted")
295 return nil
296 }
297 }
298
299 selectedSessions := 0
300 for _, idx := range selected {
301 selectedSessions += len(candidates[idx].Paths)
302 }
303 if !opts.yes {
304 fmt.Fprintf(out, "permanently delete %s in %s? [y/N]: ", sessionCount(selectedSessions), directoryCount(len(selected)))
305 line, err := reader.ReadString('\n')
306 if err != nil && !errors.Is(err, io.EOF) {
307 return fmt.Errorf("read confirmation: %w", err)
308 }
309 answer := strings.ToLower(strings.TrimSpace(line))
310 if answer != "y" && answer != "yes" {
311 fmt.Fprintln(out, "no sessions deleted")
312 return nil
313 }
314 }
315
316 deletedSessions := 0
317 deletedGroups := 0
318 var deleteErrors []error
319 for _, idx := range selected {
320 group := candidates[idx]
321 if opts.olderThan == nil {
322 _, err := stat(group.CWD)
323 if err == nil {
324 fmt.Fprintf(errOut, "warning: preserving sessions for %s because the directory now exists\n", group.CWD)
325 continue
326 }
327 if !errors.Is(err, fs.ErrNotExist) {
328 fmt.Fprintf(errOut, "warning: preserving sessions for %s after recheck: %v\n", group.CWD, err)
329 continue
330 }
331 }
332
333 groupDeleted := 0
334 for _, path := range group.Paths {
335 if opts.olderThan != nil {
336 info, err := stat(path)
337 if err != nil {
338 fmt.Fprintf(errOut, "warning: preserving %s after recheck: %v\n", path, err)
339 continue
340 }
341 if !info.ModTime().Before(cutoff) {
342 fmt.Fprintf(errOut, "warning: preserving %s because it now has recent activity\n", path)
343 continue
344 }
345 }
346 if err := core.DeleteSession(path); err != nil {
347 deleteErrors = append(deleteErrors, fmt.Errorf("delete %s: %w", path, err))
348 continue
349 }
350 groupDeleted++
351 _ = os.Remove(filepath.Dir(path))
352 }
353 deletedSessions += groupDeleted
354 if groupDeleted == len(group.Paths) {
355 deletedGroups++
356 }
357 }
358 fmt.Fprintf(out, "deleted %s from %s\n", sessionCount(deletedSessions), directoryCount(deletedGroups))
359 return errors.Join(deleteErrors...)
360}
361
362func parseSessionSelection(input string, count int) ([]int, error) {
363 input = strings.ToLower(strings.TrimSpace(input))
364 switch input {
365 case "", "none", "n":
366 return nil, nil
367 case "all", "a":
368 selected := make([]int, count)
369 for idx := range selected {
370 selected[idx] = idx
371 }
372 return selected, nil
373 }
374
375 seen := make(map[int]bool)
376 for _, field := range strings.Split(input, ",") {
377 field = strings.TrimSpace(field)
378 if field == "" {
379 return nil, fmt.Errorf("invalid empty selection")
380 }
381 startText, endText, isRange := strings.Cut(field, "-")
382 start, err := strconv.Atoi(strings.TrimSpace(startText))
383 if err != nil {
384 return nil, fmt.Errorf("invalid selection %q", field)
385 }
386 end := start
387 if isRange {
388 end, err = strconv.Atoi(strings.TrimSpace(endText))
389 if err != nil || end < start {
390 return nil, fmt.Errorf("invalid selection range %q", field)
391 }
392 }
393 if start < 1 || end > count {
394 return nil, fmt.Errorf("selection %q is outside 1-%d", field, count)
395 }
396 for value := start; value <= end; value++ {
397 seen[value-1] = true
398 }
399 }
400 selected := make([]int, 0, len(seen))
401 for idx := range seen {
402 selected = append(selected, idx)
403 }
404 sort.Ints(selected)
405 return selected, nil
406}
407
408func totalSessions(groups []core.StoredSessionGroup) int {
409 total := 0
410 for _, group := range groups {
411 total += len(group.Paths)
412 }
413 return total
414}
415
416func sessionCount(count int) string {
417 if count == 1 {
418 return "1 session"
419 }
420 return fmt.Sprintf("%d sessions", count)
421}
422
423func directoryCount(count int) string {
424 if count == 1 {
425 return "1 directory"
426 }
427 return fmt.Sprintf("%d directories", count)
428}