Updates golangci-lint configuration to v2 format with Go 1.23, streamlines linter settings by removing deprecated options and unnecessary exclusions. Adds package documentation and renames CleanupResult to Result for consistency. Marks unused context parameter with underscore.
391 lines
11 KiB
Go
391 lines
11 KiB
Go
// Copyright 2026 MarketAlly. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
// Package cleanup provides disk cleanup utilities for CI runners.
|
|
package cleanup
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"git.marketally.com/gitcaddy/gitcaddy-runner/internal/pkg/config"
|
|
|
|
log "github.com/sirupsen/logrus"
|
|
)
|
|
|
|
// Result contains the results of a cleanup operation.
|
|
type Result struct {
|
|
BytesFreed int64
|
|
FilesDeleted int
|
|
Errors []error
|
|
Duration time.Duration
|
|
}
|
|
|
|
// RunCleanup performs cleanup operations to free disk space.
|
|
func RunCleanup(_ context.Context, cfg *config.Config) (*Result, error) {
|
|
start := time.Now()
|
|
result := &Result{}
|
|
|
|
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 or build tool temp files
|
|
runnerPatterns := []string{
|
|
"act-", "runner-", "gitea-", "workflow-",
|
|
"go-build", "go-link",
|
|
"node-compile-cache", "npm-", "yarn-", "yarn--", "pnpm-",
|
|
"ts-node-", "tsx-", "jiti", "v8-compile-cache",
|
|
"text-diff-expansion-test", "DiagOutputDir",
|
|
"dugite-native-", "reorderCommitMessage-", "squashCommitMessage-",
|
|
}
|
|
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},
|
|
// Windows custom paths used by some CI setups
|
|
{"C:\\L\\Yarn", "Yarn global cache (Windows)", 0},
|
|
{filepath.Join(os.TempDir(), "chocolatey"), "Chocolatey temp cache", 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])
|
|
}
|