// Copyright 2026 MarketAlly. All rights reserved. // SPDX-License-Identifier: MIT package cleanup import ( "context" "fmt" "os" "path/filepath" "time" "git.marketally.com/gitcaddy/gitcaddy-runner/internal/pkg/config" log "github.com/sirupsen/logrus" ) // CleanupResult contains the results of a cleanup operation type CleanupResult struct { BytesFreed int64 FilesDeleted int Errors []error Duration time.Duration } // RunCleanup performs cleanup operations to free disk space func RunCleanup(ctx context.Context, cfg *config.Config) (*CleanupResult, error) { start := time.Now() result := &CleanupResult{} log.Info("Starting runner cleanup...") // 1. Clean old cache directories cacheDir := filepath.Join(cfg.Cache.Dir, "_cache") if cacheDir != "" { if bytes, files, err := cleanOldDir(cacheDir, 24*time.Hour); err != nil { result.Errors = append(result.Errors, fmt.Errorf("cache cleanup: %w", err)) } else { result.BytesFreed += bytes result.FilesDeleted += files log.Infof("Cleaned cache: freed %d bytes, deleted %d files", bytes, files) } } // 2. Clean old work directories workDir := cfg.Container.WorkdirParent if workDir != "" { if bytes, files, err := cleanOldWorkDirs(workDir, 48*time.Hour); err != nil { result.Errors = append(result.Errors, fmt.Errorf("workdir cleanup: %w", err)) } else { result.BytesFreed += bytes result.FilesDeleted += files log.Infof("Cleaned work dirs: freed %d bytes, deleted %d files", bytes, files) } } // 3. Clean old artifact staging directories artifactDir := cfg.Cache.Dir if bytes, files, err := cleanOldArtifacts(artifactDir, 72*time.Hour); err != nil { result.Errors = append(result.Errors, fmt.Errorf("artifact cleanup: %w", err)) } else { result.BytesFreed += bytes result.FilesDeleted += files log.Infof("Cleaned artifacts: freed %d bytes, deleted %d files", bytes, files) } // 4. Clean system temp files (older than 24h) if bytes, files, err := cleanTempDir(24 * time.Hour); err != nil { result.Errors = append(result.Errors, fmt.Errorf("temp cleanup: %w", err)) } else { result.BytesFreed += bytes result.FilesDeleted += files log.Infof("Cleaned temp: freed %d bytes, deleted %d files", bytes, files) } // 5. Clean build tool caches (older than 7 days) // These can grow very large from Go, npm, nuget, gradle, maven builds if bytes, files, err := cleanBuildCaches(7 * 24 * time.Hour); err != nil { result.Errors = append(result.Errors, fmt.Errorf("build cache cleanup: %w", err)) } else { result.BytesFreed += bytes result.FilesDeleted += files log.Infof("Cleaned build caches: freed %d bytes, deleted %d files", bytes, files) } result.Duration = time.Since(start) log.Infof("Cleanup completed: freed %s in %s", formatBytes(result.BytesFreed), result.Duration) return result, nil } // cleanOldDir removes files older than maxAge from a directory func cleanOldDir(dir string, maxAge time.Duration) (int64, int, error) { if _, err := os.Stat(dir); os.IsNotExist(err) { return 0, 0, nil } var bytesFreed int64 var filesDeleted int cutoff := time.Now().Add(-maxAge) err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { if err != nil { return nil // Skip errors } if info.IsDir() { return nil } if info.ModTime().Before(cutoff) { size := info.Size() if err := os.Remove(path); err == nil { bytesFreed += size filesDeleted++ } } return nil }) return bytesFreed, filesDeleted, err } // cleanOldWorkDirs removes work directories older than maxAge func cleanOldWorkDirs(baseDir string, maxAge time.Duration) (int64, int, error) { if _, err := os.Stat(baseDir); os.IsNotExist(err) { return 0, 0, nil } var bytesFreed int64 var filesDeleted int cutoff := time.Now().Add(-maxAge) entries, err := os.ReadDir(baseDir) if err != nil { return 0, 0, err } for _, entry := range entries { if !entry.IsDir() { continue } path := filepath.Join(baseDir, entry.Name()) info, err := entry.Info() if err != nil { continue } if info.ModTime().Before(cutoff) { size := dirSize(path) if err := os.RemoveAll(path); err == nil { bytesFreed += size filesDeleted++ log.Debugf("Removed old work dir: %s", path) } } } return bytesFreed, filesDeleted, nil } // cleanOldArtifacts removes artifact staging files older than maxAge func cleanOldArtifacts(baseDir string, maxAge time.Duration) (int64, int, error) { if _, err := os.Stat(baseDir); os.IsNotExist(err) { return 0, 0, nil } var bytesFreed int64 var filesDeleted int cutoff := time.Now().Add(-maxAge) // Look for artifact staging dirs patterns := []string{"artifact-*", "upload-*", "download-*"} for _, pattern := range patterns { matches, _ := filepath.Glob(filepath.Join(baseDir, pattern)) for _, path := range matches { info, err := os.Stat(path) if err != nil { continue } if info.ModTime().Before(cutoff) { var size int64 if info.IsDir() { size = dirSize(path) err = os.RemoveAll(path) } else { size = info.Size() err = os.Remove(path) } if err == nil { bytesFreed += size filesDeleted++ } } } } return bytesFreed, filesDeleted, nil } // cleanTempDir removes old files from system temp directory func cleanTempDir(maxAge time.Duration) (int64, int, error) { tmpDir := os.TempDir() var bytesFreed int64 var filesDeleted int cutoff := time.Now().Add(-maxAge) entries, err := os.ReadDir(tmpDir) if err != nil { return 0, 0, err } // Only clean files/dirs that look like runner/act artifacts runnerPatterns := []string{"act-", "runner-", "gitea-", "workflow-", "go-build", "go-link", "node-compile-cache", "npm-", "yarn-", "pnpm-"} for _, entry := range entries { name := entry.Name() isRunner := false for _, p := range runnerPatterns { if len(name) >= len(p) && name[:len(p)] == p { isRunner = true break } } if !isRunner { continue } path := filepath.Join(tmpDir, name) info, err := entry.Info() if err != nil { continue } if info.ModTime().Before(cutoff) { var size int64 if info.IsDir() { size = dirSize(path) err = os.RemoveAll(path) } else { size = info.Size() err = os.Remove(path) } if err == nil { bytesFreed += size filesDeleted++ } } } return bytesFreed, filesDeleted, nil } // dirSize calculates the total size of a directory func dirSize(path string) int64 { var size int64 filepath.Walk(path, func(_ string, info os.FileInfo, err error) error { if err != nil { return nil } if !info.IsDir() { size += info.Size() } return nil }) return size } // cleanBuildCaches removes old build tool caches that accumulate from CI jobs // These are cleaned more aggressively (files older than 7 days) since they can grow very large func cleanBuildCaches(maxAge time.Duration) (int64, int, error) { home := os.Getenv("HOME") if home == "" { home = os.Getenv("USERPROFILE") // Windows } if home == "" { home = "/root" // fallback for runners typically running as root } var totalBytesFreed int64 var totalFilesDeleted int // Build cache directories to clean // Format: {path, description, maxAge (0 = use default)} // Go build cache cleaned more aggressively (3 days) as it grows very fast goBuildMaxAge := 3 * 24 * time.Hour cacheDirs := []struct { path string desc string maxAge time.Duration }{ // Linux paths {filepath.Join(home, ".cache", "go-build"), "Go build cache", goBuildMaxAge}, {filepath.Join(home, ".cache", "golangci-lint"), "golangci-lint cache", 0}, {filepath.Join(home, ".npm", "_cacache"), "npm cache", 0}, {filepath.Join(home, ".cache", "pnpm"), "pnpm cache", 0}, {filepath.Join(home, ".cache", "yarn"), "yarn cache", 0}, {filepath.Join(home, ".nuget", "packages"), "NuGet cache", 0}, {filepath.Join(home, ".gradle", "caches"), "Gradle cache", 0}, {filepath.Join(home, ".m2", "repository"), "Maven cache", 0}, {filepath.Join(home, ".cache", "pip"), "pip cache", 0}, {filepath.Join(home, ".cargo", "registry", "cache"), "Cargo cache", 0}, {filepath.Join(home, ".rustup", "tmp"), "Rustup temp", 0}, // macOS paths (Library/Caches) {filepath.Join(home, "Library", "Caches", "go-build"), "Go build cache (macOS)", goBuildMaxAge}, {filepath.Join(home, "Library", "Caches", "Yarn"), "Yarn cache (macOS)", 0}, {filepath.Join(home, "Library", "Caches", "pip"), "pip cache (macOS)", 0}, {filepath.Join(home, "Library", "Caches", "Homebrew"), "Homebrew cache (macOS)", 0}, // Windows paths (LOCALAPPDATA) {filepath.Join(os.Getenv("LOCALAPPDATA"), "go-build"), "Go build cache (Windows)", goBuildMaxAge}, {filepath.Join(os.Getenv("LOCALAPPDATA"), "npm-cache"), "npm cache (Windows)", 0}, {filepath.Join(os.Getenv("LOCALAPPDATA"), "pnpm"), "pnpm cache (Windows)", 0}, {filepath.Join(os.Getenv("LOCALAPPDATA"), "Yarn", "Cache"), "Yarn cache (Windows)", 0}, {filepath.Join(os.Getenv("LOCALAPPDATA"), "NuGet", "v3-cache"), "NuGet cache (Windows)", 0}, {filepath.Join(os.Getenv("LOCALAPPDATA"), "pip", "Cache"), "pip cache (Windows)", 0}, } for _, cache := range cacheDirs { if _, err := os.Stat(cache.path); os.IsNotExist(err) { continue } // Use cache-specific maxAge if set, otherwise use default cacheMaxAge := cache.maxAge if cacheMaxAge == 0 { cacheMaxAge = maxAge } cutoff := time.Now().Add(-cacheMaxAge) var bytesFreed int64 var filesDeleted int err := filepath.Walk(cache.path, func(path string, info os.FileInfo, err error) error { if err != nil { return nil // Skip errors } if info.IsDir() { return nil } if info.ModTime().Before(cutoff) { size := info.Size() if err := os.Remove(path); err == nil { bytesFreed += size filesDeleted++ } } return nil }) if err == nil && (bytesFreed > 0 || filesDeleted > 0) { log.Infof("Cleaned %s: freed %s, deleted %d files", cache.desc, formatBytes(bytesFreed), filesDeleted) totalBytesFreed += bytesFreed totalFilesDeleted += filesDeleted } // Also remove empty directories filepath.Walk(cache.path, func(path string, info os.FileInfo, err error) error { if err != nil || !info.IsDir() || path == cache.path { return nil } entries, _ := os.ReadDir(path) if len(entries) == 0 { os.Remove(path) } return nil }) } return totalBytesFreed, totalFilesDeleted, nil } // formatBytes formats bytes into human readable string func formatBytes(bytes int64) string { const unit = 1024 if bytes < unit { return fmt.Sprintf("%d B", bytes) } div, exp := int64(unit), 0 for n := bytes / unit; n >= unit; n /= unit { div *= unit exp++ } return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) }