From 525f96f839e73806b5037d4edc3e5488ab9cef98 Mon Sep 17 00:00:00 2001 From: Alec Thomas Date: Sat, 7 Feb 2026 09:27:17 +1100 Subject: [PATCH] fix: use Kong config loader for HCL globals `hcl.Unmarshal()` was overwriting envars/flags, with this approach the HCL is correctly integrated into the CLI parsing. --- cachew.hcl | 2 +- cmd/cachewd/main.go | 105 ++++++++++++++++++------------- internal/config/kong.go | 119 +++++++++++++++++++++++++++++++++++ internal/config/kong_test.go | 110 ++++++++++++++++++++++++++++++++ 4 files changed, 293 insertions(+), 43 deletions(-) create mode 100644 internal/config/kong.go create mode 100644 internal/config/kong_test.go diff --git a/cachew.hcl b/cachew.hcl index 90c5daf..6cfa596 100644 --- a/cachew.hcl +++ b/cachew.hcl @@ -8,7 +8,7 @@ # } url = "http://127.0.0.1:8080" -logging { +log { level = "debug" } diff --git a/cmd/cachewd/main.go b/cmd/cachewd/main.go index 8a1f5c8..be34db2 100644 --- a/cmd/cachewd/main.go +++ b/cmd/cachewd/main.go @@ -32,35 +32,68 @@ type GlobalConfig struct { Bind string `hcl:"bind" default:"127.0.0.1:8080" help:"Bind address for the server."` URL string `hcl:"url" default:"http://127.0.0.1:8080/" help:"Base URL for cachewd."` SchedulerConfig jobscheduler.Config `embed:"" hcl:"scheduler,block" prefix:"scheduler-"` - LoggingConfig logging.Config `embed:"" hcl:"logging,block" prefix:"log-"` + LoggingConfig logging.Config `embed:"" hcl:"log,block" prefix:"log-"` MetricsConfig metrics.Config `embed:"" hcl:"metrics,block" prefix:"metrics-"` } var cli struct { Schema bool `help:"Print the configuration file schema." xor:"command"` - Config *os.File `hcl:"-" help:"Configuration file path." placeholder:"PATH" required:"" default:"cachew.hcl"` + Config kong.ConfigFlag `hcl:"-" help:"Configuration file path." placeholder:"PATH" required:"" default:"cachew.hcl"` // GlobalConfig accepts command-line, but can also be parsed from HCL. GlobalConfig } func main() { - kctx := kong.Parse(&cli, kong.DefaultEnvars("CACHEW")) + kctx := kong.Parse(&cli, kong.DefaultEnvars("CACHEW"), kong.Configuration(config.KongLoader[GlobalConfig], "cachew.hcl")) - ast, err := hcl.Parse(cli.Config) + configReader, err := os.Open(string(cli.Config)) kctx.FatalIfErrorf(err) + defer configReader.Close() - globalConfig, providersConfig := config.Split[GlobalConfig](ast) - - err = hcl.UnmarshalAST(globalConfig, &cli.GlobalConfig) + ast, err := hcl.Parse(configReader) kctx.FatalIfErrorf(err) + _, providersConfig := config.Split[GlobalConfig](ast) + ctx := context.Background() logger, ctx := logging.Configure(ctx, cli.LoggingConfig) scheduler := jobscheduler.New(ctx, cli.SchedulerConfig) + cr, sr := newRegistries(scheduler) + + // Commands + switch { //nolint:gocritic + case cli.Schema: + printSchema(kctx, cr, sr) + return + } + + mux, err := newMux(ctx, cr, sr, providersConfig) + kctx.FatalIfErrorf(err) + + metricsClient, err := metrics.New(ctx, cli.MetricsConfig) + kctx.FatalIfErrorf(err, "failed to create metrics client") + defer func() { + if err := metricsClient.Close(); err != nil { + logger.ErrorContext(ctx, "failed to close metrics client", "error", err) + } + }() + + if err := metricsClient.ServeMetrics(ctx); err != nil { + kctx.FatalIfErrorf(err, "failed to start metrics server") + } + + logger.InfoContext(ctx, "Starting cachewd", slog.String("bind", cli.Bind)) + + server := newServer(ctx, logger, mux) + err = server.ListenAndServe() + kctx.FatalIfErrorf(err) +} + +func newRegistries(scheduler jobscheduler.Scheduler) (*cache.Registry, *strategy.Registry) { cr := cache.NewRegistry() cache.RegisterMemory(cr) cache.RegisterDisk(cr) @@ -75,28 +108,28 @@ func main() { git.Register(sr, scheduler) gomod.Register(sr) - // Commands - switch { //nolint:gocritic - case cli.Schema: - schema := config.Schema[GlobalConfig](cr, sr) - slices.SortStableFunc(schema.Entries, func(a, b hcl.Entry) int { - return strings.Compare(a.EntryKey(), b.EntryKey()) - }) - text, err := hcl.MarshalAST(schema) - kctx.FatalIfErrorf(err) + return cr, sr +} - if fileInfo, err := os.Stdout.Stat(); err == nil && (fileInfo.Mode()&os.ModeCharDevice) != 0 { - err = quick.Highlight(os.Stdout, string(text), "terraform", "terminal256", "solarized") - kctx.FatalIfErrorf(err) - } else { - fmt.Printf("%s\n", text) //nolint:forbidigo - } - return +func printSchema(kctx *kong.Context, cr *cache.Registry, sr *strategy.Registry) { + schema := config.Schema[GlobalConfig](cr, sr) + slices.SortStableFunc(schema.Entries, func(a, b hcl.Entry) int { + return strings.Compare(a.EntryKey(), b.EntryKey()) + }) + text, err := hcl.MarshalAST(schema) + kctx.FatalIfErrorf(err) + + if fileInfo, err := os.Stdout.Stat(); err == nil && (fileInfo.Mode()&os.ModeCharDevice) != 0 { + err = quick.Highlight(os.Stdout, string(text), "terraform", "terminal256", "solarized") + kctx.FatalIfErrorf(err) + } else { + fmt.Printf("%s\n", text) //nolint:forbidigo } +} +func newMux(ctx context.Context, cr *cache.Registry, sr *strategy.Registry, providersConfig *hcl.AST) (*http.ServeMux, error) { mux := http.NewServeMux() - // Health check endpoints mux.HandleFunc("GET /_liveness", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("OK")) //nolint:errcheck @@ -107,23 +140,14 @@ func main() { _, _ = w.Write([]byte("OK")) //nolint:errcheck }) - err = config.Load(ctx, cr, sr, providersConfig, mux, parseEnvars()) - kctx.FatalIfErrorf(err) - - metricsClient, err := metrics.New(ctx, cli.MetricsConfig) - kctx.FatalIfErrorf(err, "failed to create metrics client") - defer func() { - if err := metricsClient.Close(); err != nil { - logger.ErrorContext(ctx, "failed to close metrics client", "error", err) - } - }() - - if err := metricsClient.ServeMetrics(ctx); err != nil { - kctx.FatalIfErrorf(err, "failed to start metrics server") + if err := config.Load(ctx, cr, sr, providersConfig, mux, parseEnvars()); err != nil { + return nil, fmt.Errorf("load config: %w", err) } - logger.InfoContext(ctx, "Starting cachewd", slog.String("bind", cli.Bind)) + return mux, nil +} +func newServer(ctx context.Context, logger *slog.Logger, mux *http.ServeMux) *http.Server { var handler http.Handler = mux handler = otelhttp.NewMiddleware(cli.MetricsConfig.ServiceName, @@ -133,7 +157,7 @@ func main() { handler = httputil.LoggingMiddleware(handler) - server := &http.Server{ + return &http.Server{ Addr: cli.Bind, Handler: handler, ReadTimeout: 30 * time.Minute, @@ -146,9 +170,6 @@ func main() { return logging.ContextWithLogger(ctx, logger.With("client", c.RemoteAddr().String())) }, } - - err = server.ListenAndServe() - kctx.FatalIfErrorf(err) } func parseEnvars() map[string]string { diff --git a/internal/config/kong.go b/internal/config/kong.go new file mode 100644 index 0000000..e529b39 --- /dev/null +++ b/internal/config/kong.go @@ -0,0 +1,119 @@ +package config + +import ( + "fmt" + "io" + "strings" + + "github.com/alecthomas/hcl/v2" + "github.com/alecthomas/kong" +) + +func KongLoader[GlobalConfig any](r io.Reader) (kong.Resolver, error) { + ast, err := hcl.Parse(r) + if err != nil { + return nil, fmt.Errorf("failed to parse HCL: %w", err) + } + return &kongResolver{flattenHCL(ast)}, nil +} + +type kongResolver struct { + values map[string]any +} + +var _ kong.Resolver = (*kongResolver)(nil) + +func (k *kongResolver) Resolve(_ *kong.Context, _ *kong.Path, flag *kong.Flag) (any, error) { + name := strings.ReplaceAll(flag.Name, "-", "_") + if v, ok := k.values[name]; ok { + return v, nil + } + if v, ok := k.values[flag.Name]; ok { + return v, nil + } + return nil, nil //nolint:nilnil +} + +func (k *kongResolver) Validate(_ *kong.Application) error { return nil } + +// Convert HCL AST to a flattened map of key to value. Each hierarchy in the HCL joined by "-". +// +// eg. +// +// block { +// value = "foo" +// } +// +// block-with-label label { +// value = "foo" +// } +// +// Would flatten to: +// +// block-value = "foo" +// block-with-label-label = "foo" +func flattenHCL(node hcl.Node) map[string]any { + out := map[string]any{} + flattenNode(out, "", node) + return out +} + +func flattenNode(out map[string]any, prefix string, node hcl.Node) { + switch node := node.(type) { + case *hcl.AST: + for _, entry := range node.Entries { + flattenNode(out, prefix, entry) + } + + case *hcl.Block: + parts := make([]string, 0, 1+len(node.Labels)) + parts = append(parts, node.Name) + parts = append(parts, node.Labels...) + key := strings.Join(parts, "-") + if prefix != "" { + key = prefix + "-" + key + } + for _, entry := range node.Body { + flattenNode(out, key, entry) + } + + case *hcl.Attribute: + key := node.Key + if prefix != "" { + key = prefix + "-" + key + } + out[key] = hclValue(node.Value) + } +} + +func hclValue(v hcl.Value) any { + switch v := v.(type) { + case *hcl.String: + return v.Str + case *hcl.Number: + if v.Float.IsInt() { + i, _ := v.Float.Int64() + return i + } + f, _ := v.Float.Float64() + return f + case *hcl.Bool: + return v.Bool + case *hcl.List: + out := make([]any, len(v.List)) + for i, item := range v.List { + out[i] = hclValue(item) + } + return out + case *hcl.Map: + out := map[string]any{} + for _, entry := range v.Entries { + out[fmt.Sprintf("%v", hclValue(entry.Key))] = hclValue(entry.Value) + } + return out + case *hcl.Heredoc: + return v.GetHeredoc() + default: + return nil + } +} diff --git a/internal/config/kong_test.go b/internal/config/kong_test.go new file mode 100644 index 0000000..239ebd4 --- /dev/null +++ b/internal/config/kong_test.go @@ -0,0 +1,110 @@ +package config //nolint:testpackage + +import ( + "strings" + "testing" + + "github.com/alecthomas/assert/v2" + "github.com/alecthomas/hcl/v2" +) + +func TestFlattenHCL(t *testing.T) { + tests := []struct { + name string + input string + expected map[string]any + }{ + { + name: "SimpleAttribute", + input: `value = "foo"`, + expected: map[string]any{ + "value": "foo", + }, + }, + { + name: "Block", + input: `block { + value = "foo" + }`, + expected: map[string]any{ + "block-value": "foo", + }, + }, + { + name: "BlockWithLabel", + input: `block-with-label label { + value = "foo" + }`, + expected: map[string]any{ + "block-with-label-label-value": "foo", + }, + }, + { + name: "NestedBlocks", + input: `outer { + inner { + value = "foo" + } + }`, + expected: map[string]any{ + "outer-inner-value": "foo", + }, + }, + { + name: "NumberInt", + input: `count = 42`, + expected: map[string]any{ + "count": int64(42), + }, + }, + { + name: "NumberFloat", + input: `ratio = 3.14`, + expected: map[string]any{ + "ratio": 3.14, + }, + }, + { + name: "Bool", + input: `enabled = true`, + expected: map[string]any{ + "enabled": true, + }, + }, + { + name: "List", + input: `tags = ["a", "b", "c"]`, + expected: map[string]any{ + "tags": []any{"a", "b", "c"}, + }, + }, + { + name: "Map", + input: `labels = {x: 1, y: 2}`, + expected: map[string]any{ + "labels": map[string]any{"x": int64(1), "y": int64(2)}, + }, + }, + { + name: "MultipleEntries", + input: ` + name = "test" + block { + port = 8080 + } + `, + expected: map[string]any{ + "name": "test", + "block-port": int64(8080), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ast, err := hcl.Parse(strings.NewReader(tt.input)) + assert.NoError(t, err) + actual := flattenHCL(ast) + assert.Equal(t, tt.expected, actual) + }) + } +}