From 4bca199facb31653d3f509d0afe87d34a7c9b3ca Mon Sep 17 00:00:00 2001 From: Adam Babik Date: Wed, 1 May 2024 20:13:18 +0200 Subject: [PATCH] internal/config: replace viper with custom config loader --- go.mod | 13 +- go.sum | 24 ---- internal/config/autoconfig/autoconfig.go | 57 ++------ internal/config/autoconfig/autoconfig_test.go | 49 ++----- internal/config/loader.go | 112 +++++++++++++++ internal/config/loader_test.go | 132 ++++++++++++++++++ 6 files changed, 269 insertions(+), 118 deletions(-) create mode 100644 internal/config/loader.go create mode 100644 internal/config/loader_test.go diff --git a/go.mod b/go.mod index 44693348d..b9573cd89 100644 --- a/go.mod +++ b/go.mod @@ -33,7 +33,6 @@ require ( github.com/opencontainers/image-spec v1.1.0 github.com/rogpeppe/go-internal v1.12.0 github.com/rwtodd/Go.Sed v0.0.0-20230610052213-ba3e9c186f0a - github.com/spf13/afero v1.11.0 github.com/stateful/godotenv v0.0.0-20240309032207-c7bc0b812915 github.com/vektah/gqlparser/v2 v2.5.11 github.com/yuin/goldmark v1.7.0 @@ -59,24 +58,15 @@ require ( github.com/docker/go-connections v0.5.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/go-logr/logr v1.4.1 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/google/cel-go v0.20.1 // indirect - github.com/hashicorp/hcl v1.0.0 // indirect - github.com/magiconair/properties v1.8.7 // indirect - github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/moby/term v0.5.0 // indirect github.com/morikuni/aec v1.0.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect - github.com/sagikazarmark/locafero v0.4.0 // indirect - github.com/sagikazarmark/slog-shim v0.1.0 // indirect - github.com/sourcegraph/conc v0.3.0 // indirect - github.com/spf13/cast v1.6.0 // indirect github.com/stoewer/go-strcase v1.3.0 // indirect - github.com/subosito/gotenv v1.6.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect go.opentelemetry.io/otel v1.24.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0 // indirect @@ -84,8 +74,8 @@ require ( go.opentelemetry.io/otel/sdk v1.24.0 // indirect go.opentelemetry.io/otel/trace v1.24.0 // indirect golang.org/x/net v0.22.0 // indirect + golang.org/x/time v0.5.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20240311173647-c811ad7063a7 // indirect - gopkg.in/ini.v1 v1.67.0 // indirect gotest.tools/v3 v3.5.1 // indirect ) @@ -147,7 +137,6 @@ require ( github.com/pkg/term v1.2.0-beta.2.0.20211217091447-1a4a3b719465 github.com/spf13/cobra v1.8.0 github.com/spf13/pflag v1.0.5 - github.com/spf13/viper v1.18.2 github.com/stretchr/testify v1.9.0 go.uber.org/zap v1.27.0 golang.org/x/sync v0.6.0 diff --git a/go.sum b/go.sum index ad81ec55f..08f03692e 100644 --- a/go.sum +++ b/go.sum @@ -90,8 +90,6 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= -github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/fullstorydev/grpcurl v1.8.9 h1:JMvZXK8lHDGyLmTQ0ZdGDnVVGuwjbpaumf8p42z0d+c= github.com/fullstorydev/grpcurl v1.8.9/go.mod h1:PNNKevV5VNAV2loscyLISrEnWQI61eqR0F8l3bVadAA= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= @@ -153,8 +151,6 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0 h1:Wqo399gCIufwto+VfwCSvsnfGpF github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0/go.mod h1:qmOFXW2epJhM0qSnUUYpldc7gVz2KMQwJ/QYCDIa7XU= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= -github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= -github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/henvic/httpretty v0.1.3 h1:4A6vigjz6Q/+yAfTD4wqipCv+Px69C7Th/NhT0ApuU8= github.com/henvic/httpretty v0.1.3/go.mod h1:UUEv7c2kHZ5SPQ51uS3wBpzPDibg2U3Y+IaXyHy5GBg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -180,8 +176,6 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= -github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= @@ -194,8 +188,6 @@ github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZ github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= -github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= -github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= @@ -239,10 +231,6 @@ github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/rwtodd/Go.Sed v0.0.0-20230610052213-ba3e9c186f0a h1:URwYffGNuBQkfwkcn+1CZhb8IE/mKSXxPXp/zzQsn80= github.com/rwtodd/Go.Sed v0.0.0-20230610052213-ba3e9c186f0a/go.mod h1:c6qgHcSUeSISur4+Kcf3WYTvpL07S8eAsoP40hDiQ1I= -github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= -github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= -github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= -github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= @@ -252,18 +240,10 @@ github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/skeema/knownhosts v1.2.1 h1:SHWdIUa82uGZz+F+47k8SY4QhhI291cXCpopT1lK2AQ= github.com/skeema/knownhosts v1.2.1/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo= -github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= -github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= -github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= -github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= -github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= -github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= -github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= github.com/stateful/godotenv v0.0.0-20240309032207-c7bc0b812915 h1:rBwOH8hK4mnonIOv9qV76i+nhmJIMaxqUeuzg9e7pF8= github.com/stateful/godotenv v0.0.0-20240309032207-c7bc0b812915/go.mod h1:A7pPuRB981nGoMyu09TOEDPHzg/eVlO3rgy1pk91xYY= github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= @@ -280,8 +260,6 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= -github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/vektah/gqlparser/v2 v2.5.11 h1:JJxLtXIoN7+3x6MBdtIP59TP1RANnY7pXOaDnADQSf8= github.com/vektah/gqlparser/v2 v2.5.11/go.mod h1:1rCcfwB2ekJofmluGWXMSEnPMZgbxzwj6FaZ/4OT8Cc= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= @@ -427,8 +405,6 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= -gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/internal/config/autoconfig/autoconfig.go b/internal/config/autoconfig/autoconfig.go index ce9d5342e..7a1fa0b03 100644 --- a/internal/config/autoconfig/autoconfig.go +++ b/internal/config/autoconfig/autoconfig.go @@ -8,10 +8,6 @@ // }) // // Treat it as a dependency injection mechanism. -// -// autoconfig relies on [viper.Viper] which has a set of limitations. The most important one -// is the fact that it does not support hierarchical configuration per folder. We might consider -// switchig from [viper.Viper] to something else in the future. package autoconfig import ( @@ -19,8 +15,6 @@ import ( "path/filepath" "github.com/pkg/errors" - "github.com/spf13/afero" - "github.com/spf13/viper" "go.uber.org/dig" "go.uber.org/multierr" "go.uber.org/zap" @@ -47,9 +41,8 @@ func mustProvide(err error) { } func init() { - // [viper.Viper] can be overridden by a decorator: - // container.Decorate(func(v *viper.Viper) *viper.Viper { return nil }) mustProvide(container.Provide(getConfig)) + mustProvide(container.Provide(getConfigLoader)) mustProvide(container.Provide(getKernelGetter)) mustProvide(container.Provide(getKernel)) mustProvide(container.Provide(getLogger)) @@ -57,47 +50,12 @@ func init() { mustProvide(container.Provide(getProjectFilters)) mustProvide(container.Provide(getSession)) mustProvide(container.Provide(getUserConfigDir)) - mustProvide(container.Provide(getViper)) - - if err := container.Invoke(func(viper *viper.Viper) { - viper.SetConfigName("runme") - viper.SetConfigType("yaml") - - viper.AddConfigPath("/etc/runme/") - viper.AddConfigPath("$HOME/.runme/") - // TODO(adamb): change to "." when ready. - viper.AddConfigPath("experimental/") - - viper.SetEnvPrefix("RUNME") - viper.AutomaticEnv() - }); err != nil { - panic("failed to setup configuration: " + err.Error()) - } } -func getConfig(userCfgDir UserConfigDir, viper *viper.Viper) (*config.Config, error) { - if err := viper.ReadInConfig(); err != nil { - return nil, errors.WithStack(err) - } - - // As viper does not offer writing config to a writer, - // the workaround is to create a in-memory file system, - // set it in viper, and write the config to it. - // Finally, a deferred cleanup function is called - // which brings back the OS file system. - // Source: https://github.com/spf13/viper/issues/856 - memFS := afero.NewMemMapFs() - - viper.SetFs(memFS) - defer viper.SetFs(afero.NewOsFs()) - - if err := viper.WriteConfigAs("/config.yaml"); err != nil { - return nil, errors.WithStack(err) - } - - content, err := afero.ReadFile(memFS, "/config.yaml") +func getConfig(cfgLoader *config.Loader, userCfgDir UserConfigDir) (*config.Config, error) { + content, err := cfgLoader.RootConfig() if err != nil { - return nil, errors.WithStack(err) + return nil, errors.WithMessage(err, "failed to load project configuration") } cfg, err := config.ParseYAML(content) @@ -117,6 +75,11 @@ func getConfig(userCfgDir UserConfigDir, viper *viper.Viper) (*config.Config, er return cfg, nil } +func getConfigLoader() (*config.Loader, error) { + // TODO(adamb): change from "./experimental" to "." when the feature is stable. + return config.NewLoader("runme", "yaml", os.DirFS("./experimental")), nil +} + func getKernel(c *config.Config, logger *zap.Logger) (_ command.Kernel, err error) { // Find the first kernel that can be instantiated without error. // This is inline with how the kernels are described in the configuration file. @@ -329,5 +292,3 @@ func getUserConfigDir() (UserConfigDir, error) { dir, err := os.UserConfigDir() return UserConfigDir(dir), errors.WithStack(err) } - -func getViper() *viper.Viper { return viper.GetViper() } diff --git a/internal/config/autoconfig/autoconfig_test.go b/internal/config/autoconfig/autoconfig_test.go index f34dbba69..dd2fa84e8 100644 --- a/internal/config/autoconfig/autoconfig_test.go +++ b/internal/config/autoconfig/autoconfig_test.go @@ -2,52 +2,33 @@ package autoconfig import ( "fmt" - "os" - "path/filepath" "testing" + "testing/fstest" - "github.com/spf13/viper" "github.com/stretchr/testify/require" - "go.uber.org/zap" - "github.com/stateful/runme/v3/internal/command" "github.com/stateful/runme/v3/internal/config" - "github.com/stateful/runme/v3/internal/project" ) -func TestInvokeAll(t *testing.T) { - tempDir := t.TempDir() - readmeFilePath := filepath.Join(tempDir, "README.md") - - // Create a README.md file in the temp directory. - err := os.WriteFile(readmeFilePath, []byte("Hello, World!"), 0o600) - require.NoError(t, err) - - // Create a runme.yaml using the README.md file from above. - // This won't work with the project as it requires the project - // to be a subdirectory of the current working directory. - configYAML := fmt.Sprintf("version: v1alpha1\nfilename: %s\n", readmeFilePath) - - // Create a runme.yaml file in the temp directory. - err = os.WriteFile(filepath.Join(tempDir, "/runme.yaml"), []byte(configYAML), 0o600) - require.NoError(t, err) - // And add it to the viper configuration. - // It's ok as viper has no other dependencies - // so nothing will be instantiated before - // the configuration is loaded. - err = Invoke(func(v *viper.Viper) { - v.AddConfigPath(tempDir) +func TestInvokeConfig(t *testing.T) { + // Create fake filesystem and set it in the config loader. + fsys := fstest.MapFS{ + "README.md": { + Data: []byte("Hello, World!"), + }, + "runme.yaml": { + Data: []byte(fmt.Sprintf("version: v1alpha1\nfilename: %s\n", "README.md")), + }, + } + + err := Invoke(func(loader *config.Loader) error { + loader.SetConfigRootPath(fsys) + return nil }) require.NoError(t, err) - // Load all dependencies. err = Invoke(func( *config.Config, - *zap.Logger, - *project.Project, - []project.Filter, - *command.Session, - *viper.Viper, ) error { return nil }) diff --git a/internal/config/loader.go b/internal/config/loader.go new file mode 100644 index 000000000..1e7a50bba --- /dev/null +++ b/internal/config/loader.go @@ -0,0 +1,112 @@ +package config + +import ( + "errors" + "io/fs" + "path/filepath" + "strings" +) + +// Loader allows to load configuration files from a file system. +type Loader struct { + // configRootPath is a root path for the configuration file. + // Typically, it's a project root path, which currently defaults to + // the current working directory. + configRootPath fs.FS + + // configName is a name of the configuration file. + configName string + + // configType is a type of the configuration file. + // Together with configName it forms a configFile. + configType string +} + +func NewLoader(configName, configType string, configRootPath fs.FS) *Loader { + l := &Loader{ + configRootPath: configRootPath, + configName: configName, + configType: configType, + } + return l +} + +func (l *Loader) configFullName() string { + if l.configType == "" { + return l.configName + } + return l.configName + "." + l.configType +} + +func (l *Loader) SetConfigRootPath(configRootPath fs.FS) { + l.configRootPath = configRootPath +} + +func (l *Loader) ChainConfigs(subDir string) ([][]byte, error) { + subDir = filepath.Clean(subDir) + + if subDir == "" || subDir == "." { + data, err := l.RootConfig() + if err != nil { + return nil, err + } + return [][]byte{data}, nil + } + + paths, err := l.filePaths(subDir) + if err != nil { + return nil, err + } + return l.readFiles(paths...) +} + +func (l *Loader) RootConfig() ([]byte, error) { + return fs.ReadFile(l.configRootPath, l.configFullName()) +} + +func (l *Loader) filePaths(subDir string) (result []string, _ error) { + if l.configName == "" { + return nil, errors.New("config name is not set") + } + + configFullName := l.configFullName() + + _, err := fs.Stat(l.configRootPath, configFullName) + if err == nil { + result = append(result, configFullName) + } else if !errors.Is(err, fs.ErrNotExist) { + return nil, err + } + + fragments := strings.Split(subDir, string(filepath.Separator)) + if len(fragments) > 0 && fragments[0] == "." { + fragments = fragments[1:] + } + + curSubDir := "" + for _, fragment := range fragments { + curSubDir = filepath.Join(curSubDir, fragment) + + configPath := filepath.Join(curSubDir, configFullName) + _, err := fs.Stat(l.configRootPath, configPath) + if err == nil { + result = append(result, configPath) + } else if !errors.Is(err, fs.ErrNotExist) { + return nil, err + } + } + + return result, nil +} + +func (l *Loader) readFiles(paths ...string) (result [][]byte, _ error) { + for _, path := range paths { + data, err := fs.ReadFile(l.configRootPath, path) + if err != nil { + return nil, err + } + result = append(result, data) + } + + return result, nil +} diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go new file mode 100644 index 000000000..ebb7c3e91 --- /dev/null +++ b/internal/config/loader_test.go @@ -0,0 +1,132 @@ +package config + +import ( + "testing" + "testing/fstest" + + "github.com/stretchr/testify/require" +) + +func TestNewLoader(t *testing.T) { + t.Parallel() + + fsys := fstest.MapFS{} + loader := NewLoader("", "", fsys) + result, err := loader.filePaths("") + require.ErrorContains(t, err, "config name is not set") + require.Nil(t, result) +} + +func TestLoader_RootConfig(t *testing.T) { + t.Parallel() + + t.Run("without root config", func(t *testing.T) { + t.Parallel() + + fsys := fstest.MapFS{} + loader := NewLoader("runme", "yaml", fsys) + result, err := loader.RootConfig() + require.ErrorContains(t, err, "open runme.yaml: file does not exist") + require.Nil(t, result) + }) + + t.Run("with root config", func(t *testing.T) { + t.Parallel() + + data := []byte("version: v1alpha1\n") + fsys := fstest.MapFS{ + "runme.yaml": { + Data: data, + }, + } + loader := NewLoader("runme", "yaml", fsys) + result, err := loader.RootConfig() + require.NoError(t, err) + require.Equal(t, data, result) + }) +} + +func TestLoader_ChainConfigs(t *testing.T) { + t.Parallel() + + t.Run("without root config", func(t *testing.T) { + t.Parallel() + + fsys := fstest.MapFS{} + loader := NewLoader("runme", "yaml", fsys) + result, err := loader.ChainConfigs("") + require.ErrorContains(t, err, "open runme.yaml: file does not exist") + require.Nil(t, result) + }) + + fsys := fstest.MapFS{ + "runme.yaml": { + Data: []byte("path:runme.yaml"), + }, + "nested/runme.yaml": { + Data: []byte("path:nested/runme.yaml"), + }, + "nested/path/runme.yaml": { + Data: []byte("path:nested/path/runme.yaml"), + }, + "other/runme.yaml": { + Data: []byte("path:other/runme.yaml"), + }, + "without/config": { + Data: []byte("path:without/config"), + }, + } + loader := NewLoader("runme", "yaml", fsys) + + t.Run("root config", func(t *testing.T) { + t.Parallel() + + result, err := loader.ChainConfigs("") + require.NoError(t, err) + require.Equal( + t, + [][]byte{[]byte("path:runme.yaml")}, + result, + ) + }) + + t.Run("nested config", func(t *testing.T) { + t.Parallel() + + result, err := loader.ChainConfigs("nested") + require.NoError(t, err) + require.Equal( + t, + [][]byte{[]byte("path:runme.yaml"), []byte("path:nested/runme.yaml")}, + result, + ) + }) + + t.Run("nested deep config", func(t *testing.T) { + t.Parallel() + + result, err := loader.ChainConfigs("nested/path") + require.NoError(t, err) + require.Equal( + t, + [][]byte{ + []byte("path:runme.yaml"), + []byte("path:nested/runme.yaml"), + []byte("path:nested/path/runme.yaml"), + }, + result, + ) + }) + + t.Run("nested without config", func(t *testing.T) { + t.Parallel() + + result, err := loader.ChainConfigs("without/config") + require.NoError(t, err) + require.Equal( + t, + [][]byte{[]byte("path:runme.yaml")}, + result, + ) + }) +}