From cdc856a6b1477b255a12bde8b4a76d772ba78d5a Mon Sep 17 00:00:00 2001 From: Alec Thomas Date: Fri, 27 Mar 2026 15:28:21 +1100 Subject: [PATCH] refactor: require "cache"/"strategy" prefix on config blocks Cache backends must now use `cache { ... }` and strategy implementations must use `strategy { ... }` instead of bare block names. This makes the config file self-documenting and removes the ambiguous fallthrough from cache registry to strategy registry. Co-Authored-By: Claude Opus 4.6 (1M context) --- cachew.hcl | 32 ++++++++++------------ internal/cache/api.go | 9 +++++- internal/config/config.go | 50 ++++++++++++++++++++++++++-------- internal/config/config_test.go | 42 ++++++++++++++++++++++++++++ internal/strategy/api.go | 9 +++++- 5 files changed, 111 insertions(+), 31 deletions(-) diff --git a/cachew.hcl b/cachew.hcl index 9fa82a1e..aad29db4 100644 --- a/cachew.hcl +++ b/cachew.hcl @@ -1,5 +1,5 @@ # Artifactory caching proxy strategy -# artifactory "example.jfrog.io" { +# strategy artifactory "example.jfrog.io" { # target = "https://example.jfrog.io" # } @@ -13,15 +13,11 @@ opa { policy = < { ... }" block. func (r *Registry) Schema() *hcl.AST { ast := &hcl.AST{} for _, entry := range r.registry { - ast.Entries = append(ast.Entries, entry.schema) + wrapped := &hcl.Block{ + Name: "cache", + Labels: append([]string{entry.schema.Name}, entry.schema.Labels...), + Body: entry.schema.Body, + Comments: entry.schema.Comments, + } + ast.Entries = append(ast.Entries, wrapped) } return ast } diff --git a/internal/config/config.go b/internal/config/config.go index 67792f23..02e838e3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -87,6 +87,22 @@ func Split[GlobalConfig any](ast *hcl.AST) (global, providers *hcl.AST) { return global, providers } +// unwrapBlock extracts the registry name from a prefixed block (e.g. "cache disk { ... }") +// and returns a copy of the block with Name set to the first label and remaining labels preserved. +func unwrapBlock(block *hcl.Block) (name string, inner *hcl.Block, err error) { + if len(block.Labels) == 0 { + return "", nil, errors.Errorf("%s: %s block requires a name label", block.Pos, block.Name) + } + inner = &hcl.Block{ + Pos: block.Pos, + Name: block.Labels[0], + Labels: block.Labels[1:], + Body: block.Body, + Comments: block.Comments, + } + return block.Labels[0], inner, nil +} + // Load HCL configuration and use that to construct the cache backend, and proxy strategies. // It returns an http.Handler that wraps mux — any loaded strategies that implement // strategy.Interceptor are applied as middleware before ServeMux route matching, so @@ -107,22 +123,34 @@ func Load( {Name: "apiv1"}, } - // First pass, instantiate caches + // First pass: collect cache backends and strategy candidates from prefixed blocks. var caches []cache.Cache for _, node := range ast.Entries { - switch node := node.(type) { - case *hcl.Block: - c, err := cr.Create(ctx, node.Name, node, vars) - if errors.Is(err, cache.ErrNotFound) { - strategyCandidates = append(strategyCandidates, node) - continue - } else if err != nil { - return nil, errors.Errorf("%s: %w", node.Pos, err) + block, ok := node.(*hcl.Block) + if !ok { + return nil, errors.Errorf("%s: attributes are not allowed", node.Position()) + } + switch block.Name { + case "cache": + name, inner, err := unwrapBlock(block) + if err != nil { + return nil, err + } + c, err := cr.Create(ctx, name, inner, vars) + if err != nil { + return nil, errors.Errorf("%s: %w", block.Pos, err) } caches = append(caches, c) - case *hcl.Attribute: - return nil, errors.Errorf("%s: attributes are not allowed", node.Pos) + case "strategy": + _, inner, err := unwrapBlock(block) + if err != nil { + return nil, err + } + strategyCandidates = append(strategyCandidates, inner) + + default: + return nil, errors.Errorf("%s: unknown block %q (expected \"cache\" or \"strategy\")", block.Pos, block.Name) } } if len(caches) == 0 { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index ef0ad9db..4ef228e3 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -8,6 +8,48 @@ import ( "github.com/alecthomas/hcl/v2" ) +func TestUnwrapBlock(t *testing.T) { + tests := []struct { + name string + block *hcl.Block + expectedName string + expectedLabels []string + expectedErr string + }{ + { + name: "SimpleBlock", + block: &hcl.Block{Name: "cache", Labels: []string{"disk"}}, + expectedName: "disk", + expectedLabels: []string{}, + }, + { + name: "BlockWithExtraLabels", + block: &hcl.Block{Name: "strategy", Labels: []string{"host", "https://ghcr.io"}}, + expectedName: "host", + expectedLabels: []string{"https://ghcr.io"}, + }, + { + name: "MissingLabel", + block: &hcl.Block{Name: "cache"}, + expectedErr: "cache block requires a name label", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + name, inner, err := unwrapBlock(tt.block) + if tt.expectedErr != "" { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedErr) + return + } + assert.NoError(t, err) + assert.Equal(t, tt.expectedName, name) + assert.Equal(t, tt.expectedName, inner.Name) + assert.Equal(t, tt.expectedLabels, inner.Labels) + }) + } +} + func TestInjectEnvars(t *testing.T) { type Scheduler struct { Concurrency int `hcl:"concurrency"` diff --git a/internal/strategy/api.go b/internal/strategy/api.go index 4bd59f8e..f5ece821 100644 --- a/internal/strategy/api.go +++ b/internal/strategy/api.go @@ -62,10 +62,17 @@ func Register[Config any, S Strategy](r *Registry, id, description string, facto } // Schema returns the schema for all registered strategies. +// Each entry is wrapped as a "strategy { ... }" block. func (r *Registry) Schema() *hcl.AST { ast := &hcl.AST{} for _, entry := range r.registry { - ast.Entries = append(ast.Entries, entry.schema) + wrapped := &hcl.Block{ + Name: "strategy", + Labels: append([]string{entry.schema.Name}, entry.schema.Labels...), + Body: entry.schema.Body, + Comments: entry.schema.Comments, + } + ast.Entries = append(ast.Entries, wrapped) } return ast }