Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions api/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions internal/cache/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions internal/cache/configuration/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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, "; "))
}
Expand Down
51 changes: 51 additions & 0 deletions internal/cache/configuration/cache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
51 changes: 51 additions & 0 deletions internal/cache/configuration/save_scopes.go
Original file line number Diff line number Diff line change
@@ -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
}
81 changes: 81 additions & 0 deletions internal/cache/configuration/save_scopes_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
4 changes: 4 additions & 0 deletions internal/cache/save.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down