380 lines
11 KiB
Go
380 lines
11 KiB
Go
// Copyright 2026 MarketAlly. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package cleanup
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"gitea.com/gitea/act_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])
|
|
}
|