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