Luigit
repositories / bugabinga.net

bugabinga.net

personal infrastructure for bugabinga!

owned by admin

services/luci/internal/lifecycle/lifecycle.go

Raw
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 != ".." }