2
0
Files
gitcaddy-runner/internal/pkg/cleanup/cleanup.go
logikonline 22f1ea6e76 chore(ui): update golangci-lint config and cleanup package docs
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.
2026-01-19 01:03:07 -05:00

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])
}