From 3d637c0238b8bd6b465118181efdbadd054fe3b3 Mon Sep 17 00:00:00 2001 From: Adam Babik Date: Fri, 17 May 2024 22:20:14 +0200 Subject: [PATCH] internal/config: replace viper with custom config loader (#564) This PR implements a generic config loader. The main purpose is to find all relevant `runme.yaml` starting from a Markdown file location. The algorithm goes up the directory tree until the project root. The next step is to merge all found `runme.yaml` configs into a single one that will be used by the execution layer. --------- Co-authored-by: Sebastian Tiedtke --- go.mod | 13 +- go.sum | 24 --- internal/cmd/beta/run_cmd.go | 17 +- internal/config/autoconfig/autoconfig.go | 60 ++---- internal/config/autoconfig/autoconfig_test.go | 49 ++--- internal/config/loader.go | 184 ++++++++++++++++++ internal/config/loader_test.go | 125 ++++++++++++ testdata/beta/server.txtar | 2 +- 8 files changed, 350 insertions(+), 124 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 3753f2e7f..905cf7722 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-20240405174034-bb8ed5da0fd0 - 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.1 @@ -64,25 +63,16 @@ require ( github.com/envoyproxy/protoc-gen-validate v1.0.4 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // 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/docker-image-spec v1.3.1 // 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.51.0 // indirect go.opentelemetry.io/otel v1.26.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0 // indirect @@ -90,8 +80,8 @@ require ( go.opentelemetry.io/otel/sdk v1.24.0 // indirect go.opentelemetry.io/otel/trace v1.26.0 // indirect golang.org/x/net v0.25.0 // indirect + golang.org/x/time v0.5.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20240513163218-0867130af1f8 // indirect - gopkg.in/ini.v1 v1.67.0 // indirect gotest.tools/v3 v3.5.1 // indirect ) @@ -151,7 +141,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.7.0 diff --git a/go.sum b/go.sum index fcc19dfba..b3191559c 100644 --- a/go.sum +++ b/go.sum @@ -98,8 +98,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.9.1 h1:YxX1aCcCc4SDBQfj9uoWcTLe8t4NWrZe1y+mk83BQgo= github.com/fullstorydev/grpcurl v1.9.1/go.mod h1:i8gKLIC6s93WdU3LSmkE5vtsCxyRmihUj5FK1cNW5EM= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= @@ -160,8 +158,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= @@ -187,8 +183,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= @@ -201,8 +195,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/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= @@ -248,10 +240,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-20240405174034-bb8ed5da0fd0 h1:Sm5QvnDuFhkajkdjAHX51h+gyuv+LmkjX//zjpZwIvA= github.com/rwtodd/Go.Sed v0.0.0-20240405174034-bb8ed5da0fd0/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.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= @@ -261,18 +249,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.2 h1:Iug2P4fLmDw9f41PB6thxUkNUkJzB5i+1/exaj40L3A= github.com/skeema/knownhosts v1.2.2/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= @@ -290,8 +270,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= @@ -432,8 +410,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/cmd/beta/run_cmd.go b/internal/cmd/beta/run_cmd.go index 034889a5f..c09dd4979 100644 --- a/internal/cmd/beta/run_cmd.go +++ b/internal/cmd/beta/run_cmd.go @@ -7,7 +7,6 @@ import ( "github.com/stateful/runme/v3/internal/command" "github.com/stateful/runme/v3/internal/config/autoconfig" - "github.com/stateful/runme/v3/internal/document" "github.com/stateful/runme/v3/internal/project" ) @@ -71,7 +70,7 @@ Run all blocks from the "setup" and "teardown" categories: } for _, t := range tasks { - err := runCodeBlock(t.CodeBlock, cmd, kernel, session, logger) + err := runCodeBlock(t, cmd, kernel, session, logger) if err != nil { return err } @@ -89,13 +88,23 @@ Run all blocks from the "setup" and "teardown" categories: } func runCodeBlock( - block *document.CodeBlock, + task project.Task, cmd *cobra.Command, kernel command.Kernel, sess *command.Session, logger *zap.Logger, ) error { - cfg, err := command.NewConfigFromCodeBlock(block) + // TODO(adamb): [command.Config] is generated exclusively from the [document.CodeBlock]. + // As we introduce some document- and block-related configs in runme.yaml (root but also nested), + // this [Command.Config] should be further extended. + // + // The way to do it is to use [config.Loader] and calling [config.Loader.FindConfigChain] with + // task's document path. It will produce all the configs that are relevant to the document. + // Next, they should be merged into a single [config.Config] in a correct order, starting from + // the last element of the returned config chain. Finally, [command.Config] should be updated. + // This algorithm should be likely encapsulated in the [internal/config] and [internal/command] + // packages. + cfg, err := command.NewConfigFromCodeBlock(task.CodeBlock) if err != nil { return err } diff --git a/internal/config/autoconfig/autoconfig.go b/internal/config/autoconfig/autoconfig.go index ce9d5342e..691b56970 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,57 +41,21 @@ 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)) mustProvide(container.Provide(getProject)) mustProvide(container.Provide(getProjectFilters)) + mustProvide(container.Provide(getRootConfig)) 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 getRootConfig(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,12 @@ 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 and + // delete the project root path. + return config.NewLoader("runme", "yaml", os.DirFS("./experimental"), config.WithProjectRootPath(os.DirFS("."))), 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 +293,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..0ba2739d3 --- /dev/null +++ b/internal/config/loader.go @@ -0,0 +1,184 @@ +package config + +import ( + "io/fs" + "path" + "path/filepath" + "strings" + + "github.com/pkg/errors" + "go.uber.org/zap" +) + +var ErrRootConfigNotFound = errors.New("root configuration file not found") + +// 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 + + // projectRootPath is a path to the project root directory. + // If not empty, it is used to find nested configuration files, + // for example using [ChainConfigs], instead of configRootPath. + projectRootPath fs.FS + + logger *zap.Logger +} + +type LoaderOption func(*Loader) + +func WithLogger(logger *zap.Logger) LoaderOption { + return func(l *Loader) { + l.logger = logger + } +} + +func WithProjectRootPath(projectRootPath fs.FS) LoaderOption { + return func(l *Loader) { + l.projectRootPath = projectRootPath + } +} + +func NewLoader(configName, configType string, configRootPath fs.FS, opts ...LoaderOption) *Loader { + if configName == "" { + panic("config name is not set") + } + + l := &Loader{ + configRootPath: configRootPath, + configName: configName, + configType: configType, + } + + for _, opt := range opts { + opt(l) + } + + if l.logger == nil { + l.logger = zap.NewNop() + } + + 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) FindConfigChain(path string) ([][]byte, error) { + paths, err := l.findConfigFilesOnPath(path) + if err != nil { + return nil, err + } + return l.readFiles(paths...) +} + +func (l *Loader) RootConfig() ([]byte, error) { + data, err := fs.ReadFile(l.configRootPath, l.configFullName()) + if err != nil { + return nil, ErrRootConfigNotFound + } + return data, nil +} + +func (l *Loader) findConfigFilesOnPath(name string) (result []string, _ error) { + name, err := l.parsePath(name) + if err != nil { + return nil, err + } + l.logger.Debug("finding config files on path", zap.String("name", name)) + + configFullName := l.configFullName() + + // Find the root configuration file and add it to the result if exists. + // It is always searched in the config root directory. + _, err = fs.Stat(l.configRootPath, configFullName) + if err == nil { + result = append(result, configFullName) + } else if !errors.Is(err, fs.ErrNotExist) { + l.logger.Debug("root configuration file not found", zap.Error(err)) + return nil, err + } + + // Detect the file system to use for nested configuration files. + fsys := l.configRootPath + if l.projectRootPath != nil { + fsys = l.projectRootPath + } + + // Split the path and iterate over the fragments to find nested configuration files. + fragments := strings.Split(name, string(filepath.Separator)) + if len(fragments) > 0 && fragments[0] == "." { + fragments = fragments[1:] + } + l.logger.Debug("path fragments", zap.Strings("fragments", fragments)) + + curDir := "" + for _, fragment := range fragments { + // Use [path.Join] instead of [filepath.Join] to support Windows paths. + // It works well with [fs.FS]. + curDir = path.Join(curDir, fragment) + + configPath := path.Join(curDir, configFullName) + l.logger.Debug("checking nested configuration file", zap.String("path", configPath)) + _, err := fs.Stat(fsys, configPath) + if err == nil { + result = append(result, configPath) + } else if !errors.Is(err, fs.ErrNotExist) { + l.logger.Debug("nested configuration file not found", zap.String("path", configPath), zap.Error(err)) + return nil, err + } + } + + l.logger.Debug("found config files on path", zap.String("name", name), zap.Strings("files", result)) + + return result, nil +} + +func (l *Loader) parsePath(name string) (string, error) { + if name == "" { + name = "." + } + + fsys := l.configRootPath + if l.projectRootPath != nil { + fsys = l.projectRootPath + } + + info, err := fs.Stat(fsys, name) + if err != nil { + return "", errors.Wrapf(err, "failed to get the path info for %q", name) + } + + if info.IsDir() { + return filepath.Clean(name), nil + } + return filepath.Dir(name), 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..897313c4c --- /dev/null +++ b/internal/config/loader_test.go @@ -0,0 +1,125 @@ +package config + +import ( + "io/fs" + "testing" + "testing/fstest" + + "github.com/stretchr/testify/require" + "go.uber.org/zap/zaptest" +) + +func TestNewLoader(t *testing.T) { + t.Parallel() + + require.Panics(t, func() { + NewLoader("", "yaml", fstest.MapFS{}) + }, "config name is not set") +} + +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, WithLogger(zaptest.NewLogger(t))) + result, err := loader.RootConfig() + require.ErrorIs(t, err, ErrRootConfigNotFound) + 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, WithLogger(zaptest.NewLogger(t))) + 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, WithLogger(zaptest.NewLogger(t))) + result, err := loader.FindConfigChain("") + require.NoError(t, err) + 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"), + Mode: fs.ModeDir, + }, + } + loader := NewLoader("runme", "yaml", fsys, WithLogger(zaptest.NewLogger(t))) + + t.Run("root config", func(t *testing.T) { + result, err := loader.FindConfigChain("") + require.NoError(t, err) + require.Equal( + t, + [][]byte{[]byte("path:runme.yaml")}, + result, + ) + }) + + t.Run("nested config", func(t *testing.T) { + result, err := loader.FindConfigChain("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) { + result, err := loader.FindConfigChain("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) { + result, err := loader.FindConfigChain("without/config") + require.NoError(t, err) + require.Equal( + t, + [][]byte{[]byte("path:runme.yaml")}, + result, + ) + }) +} diff --git a/testdata/beta/server.txtar b/testdata/beta/server.txtar index 64605675d..f4828e1ee 100644 --- a/testdata/beta/server.txtar +++ b/testdata/beta/server.txtar @@ -1,6 +1,6 @@ ! exec runme beta server start & # wait for the server to generate certs and start up -exec sleep 5 +exec sleep 10 exec runme beta server stop wait ! stdout .