package lifecycle import ( "encoding/json" "errors" "fmt" "io/fs" "os" "path/filepath" "sort" "strings" "syscall" "time" "bugabinga.net/luci/internal/history" ) type Tombstone struct { RunID string `json:"run_id"` DeletedAt time.Time `json:"deleted_at"` } type Preview struct { RunID string `json:"run_id"` Events []history.Event `json:"events"` Files []File `json:"files"` Bytes int64 `json:"reclaimable_bytes"` } type File struct { Path string `json:"path"` Size int64 `json:"size"` } type Lock struct{ file *os.File } // TryLockRun serializes deletion with processing one run. // shared=false never waits, so an operator receives a definite busy result. func TryLockRun(dataDir, runID string, shared bool) (*Lock, error) { if !safe(runID) { return nil, fmt.Errorf("unsafe run id %q", runID) } dir := filepath.Join(dataDir, "locks") if err := os.MkdirAll(dir, 0o755); err != nil { return nil, err } file, err := os.OpenFile(filepath.Join(dir, runID+".lock"), os.O_CREATE|os.O_RDWR, 0o644) if err != nil { return nil, err } op := syscall.LOCK_NB if shared { op |= syscall.LOCK_SH } else { op |= syscall.LOCK_EX } if err := syscall.Flock(int(file.Fd()), op); err != nil { file.Close() if errors.Is(err, syscall.EWOULDBLOCK) { return nil, fmt.Errorf("run %s is being processed", runID) } return nil, err } return &Lock{file: file}, nil } func (l *Lock) Close() error { if l == nil || l.file == nil { return nil } err := syscall.Flock(int(l.file.Fd()), syscall.LOCK_UN) return errors.Join(err, l.file.Close()) } func Deleted(dataDir, runID string) (bool, error) { _, err := os.Stat(path(dataDir, runID)) if errors.Is(err, os.ErrNotExist) { return false, nil } return err == nil, err } func Filter(dataDir string, events []history.Event) ([]history.Event, error) { out := events[:0] seen := map[string]bool{} for _, event := range events { deleted, ok := seen[event.RunID] if !ok { var err error deleted, err = Deleted(dataDir, event.RunID) if err != nil { return nil, err } seen[event.RunID] = deleted } if !deleted { out = append(out, event) } } return out, nil } func PreviewRun(dataDir, runID string, events []history.Event) (Preview, error) { if !safe(runID) { return Preview{}, fmt.Errorf("unsafe run id %q", runID) } p := Preview{RunID: runID} for _, e := range events { if e.RunID == runID { p.Events = append(p.Events, e) } } if len(p.Events) == 0 { return p, fmt.Errorf("unknown run %s", runID) } paths := map[string]bool{filepath.Join(dataDir, "artifacts", runID): true} for _, e := range p.Events { if e.ChildID == "" || e.ChildID == "run" { continue } name := runID + "-" + e.ChildID if strings.HasPrefix(e.ChildID, "legacy-") { name = runID + "-" + e.Job } paths[filepath.Join(dataDir, "live", name+".log")] = true paths[filepath.Join(dataDir, "logs", name+".log.zst")] = true } for root := range paths { err := filepath.WalkDir(root, func(name string, entry fs.DirEntry, err error) error { if errors.Is(err, os.ErrNotExist) { return nil } if err != nil { return err } if entry.Type().IsRegular() { info, err := entry.Info() if err != nil { return err } p.Files = append(p.Files, File{Path: name, Size: info.Size()}) p.Bytes += info.Size() } return nil }) if err != nil { return p, err } } sort.Slice(p.Files, func(i, j int) bool { return p.Files[i].Path < p.Files[j].Path }) return p, nil } // Delete writes durable visibility barrier before best-effort retryable cleanup. func Delete(dataDir string, preview Preview) error { if !safe(preview.RunID) { return fmt.Errorf("unsafe run id %q", preview.RunID) } if err := os.MkdirAll(filepath.Join(dataDir, "deleted"), 0o755); err != nil { return err } data, err := json.Marshal(Tombstone{RunID: preview.RunID, DeletedAt: time.Now().UTC()}) if err != nil { return err } tmp := path(dataDir, preview.RunID) + ".tmp" file, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644) if err != nil { return err } if _, err = file.Write(data); err == nil { err = file.Sync() } if closeErr := file.Close(); err == nil { err = closeErr } if err != nil { return err } if err := os.Rename(tmp, path(dataDir, preview.RunID)); err != nil { return err } dir, err := os.Open(filepath.Join(dataDir, "deleted")) if err != nil { return err } err = dir.Sync() closeErr := dir.Close() if err != nil { return err } if closeErr != nil { return closeErr } for _, file := range preview.Files { if err := os.Remove(file.Path); err != nil && !errors.Is(err, os.ErrNotExist) { return err } } return os.RemoveAll(filepath.Join(dataDir, "artifacts", preview.RunID)) } func path(dataDir, runID string) string { return filepath.Join(dataDir, "deleted", runID+".json") } func safe(id string) bool { return id != "" && filepath.Base(id) == id && id != "." && id != ".." }