go-build/pkg/build/cache.go
2026-04-15 22:27:49 +01:00

402 lines
10 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"
"os"
"dappco.re/go/build/internal/ax"
"dappco.re/go/core"
"dappco.re/go/core/io"
coreerr "dappco.re/go/core/log"
"gopkg.in/yaml.v3"
)
// DefaultCacheDirectory is the project-local cache metadata directory used when
// no cache directory is supplied.
//
// cfg := build.CacheConfig{Enabled: true}
// // SetupCache(io.Local, ".", &cfg) -> ".core/cache"
const DefaultCacheDirectory = ".core/cache"
// DefaultProcessCacheDirectory is the RFC-documented cache directory used by
// the single-argument SetupCache form when only environment wiring is needed.
const DefaultProcessCacheDirectory = "~/.cache/core-build"
// DefaultBuildCachePaths returns the project-local Go cache directories used
// when no cache paths are configured.
//
// paths := build.DefaultBuildCachePaths("/workspace/project")
// // ["/workspace/project/cache/go-build", "/workspace/project/cache/go-mod"]
func DefaultBuildCachePaths(baseDir string) []string {
if core.Trim(baseDir) == "" {
return []string{
"cache/go-build",
"cache/go-mod",
}
}
return []string{
ax.Join(baseDir, "cache", "go-build"),
ax.Join(baseDir, "cache", "go-mod"),
}
}
// 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 `json:"enabled" yaml:"enabled"`
// Dir is where cache metadata is stored.
Dir string `json:"dir,omitempty" yaml:"dir,omitempty"`
// Directory is the deprecated alias for Dir.
Directory string `json:"-" yaml:"-"`
// KeyPrefix prefixes the generated cache key.
KeyPrefix string `json:"key_prefix,omitempty" yaml:"key_prefix,omitempty"`
// Paths are cache directories that should exist before the build starts.
Paths []string `json:"paths,omitempty" yaml:"paths,omitempty"`
// RestoreKeys are fallback prefixes used when the exact cache key is not present.
RestoreKeys []string `json:"restore_keys,omitempty" yaml:"restore_keys,omitempty"`
}
// MarshalYAML emits the documented cache configuration shape with the Dir field.
//
// data, err := yaml.Marshal(build.CacheConfig{Enabled: true, Dir: ".core/cache"})
func (c CacheConfig) MarshalYAML() (any, error) {
type rawCacheConfig struct {
Enabled bool `yaml:"enabled"`
Dir string `yaml:"dir,omitempty"`
KeyPrefix string `yaml:"key_prefix,omitempty"`
Paths []string `yaml:"paths,omitempty"`
RestoreKeys []string `yaml:"restore_keys,omitempty"`
}
return rawCacheConfig{
Enabled: c.Enabled,
Dir: c.effectiveDirectory(),
KeyPrefix: c.KeyPrefix,
Paths: c.Paths,
RestoreKeys: c.RestoreKeys,
}, nil
}
// 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.Dir = firstNonEmpty(raw.Dir, raw.Directory)
c.Directory = c.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.
//
// The canonical form is the 3-argument variant:
//
// err := build.SetupCache(io.Local, ".", &build.CacheConfig{
// Enabled: true,
// Paths: []string{"~/.cache/go-build", "~/go/pkg/mod"},
// })
//
// A compatibility 1-argument form is also supported for the RFC-shaped API:
//
// err := build.SetupCache(build.CacheConfig{Enabled: true})
func SetupCache(args ...any) error {
switch len(args) {
case 1:
cfg, ok := cacheConfigArg(args[0])
if !ok || cfg == nil || !cfg.Enabled {
return nil
}
// The single-argument form configures the process environment for callers
// that only need cache wiring and do not have a filesystem/project root.
if cfg.effectiveDirectory() == "" {
cfg.Dir = DefaultProcessCacheDirectory
cfg.Directory = DefaultProcessCacheDirectory
}
if len(cfg.Paths) == 0 {
cfg.Paths = []string{"~/.cache/go-build", "~/go/pkg/mod"}
}
applyCacheEnvironment(cfg)
return nil
case 3:
fs, _ := args[0].(io.Medium)
dir, _ := args[1].(string)
cfg, ok := args[2].(*CacheConfig)
if !ok {
return coreerr.E("build.SetupCache", "third argument must be *CacheConfig", nil)
}
return setupCacheWithMedium(fs, dir, cfg)
default:
return coreerr.E("build.SetupCache", "expected 1 or 3 arguments", nil)
}
}
func cacheConfigArg(arg any) (*CacheConfig, bool) {
switch cfg := arg.(type) {
case CacheConfig:
return &cfg, true
case *CacheConfig:
return cfg, true
default:
return nil, false
}
}
func setupCacheWithMedium(fs io.Medium, dir string, cfg *CacheConfig) error {
if fs == nil || cfg == nil || !cfg.Enabled {
return nil
}
directory := cfg.effectiveDirectory()
if directory == "" {
directory = ax.Join(dir, DefaultCacheDirectory)
}
directory = normaliseCachePath(dir, directory)
cfg.Dir = directory
cfg.Directory = directory
if len(cfg.Paths) == 0 {
cfg.Paths = DefaultBuildCachePaths(dir)
}
if err := fs.EnsureDir(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 setupCacheWithMedium(fs, dir, &cfg.Build.Cache)
}
// CacheKey returns a deterministic cache key from go.sum, go.work.sum, and the target platform.
//
// key := build.CacheKey(io.Local, ".", "linux", "amd64") // "go-linux-amd64-abc123..."
func CacheKey(fs io.Medium, dir, goos, goarch string) string {
var seed []byte
if fs != nil {
for _, name := range []string{"go.sum", "go.work.sum"} {
if content, err := fs.Read(ax.Join(dir, name)); err == nil {
seed = append(seed, content...)
seed = append(seed, '\n')
}
}
if len(seed) == 0 {
seed = append(seed, '\n')
}
}
seed = append(seed, goos...)
seed = append(seed, '\n')
seed = append(seed, goarch...)
sum := sha256.Sum256(seed)
suffix := hex.EncodeToString(sum[:])[:12]
return core.Join("-", "go", goos, goarch, suffix)
}
// CacheKeyWithConfig returns a deterministic cache key and applies the optional
// cache key prefix from configuration.
//
// key := build.CacheKeyWithConfig(io.Local, ".", "linux", "amd64", &cfg.Cache)
// // "demo-go-linux-amd64-abc123..."
func CacheKeyWithConfig(fs io.Medium, dir, goos, goarch string, cfg *CacheConfig) string {
key := CacheKey(fs, dir, goos, goarch)
if cfg == nil {
return key
}
prefix := core.Trim(cfg.KeyPrefix)
if prefix == "" {
return key
}
return core.Join("-", prefix, key)
}
// CacheRestoreKeys returns the configured restore-key prefixes in stable order.
//
// keys := build.CacheRestoreKeys(&build.CacheConfig{
// KeyPrefix: "demo",
// RestoreKeys: []string{"go-", "core-"},
// })
// // ["demo", "go-", "core-"]
func CacheRestoreKeys(cfg *CacheConfig) []string {
if cfg == nil {
return nil
}
keys := make([]string, 0, 1+len(cfg.RestoreKeys))
if prefix := core.Trim(cfg.KeyPrefix); prefix != "" {
keys = append(keys, prefix)
}
keys = append(keys, cfg.RestoreKeys...)
return deduplicateStrings(keys)
}
// 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 cacheEnvironmentName(path string) string {
base := core.Lower(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 applyCacheEnvironment(cfg *CacheConfig) {
for _, env := range CacheEnvironment(cfg) {
parts := core.SplitN(env, "=", 2)
if len(parts) != 2 {
continue
}
_ = os.Setenv(parts[0], parts[1])
}
}
func normaliseCachePath(baseDir, path string) string {
path = core.Trim(path)
if path == "" {
return ""
}
if core.HasPrefix(path, "~") {
home := core.Env("HOME")
if home != "" {
if path == "~" {
return ax.Clean(home)
}
if core.HasPrefix(path, "~/") {
return ax.Join(home, core.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 core.Trim(value) != "" {
return value
}
}
return ""
}
func (c CacheConfig) effectiveDirectory() string {
if core.Trim(c.Dir) != "" {
return c.Dir
}
return c.Directory
}