diff --git a/api/cache.go b/api/cache.go index 718a0725c4..627e76faa5 100644 --- a/api/cache.go +++ b/api/cache.go @@ -56,10 +56,11 @@ type CacheBlob struct { // CacheEntryCreateReq is the request body for creating (storing) a cache entry. type CacheEntryCreateReq struct { - TargetPaths []string `json:"target_paths"` - CacheKey []CacheKeyPart `json:"cache_key"` - Blobs []CacheBlob `json:"blobs"` - Platform string `json:"platform"` + TargetPaths []string `json:"target_paths"` + CacheKey []CacheKeyPart `json:"cache_key"` + Blobs []CacheBlob `json:"blobs"` + Platform string `json:"platform"` + Scopes map[string]bool `json:"scopes"` } // CacheEntryCreateResp describes how to upload the new cache entry. The storage diff --git a/internal/cache/client.go b/internal/cache/client.go index c24f8ff2cc..390f16cf42 100644 --- a/internal/cache/client.go +++ b/internal/cache/client.go @@ -170,6 +170,10 @@ func (c *client) resolveCacheKey(cacheConfig *configuration.Cache) ([]api.CacheK return configuration.ResolveCacheKey(cacheConfig.CacheKey, nil) } +func (c *client) resolveSaveScopes(cacheConfig *configuration.Cache) map[string]bool { + return configuration.ResolveSaveScopes(cacheConfig.SaveScopes) +} + // ProgressCallback is invoked during Save and Restore to report progress. // // Implementations must be thread-safe; the callback may be called from diff --git a/internal/cache/configuration/cache.go b/internal/cache/configuration/cache.go index de0a8e6a27..05dc7c0678 100644 --- a/internal/cache/configuration/cache.go +++ b/internal/cache/configuration/cache.go @@ -13,6 +13,8 @@ type Cache struct { CacheKey []KeyPart `yaml:"cache_key"` // Target Paths to remove. TargetPaths []string `yaml:"target_paths"` + // SaveScopes are the scopes to save the entry under + SaveScopes map[string]bool `yaml:"save_scopes"` } // Validate validates the cache configuration and returns an error if invalid. @@ -61,6 +63,8 @@ func (c Cache) Validate() error { } } + errors = c.validateSaveScopes(errors) + if len(errors) > 0 { return fmt.Errorf("cache validation failed for name '%s': %s", c.Name, strings.Join(errors, "; ")) } diff --git a/internal/cache/configuration/cache_test.go b/internal/cache/configuration/cache_test.go index 2ea1fbc13c..612a1b115b 100644 --- a/internal/cache/configuration/cache_test.go +++ b/internal/cache/configuration/cache_test.go @@ -147,6 +147,57 @@ func TestCacheValidate(t *testing.T) { wantErr: true, errMsg: "is duplicated", }, + { + name: "valid save scopes", + cache: Cache{ + Name: "valid_id", + CacheKey: literalKey, + TargetPaths: []string{"node_modules"}, + SaveScopes: map[string]bool{ + ScopePipeline: true, + ScopeBranch: true, + ScopeBuildID: true, + }, + }, + wantErr: false, + }, + { + name: "unknown save scopes", + cache: Cache{ + Name: "valid_id", + CacheKey: literalKey, + TargetPaths: []string{"node_modules"}, + SaveScopes: map[string]bool{ + ScopePipeline: true, + ScopeBranch: true, + "foo": true, + }, + }, + wantErr: true, + errMsg: "unsupported scope(s) 'foo'", + }, + { + name: "disabled but valid save scopes", + cache: Cache{ + Name: "valid_id", + CacheKey: literalKey, + TargetPaths: []string{"node_modules"}, + SaveScopes: map[string]bool{ + ScopeBranch: false, + }, + }, + wantErr: false, + }, + { + name: "empty save scopes", + cache: Cache{ + Name: "valid_id", + CacheKey: literalKey, + TargetPaths: []string{"node_modules"}, + SaveScopes: map[string]bool{}, + }, + wantErr: false, + }, } for _, tt := range tests { diff --git a/internal/cache/configuration/save_scopes.go b/internal/cache/configuration/save_scopes.go new file mode 100644 index 0000000000..44b5976f48 --- /dev/null +++ b/internal/cache/configuration/save_scopes.go @@ -0,0 +1,51 @@ +package configuration + +import ( + "fmt" + "sort" + "strings" +) + +const ( + ScopeBranch = "branch" + ScopeBuildID = "build" + ScopePipeline = "pipeline" +) + +var saveScopeEnvVars = map[string]string{ + ScopeBranch: "BUILDKITE_BRANCH", + ScopeBuildID: "BUILDKITE_BUILD_ID", + ScopePipeline: "BUILDKITE_PIPELINE_SLUG", +} + +// ResolveSaveScopes resolves the cache's save_scopes by +// preserving enabled scopes and removing disabled scopes. +// The result is always non-nil so the wire scopes object +// marshals to {} rather than null. +func ResolveSaveScopes(scopes map[string]bool) map[string]bool { + resolved := make(map[string]bool, len(scopes)) + for scope, enabled := range scopes { + if !enabled { + continue + } + resolved[scope] = enabled + } + return resolved +} + +func (c Cache) validateSaveScopes(errors []string) []string { + if len(c.SaveScopes) > 0 { + var unknown []string + for s := range c.SaveScopes { + if _, ok := saveScopeEnvVars[s]; !ok { + unknown = append(unknown, s) + } + } + + if len(unknown) > 0 { + sort.Strings(unknown) + errors = append(errors, fmt.Sprintf("save_scopes: unsupported scope(s) '%s' (supported: branch, build, pipeline)", strings.Join(unknown, ", "))) + } + } + return errors +} diff --git a/internal/cache/configuration/save_scopes_test.go b/internal/cache/configuration/save_scopes_test.go new file mode 100644 index 0000000000..e61571b7e4 --- /dev/null +++ b/internal/cache/configuration/save_scopes_test.go @@ -0,0 +1,81 @@ +package configuration + +import ( + "reflect" + "testing" +) + +func TestResolveSaveScopes(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + scopes map[string]bool + want map[string]bool + wantErr string + }{ + { + name: "nil scopes returns non-nil empty map", + scopes: nil, + want: map[string]bool{}, + }, + { + name: "empty scopes returns non-nil empty map", + scopes: map[string]bool{}, + want: map[string]bool{}, + }, + { + name: "all disabled returns non-nil empty map", + scopes: map[string]bool{ScopeBranch: false, ScopeBuildID: false, ScopePipeline: false}, + want: map[string]bool{}, + }, + { + name: "branch only", + scopes: map[string]bool{ScopeBranch: true}, + want: map[string]bool{ScopeBranch: true}, + }, + { + name: "build only", + scopes: map[string]bool{ScopeBuildID: true}, + want: map[string]bool{ScopeBuildID: true}, + }, + { + name: "pipeline only", + scopes: map[string]bool{ScopePipeline: true}, + want: map[string]bool{ScopePipeline: true}, + }, + { + name: "all enabled", + scopes: map[string]bool{ScopeBranch: true, ScopeBuildID: true, ScopePipeline: true}, + want: map[string]bool{ + ScopeBranch: true, + ScopeBuildID: true, + ScopePipeline: true, + }, + }, + { + name: "mixed enabled and disabled skips disabled", + scopes: map[string]bool{ScopeBranch: true, ScopeBuildID: true, ScopePipeline: false}, + want: map[string]bool{ + ScopeBranch: true, + ScopeBuildID: true, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := ResolveSaveScopes(tt.scopes) + + // Contract: always non-nil so the wire `scopes` object marshals to `{}`, never null. + if got == nil { + t.Fatal("ResolveSaveScopes() = nil, want non-nil map") + } + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("ResolveSaveScopes() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/cache/save.go b/internal/cache/save.go index f8ea211510..057241347e 100644 --- a/internal/cache/save.go +++ b/internal/cache/save.go @@ -86,11 +86,14 @@ func (c *client) Save(ctx context.Context, cacheID string) (SaveResult, error) { return result, fmt.Errorf("failed to resolve cache key: %w", err) } + scopes := c.resolveSaveScopes(cacheConfig) + span.SetAttributes( attribute.String("cache.id", cacheID), attribute.String("cache.registry", c.registry), attribute.Int("cache.target_paths_count", len(cacheConfig.TargetPaths)), attribute.Int("cache.key_parts_count", len(cacheKey)), + attribute.Int("cache.save_scopes_count", len(scopes)), ) c.callProgress(cacheID, "validating", "Validating cache configuration", 0, 0) @@ -244,6 +247,7 @@ func (c *client) Save(ctx context.Context, cacheID string) (SaveResult, error) { Compression: c.format, }}, Platform: c.platform, + Scopes: scopes, }) if api.BreakOnNonRetryable(r, createApiResp, err) { return err