go-build/pkg/build/cache.go
Virgil 9e09f022a9 feat(build): wire cache paths into go builds
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 12:59:08 +00:00

261 lines
6.4 KiB
Go

// Package build provides project type detection and cross-compilation for the Core build system.
// This file handles build cache configuration and key generation.
package build
import (
"crypto/sha256"
"encoding/hex"
"sort"
"strconv"
"strings"
"dappco.re/go/core"
"dappco.re/go/core/build/internal/ax"
"dappco.re/go/core/io"
coreerr "dappco.re/go/core/log"
"gopkg.in/yaml.v3"
)
// CacheConfig holds build cache configuration loaded from .core/build.yaml.
//
// cfg := build.CacheConfig{
// Enabled: true,
// Directory: ".core/cache",
// Paths: []string{"~/.cache/go-build", "~/go/pkg/mod"},
// }
type CacheConfig struct {
// Enabled turns cache setup on for the build.
Enabled bool `yaml:"enabled"`
// Directory is where cache metadata is stored.
Directory string `yaml:"dir,omitempty"`
// KeyPrefix prefixes the generated cache key.
KeyPrefix string `yaml:"key_prefix,omitempty"`
// Paths are cache directories that should exist before the build starts.
Paths []string `yaml:"paths,omitempty"`
// RestoreKeys are fallback prefixes used when the exact cache key is not present.
RestoreKeys []string `yaml:"restore_keys,omitempty"`
}
// UnmarshalYAML accepts both the concise build config keys and the longer aliases.
//
// err := yaml.Unmarshal([]byte("dir: .core/cache"), &cfg)
func (c *CacheConfig) UnmarshalYAML(value *yaml.Node) error {
type rawCacheConfig struct {
Enabled bool `yaml:"enabled"`
Directory string `yaml:"directory"`
Dir string `yaml:"dir"`
KeyPrefix string `yaml:"key_prefix"`
Key string `yaml:"key"`
Paths []string `yaml:"paths"`
RestoreKeys []string `yaml:"restore_keys"`
}
var raw rawCacheConfig
if err := value.Decode(&raw); err != nil {
return err
}
c.Enabled = raw.Enabled
c.Directory = firstNonEmpty(raw.Directory, raw.Dir)
c.KeyPrefix = firstNonEmpty(raw.KeyPrefix, raw.Key)
c.Paths = raw.Paths
c.RestoreKeys = raw.RestoreKeys
return nil
}
// SetupCache normalises cache paths and ensures the cache directories exist.
//
// err := build.SetupCache(io.Local, ".", &build.CacheConfig{
// Enabled: true,
// Paths: []string{"~/.cache/go-build", "~/go/pkg/mod"},
// })
func SetupCache(fs io.Medium, dir string, cfg *CacheConfig) error {
if fs == nil || cfg == nil || !cfg.Enabled {
return nil
}
if cfg.Directory == "" {
cfg.Directory = ax.Join(dir, ConfigDir, "cache")
}
cfg.Directory = normaliseCachePath(dir, cfg.Directory)
if err := fs.EnsureDir(cfg.Directory); err != nil {
return coreerr.E("build.SetupCache", "failed to create cache directory", err)
}
normalisedPaths := make([]string, 0, len(cfg.Paths))
for _, path := range cfg.Paths {
path = normaliseCachePath(dir, path)
if path == "" {
continue
}
if err := fs.EnsureDir(path); err != nil {
return coreerr.E("build.SetupCache", "failed to create cache path "+path, err)
}
normalisedPaths = append(normalisedPaths, path)
}
cfg.Paths = deduplicateStrings(normalisedPaths)
return nil
}
// SetupBuildCache prepares the cache configuration stored on a build config.
//
// err := build.SetupBuildCache(io.Local, ".", cfg)
func SetupBuildCache(fs io.Medium, dir string, cfg *BuildConfig) error {
if fs == nil || cfg == nil {
return nil
}
return SetupCache(fs, dir, &cfg.Build.Cache)
}
// CacheKey returns a deterministic cache key for the build configuration and target.
//
// key := build.CacheKey("core-build", build.Target{OS: "linux", Arch: "amd64"}, &build.CacheConfig{
// KeyPrefix: "main",
// })
func CacheKey(buildName string, target Target, cfg *CacheConfig) string {
if buildName == "" {
buildName = "build"
}
keyPrefix := buildName
if cfg != nil && cfg.KeyPrefix != "" {
keyPrefix = cfg.KeyPrefix
}
snapshot := cacheKeySnapshot(buildName, target, cfg)
sum := sha256.Sum256([]byte(snapshot))
suffix := hex.EncodeToString(sum[:])[:12]
return core.Join("-", keyPrefix, target.OS, target.Arch, suffix)
}
// CacheEnvironment returns environment variables derived from the cache config.
//
// env := build.CacheEnvironment(&build.CacheConfig{Enabled: true, Paths: []string{"/tmp/go-build"}})
func CacheEnvironment(cfg *CacheConfig) []string {
if cfg == nil || !cfg.Enabled {
return nil
}
var env []string
for _, path := range cfg.Paths {
switch cacheEnvironmentName(path) {
case "GOCACHE":
env = appendIfMissing(env, "GOCACHE="+path)
case "GOMODCACHE":
env = appendIfMissing(env, "GOMODCACHE="+path)
}
}
return deduplicateStrings(env)
}
func cacheKeySnapshot(buildName string, target Target, cfg *CacheConfig) string {
parts := []string{
"build",
buildName,
target.OS,
target.Arch,
}
if cfg == nil {
return core.Join("\n", parts...)
}
parts = append(parts,
strconv.FormatBool(cfg.Enabled),
cfg.Directory,
cfg.KeyPrefix,
)
paths := deduplicateStrings(append([]string(nil), cfg.Paths...))
sort.Strings(paths)
parts = append(parts, "paths:"+core.Join(",", paths...))
restoreKeys := deduplicateStrings(append([]string(nil), cfg.RestoreKeys...))
sort.Strings(restoreKeys)
parts = append(parts, "restore:"+core.Join(",", restoreKeys...))
return core.Join("\n", parts...)
}
func cacheEnvironmentName(path string) string {
base := strings.ToLower(ax.Base(path))
switch base {
case "go-build", "gocache":
return "GOCACHE"
case "go-mod", "gomodcache":
return "GOMODCACHE"
default:
return ""
}
}
func appendIfMissing(values []string, value string) []string {
for _, current := range values {
if current == value {
return values
}
}
return append(values, value)
}
func normaliseCachePath(baseDir, path string) string {
path = strings.TrimSpace(path)
if path == "" {
return ""
}
if strings.HasPrefix(path, "~") {
home := core.Env("HOME")
if home != "" {
if path == "~" {
return ax.Clean(home)
}
if strings.HasPrefix(path, "~/") {
return ax.Join(home, strings.TrimPrefix(path, "~/"))
}
}
}
if ax.IsAbs(path) {
return ax.Clean(path)
}
return ax.Join(baseDir, path)
}
func deduplicateStrings(values []string) []string {
if len(values) == 0 {
return values
}
seen := make(map[string]struct{}, len(values))
result := make([]string, 0, len(values))
for _, value := range values {
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
return result
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}