diff --git a/.github/dependabot.yml b/.github/dependabot.yml index de7842a..0db9412 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,6 +8,13 @@ updates: schedule: interval: weekly + - package-ecosystem: gomod + directory: /gcp + labels: + - Skip-Changelog + schedule: + interval: weekly + - package-ecosystem: gomod directory: /grpc labels: diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index bc26413..e7a2d75 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -12,7 +12,7 @@ jobs: if: ${{ github.actor != 'dependabot[bot]' }} strategy: matrix: - module: [ '', 'grpc' ] + module: [ '', 'gcp', 'grpc' ] name: Coverage runs-on: ubuntu-latest steps: diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 18527fa..5aafe14 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -11,7 +11,7 @@ jobs: lint: strategy: matrix: - module: [ '', 'grpc' ] + module: [ '', 'gcp', 'grpc' ] name: Lint runs-on: ubuntu-latest steps: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 96f0abd..b36b63b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -51,6 +51,7 @@ jobs: with: script: | const modules = [ + 'gcp', 'grpc' ] for (const module of modules) { diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 648ccb4..23a5244 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,7 +11,7 @@ jobs: test: strategy: matrix: - module: [ '', 'grpc' ] + module: [ '', 'gcp', 'grpc' ] go-version: [ 'stable', 'oldstable' ] name: Test runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 99ce166..24e87a6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,9 @@ # All files and folders start with . .* +# Go workspace files +go.work +go.work.sum + # Go vendor folder vendor diff --git a/.golangci.yml b/.golangci.yml index a8455f5..7bfa59a 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -14,8 +14,11 @@ linters-settings: Use of this source code is governed by a MIT license found in the LICENSE file. goimports: local-prefixes: github.com/nil-go/nilgo + gomoddirectives: + replace-local: true govet: - check-shadowing: true + enable: + - shadow makezero: always: true misspell: diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b7b53e..716df31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,3 +11,4 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Add Runner for parallel execution (#9). - Support gRPC server (#15). +- Add nilgo.Run (#22). diff --git a/config/konf.go b/config/konf.go new file mode 100644 index 0000000..705225a --- /dev/null +++ b/config/konf.go @@ -0,0 +1,68 @@ +// Copyright (c) 2024 The nilgo authors +// Use of this source code is governed by a MIT license found in the LICENSE file. + +// Package config provides an application configuration loader base on [konf]. +// +// # Configuration Sources +// +// It loads configuration from the following sources, +// and each source takes precedence over the sources below it: +// +// - config files specified by WithFS and WithFile. +// WithFile also can be overridden by the environment variable `CONFIG_FILE`. +// For example, if CONFIG_FILE = "f1, f2,f3", it will load f1, f2, and f3, +// and each file takes precedence over the files before it. +// - environment variables which matches the following pattern: +// prefix + "_" + key, all in ALL CAPS. +// For example, FOO_BAR is the name of environment variable for configuration `foo.bar`. +// +// [konf]: https://pkg.go.dev/github.com/nil-go/konf +package config + +import ( + "errors" + "fmt" + "log/slog" + "os" + + "github.com/nil-go/konf" + "github.com/nil-go/konf/provider/env" + "github.com/nil-go/konf/provider/fs" + "gopkg.in/yaml.v3" +) + +// New creates a new konf.Config with the given Option(s). +func New(opts ...Option) (*konf.Config, error) { + options := options{} + for _, opt := range opts { + opt(&options) + } + if files := konf.Get[[]string]("config.file"); len(files) > 0 { + options.files = files + } + if len(options.files) == 0 { + options.files = []string{"config/config.yaml"} + } + + config := konf.New(options.opts...) + for _, file := range options.files { + if err := config.Load(fs.New(options.fs, file, fs.WithUnmarshal(yaml.Unmarshal))); err != nil { + var e *os.PathError + if !errors.As(err, &e) { + return nil, fmt.Errorf("load config file %s: %w", file, err) + } + + // Ignore not found error since config file is optional. + slog.Warn("Config file not found.", "file", file) + } + } + // Ignore error: env loader does not return error. + _ = config.Load(env.New()) + if options.fn != nil { + if err := options.fn(config); err != nil { + return nil, err + } + } + + return config, nil +} diff --git a/config/konf_test.go b/config/konf_test.go new file mode 100644 index 0000000..41646cb --- /dev/null +++ b/config/konf_test.go @@ -0,0 +1,150 @@ +// Copyright (c) 2024 The nilgo authors +// Use of this source code is governed by a MIT license found in the LICENSE file. + +package config_test + +import ( + "errors" + "testing" + "testing/fstest" + + "github.com/nil-go/konf" + "github.com/nil-go/konf/provider/env" + "github.com/nil-go/konf/provider/fs" + "gopkg.in/yaml.v3" + + "github.com/nil-go/nilgo/config" + "github.com/nil-go/nilgo/internal/assert" +) + +func TestNew(t *testing.T) { + testcases := []struct { + description string + opts []config.Option + env map[string]string + key string + explanation string + err string + }{ + { + description: "config file", + opts: []config.Option{config.WithFile("testdata/config.yaml")}, + key: "nilgo.source", + explanation: `nilgo.source has value[file] that is loaded by loader[fs:///testdata/config.yaml]. + +`, + }, + { + description: "multiple config files", + opts: []config.Option{config.WithFile("testdata/config.yaml", "testdata/staging.yaml")}, + key: "nilgo.stage", + explanation: `nilgo.stage has value[staging] that is loaded by loader[fs:///testdata/staging.yaml]. +Here are other value(loader)s: + - dev(fs:///testdata/config.yaml) + +`, + }, + { + description: "with environment variables", + opts: []config.Option{config.WithFile("testdata/config.yaml")}, + env: map[string]string{"NILGO_SOURCE": "env"}, + key: "nilgo.source", + explanation: `nilgo.source has value[env] that is loaded by loader[env:*]. +Here are other value(loader)s: + - file(fs:///testdata/config.yaml) + +`, + }, + { + description: "config file path in environment variable", + env: map[string]string{"CONFIG_FILE": "testdata/config.yaml"}, + key: "nilgo.source", + explanation: `nilgo.source has value[file] that is loaded by loader[fs:///testdata/config.yaml]. + +`, + }, + { + description: "default config file not found", + key: "nilgo.source", + explanation: `nilgo.source has no configuration. + +`, + }, + { + description: "with fs", + opts: []config.Option{config.WithFS(fstest.MapFS{"config/config.yaml": {Data: []byte("nilgo:\n source: fs")}})}, + key: "nilgo.source", + explanation: `nilgo.source has value[fs] that is loaded by loader[fs:///config/config.yaml]. + +`, + }, + { + description: "with option", + opts: []config.Option{ + config.WithOption(konf.WithCaseSensitive()), + config.WithFS(fstest.MapFS{"config/config.yaml": {Data: []byte("nilgo:\n source: fs")}}), + }, + key: "nilgo.Source", + explanation: `nilgo.Source has no configuration. + +`, + }, + { + description: "with", + opts: []config.Option{ + config.With(func(cfg *konf.Config) error { + return cfg.Load(fs.New(nil, "testdata/config.yaml", fs.WithUnmarshal(yaml.Unmarshal))) + }), + config.WithFS(fstest.MapFS{"config/config.yaml": {Data: []byte("nilgo:\n source: fs")}}), + }, + key: "nilgo.Source", + explanation: `nilgo.Source has value[file] that is loaded by loader[fs:///testdata/config.yaml]. +Here are other value(loader)s: + - fs(fs:///config/config.yaml) + +`, + }, + { + description: "unmarshal error", + opts: []config.Option{config.WithFS(fstest.MapFS{"config/config.yaml": {Data: []byte("nilgo")}})}, + key: "nilgo.source", + err: "load config file config/config.yaml: load configuration: unmarshal: yaml: unmarshal errors:\n" + + " line 1: cannot unmarshal !!str `nilgo` into map[string]interface {}", + }, + { + description: "with error", + opts: []config.Option{ + config.With(func(*konf.Config) error { + return errors.New("with error") + }), + config.WithFS(fstest.MapFS{"config/config.yaml": {Data: []byte("nilgo:\n source: fs")}}), + }, + key: "nilgo.Source", + err: "with error", + }, + } + + for _, testcase := range testcases { + testcase := testcase + + t.Run(testcase.description, func(t *testing.T) { + for key, value := range testcase.env { + t.Setenv(key, value) + } + + // Reset the default config to load the new environment variables. + cfg := konf.New() + // Ignore error: env loader does not return error. + _ = cfg.Load(env.New()) + konf.SetDefault(cfg) + + cfg, err := config.New(testcase.opts...) + if testcase.err == "" { + assert.NoError(t, err) + assert.Equal(t, testcase.explanation, cfg.Explain(testcase.key)) + } else { + assert.EqualError(t, err, testcase.err) + } + }) + } +} diff --git a/config/option.go b/config/option.go new file mode 100644 index 0000000..f2ec932 --- /dev/null +++ b/config/option.go @@ -0,0 +1,55 @@ +// Copyright (c) 2024 The nilgo authors +// Use of this source code is governed by a MIT license found in the LICENSE file. + +package config + +import ( + "io/fs" + + "github.com/nil-go/konf" +) + +// WithFile explicitly provides the config file paths, +// and each file takes precedence over the files before it. +// +// By default, it uses "config/config.yaml". +// It can be overridden by the environment variable "CONFIG_FILE". +func WithFile(files ...string) Option { + return func(options *options) { + options.files = append(options.files, files...) + } +} + +// WithFS provides the fs.FS to load the config files from. +// +// By default, it uses OS file system under the current directory. +func WithFS(fs fs.FS) Option { + return func(options *options) { + options.fs = fs + } +} + +// WithOption provides the konf.Option to customize the config. +func WithOption(opts ...konf.Option) Option { + return func(options *options) { + options.opts = append(options.opts, opts...) + } +} + +// With allows to customize the config with the given function. +func With(fn func(config *konf.Config) error) Option { + return func(options *options) { + options.fn = fn + } +} + +type ( + // Option configures the config with specific options. + Option func(*options) + options struct { + opts []konf.Option + files []string + fs fs.FS + fn func(*konf.Config) error + } +) diff --git a/config/testdata/config.yaml b/config/testdata/config.yaml new file mode 100644 index 0000000..a118382 --- /dev/null +++ b/config/testdata/config.yaml @@ -0,0 +1,3 @@ +nilgo: + source: file + stage: dev diff --git a/config/testdata/staging.yaml b/config/testdata/staging.yaml new file mode 100644 index 0000000..b1341f2 --- /dev/null +++ b/config/testdata/staging.yaml @@ -0,0 +1,2 @@ +nilgo: + stage: staging diff --git a/gcp/go.mod b/gcp/go.mod new file mode 100644 index 0000000..78f1924 --- /dev/null +++ b/gcp/go.mod @@ -0,0 +1,3 @@ +module github.com/nil-go/nilgo/gcp + +go 1.21 diff --git a/gcp/option.go b/gcp/option.go new file mode 100644 index 0000000..ec34861 --- /dev/null +++ b/gcp/option.go @@ -0,0 +1,4 @@ +// Copyright (c) 2024 The nilgo authors +// Use of this source code is governed by a MIT license found in the LICENSE file. + +package gcp diff --git a/go.mod b/go.mod index 935d2ca..a01deaf 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,13 @@ module github.com/nil-go/nilgo go 1.21 + +require ( + github.com/nil-go/konf v1.1.0 + github.com/nil-go/sloth v0.3.0 + github.com/nil-go/sloth/otel v0.3.0 + go.opentelemetry.io/otel/trace v1.26.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require go.opentelemetry.io/otel v1.26.0 // indirect diff --git a/go.sum b/go.sum index e69de29..1f0ac02 100644 --- a/go.sum +++ b/go.sum @@ -0,0 +1,22 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/nil-go/konf v1.1.0 h1:2rX5lC9B/oo31IuWOeb1Hd0M4UEx3WTm+hsQQPF392Y= +github.com/nil-go/konf v1.1.0/go.mod h1:ULW6PmJzWMd0F4KKNJQPhWD6Zu5eoX9U1C9SW2BPAr4= +github.com/nil-go/sloth v0.3.0 h1:lAqd8/pH6psoXZDpScCefY+3V9PVfJnIyOqMK1GSvwo= +github.com/nil-go/sloth v0.3.0/go.mod h1:SE8dLU9DLYeuLtu3kHp9PUEyj0OwUGKvTjSpx8tPdwo= +github.com/nil-go/sloth/otel v0.3.0 h1:BCF2oExOkzDjky8IHkT/4CqKvimbQldWp650qNk+vBo= +github.com/nil-go/sloth/otel v0.3.0/go.mod h1:AgNrkeeSag05r0HnTSotvYWmC8KF1XemF1NRN2iwYfk= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +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= +go.opentelemetry.io/otel v1.26.0 h1:LQwgL5s/1W7YiiRwxf03QGnWLb2HW4pLiAhaA5cZXBs= +go.opentelemetry.io/otel v1.26.0/go.mod h1:UmLkJHUAidDval2EICqBMbnAd0/m2vmpf/dAM+fvFs4= +go.opentelemetry.io/otel/trace v1.26.0 h1:1ieeAUb4y0TE26jUFrCIXKpTuVK7uJGN9/Z/2LP5sQA= +go.opentelemetry.io/otel/trace v1.26.0/go.mod h1:4iDxvGDQuUkHve82hJJ8UqrwswHYsZuWCBllGV2U2y0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/grpc/go.mod b/grpc/go.mod index 8801bac..da394fe 100644 --- a/grpc/go.mod +++ b/grpc/go.mod @@ -3,12 +3,12 @@ module github.com/nil-go/nilgo/grpc go 1.21 require ( - github.com/nil-go/konf v1.0.0 + github.com/nil-go/konf v1.1.0 github.com/nil-go/sloth v0.3.0 github.com/stretchr/testify v1.9.0 - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.50.0 - go.opentelemetry.io/otel/sdk v1.25.0 - go.opentelemetry.io/otel/sdk/metric v1.25.0 + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.51.0 + go.opentelemetry.io/otel/sdk v1.26.0 + go.opentelemetry.io/otel/sdk/metric v1.26.0 google.golang.org/grpc v1.63.2 google.golang.org/protobuf v1.33.0 ) @@ -23,14 +23,14 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - go.opentelemetry.io/otel v1.25.0 // indirect - go.opentelemetry.io/otel/metric v1.25.0 // indirect - go.opentelemetry.io/otel/trace v1.25.0 // indirect - golang.org/x/net v0.23.0 // indirect + go.opentelemetry.io/otel v1.26.0 // indirect + go.opentelemetry.io/otel/metric v1.26.0 // indirect + go.opentelemetry.io/otel/trace v1.26.0 // indirect + golang.org/x/net v0.24.0 // indirect golang.org/x/oauth2 v0.17.0 // indirect - golang.org/x/sys v0.18.0 // indirect + golang.org/x/sys v0.19.0 // indirect golang.org/x/text v0.14.0 // indirect google.golang.org/appengine v1.6.8 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240415180920-8c6c420018be // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/grpc/go.sum b/grpc/go.sum index c1a10ae..f00acc2 100644 --- a/grpc/go.sum +++ b/grpc/go.sum @@ -20,8 +20,8 @@ github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/nil-go/konf v1.0.0 h1:vz8qW1KEy7x9H9ti4rA9mDOOtLCkWFq3SfH0vqmP0ck= -github.com/nil-go/konf v1.0.0/go.mod h1:ULW6PmJzWMd0F4KKNJQPhWD6Zu5eoX9U1C9SW2BPAr4= +github.com/nil-go/konf v1.1.0 h1:2rX5lC9B/oo31IuWOeb1Hd0M4UEx3WTm+hsQQPF392Y= +github.com/nil-go/konf v1.1.0/go.mod h1:ULW6PmJzWMd0F4KKNJQPhWD6Zu5eoX9U1C9SW2BPAr4= github.com/nil-go/sloth v0.3.0 h1:lAqd8/pH6psoXZDpScCefY+3V9PVfJnIyOqMK1GSvwo= github.com/nil-go/sloth v0.3.0/go.mod h1:SE8dLU9DLYeuLtu3kHp9PUEyj0OwUGKvTjSpx8tPdwo= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -29,26 +29,26 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN 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/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.50.0 h1:zvpPXY7RfYAGSdYQLjp6zxdJNSYD/+FFoCTQN9IPxBs= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.50.0/go.mod h1:BMn8NB1vsxTljvuorms2hyOs8IBuuBEq0pl7ltOfy30= -go.opentelemetry.io/otel v1.25.0 h1:gldB5FfhRl7OJQbUHt/8s0a7cE8fbsPAtdpRaApKy4k= -go.opentelemetry.io/otel v1.25.0/go.mod h1:Wa2ds5NOXEMkCmUou1WA7ZBfLTHWIsp034OVD7AO+Vg= -go.opentelemetry.io/otel/metric v1.25.0 h1:LUKbS7ArpFL/I2jJHdJcqMGxkRdxpPHE0VU/D4NuEwA= -go.opentelemetry.io/otel/metric v1.25.0/go.mod h1:rkDLUSd2lC5lq2dFNrX9LGAbINP5B7WBkC78RXCpH5s= -go.opentelemetry.io/otel/sdk v1.25.0 h1:PDryEJPC8YJZQSyLY5eqLeafHtG+X7FWnf3aXMtxbqo= -go.opentelemetry.io/otel/sdk v1.25.0/go.mod h1:oFgzCM2zdsxKzz6zwpTZYLLQsFwc+K0daArPdIhuxkw= -go.opentelemetry.io/otel/sdk/metric v1.25.0 h1:7CiHOy08LbrxMAp4vWpbiPcklunUshVpAvGBrdDRlGw= -go.opentelemetry.io/otel/sdk/metric v1.25.0/go.mod h1:LzwoKptdbBBdYfvtGCzGwk6GWMA3aUzBOwtQpR6Nz7o= -go.opentelemetry.io/otel/trace v1.25.0 h1:tqukZGLwQYRIFtSQM2u2+yfMVTgGVeqRLPUYx1Dq6RM= -go.opentelemetry.io/otel/trace v1.25.0/go.mod h1:hCCs70XM/ljO+BeQkyFnbK28SBIJ/Emuha+ccrCRT7I= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.51.0 h1:A3SayB3rNyt+1S6qpI9mHPkeHTZbD7XILEqWnYZb2l0= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.51.0/go.mod h1:27iA5uvhuRNmalO+iEUdVn5ZMj2qy10Mm+XRIpRmyuU= +go.opentelemetry.io/otel v1.26.0 h1:LQwgL5s/1W7YiiRwxf03QGnWLb2HW4pLiAhaA5cZXBs= +go.opentelemetry.io/otel v1.26.0/go.mod h1:UmLkJHUAidDval2EICqBMbnAd0/m2vmpf/dAM+fvFs4= +go.opentelemetry.io/otel/metric v1.26.0 h1:7S39CLuY5Jgg9CrnA9HHiEjGMF/X2VHvoXGgSllRz30= +go.opentelemetry.io/otel/metric v1.26.0/go.mod h1:SY+rHOI4cEawI9a7N1A4nIg/nTQXe1ccCNWYOJUrpX4= +go.opentelemetry.io/otel/sdk v1.26.0 h1:Y7bumHf5tAiDlRYFmGqetNcLaVUZmh4iYfmGxtmz7F8= +go.opentelemetry.io/otel/sdk v1.26.0/go.mod h1:0p8MXpqLeJ0pzcszQQN4F0S5FVjBLgypeGSngLsmirs= +go.opentelemetry.io/otel/sdk/metric v1.26.0 h1:cWSks5tfriHPdWFnl+qpX3P681aAYqlZHcAyHw5aU9Y= +go.opentelemetry.io/otel/sdk/metric v1.26.0/go.mod h1:ClMFFknnThJCksebJwz7KIyEDHO+nTB6gK8obLy8RyE= +go.opentelemetry.io/otel/trace v1.26.0 h1:1ieeAUb4y0TE26jUFrCIXKpTuVK7uJGN9/Z/2LP5sQA= +go.opentelemetry.io/otel/trace v1.26.0/go.mod h1:4iDxvGDQuUkHve82hJJ8UqrwswHYsZuWCBllGV2U2y0= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= -golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= +golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= golang.org/x/oauth2 v0.17.0 h1:6m3ZPmLEFdVxKKWnKq4VqZ60gutO35zm+zrAHVmHyDQ= golang.org/x/oauth2 v0.17.0/go.mod h1:OzPDGQiuQMguemayvdylqddI7qcD9lnSDb+1FiwQ5HA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -58,8 +58,8 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= -golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -75,8 +75,8 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237 h1:NnYq6UN9ReLM9/Y01KWNOWyI5xQ9kbIms5GGJVwS/Yc= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240415180920-8c6c420018be h1:LG9vZxsWGOmUKieR8wPAUR3u3MpnYFQZROPIMaXh7/A= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240415180920-8c6c420018be/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY= google.golang.org/grpc v1.63.2 h1:MUeiw1B2maTVZthpU5xvASfTh3LDbxHd6IJ6QQVU+xM= google.golang.org/grpc v1.63.2/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDomNkRA= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= diff --git a/grpc/server.go b/grpc/server.go index cc0390a..eaea99a 100644 --- a/grpc/server.go +++ b/grpc/server.go @@ -55,12 +55,16 @@ func NewServer(opts ...grpc.ServerOption) *grpc.Server { // with listening on multiple tcp and unix socket address. // // It also resister health and reflection services if the services have not registered. -func Run(server *grpc.Server, addresses ...string) func(context.Context) error { //nolint:cyclop,funlen +func Run(server *grpc.Server, addresses ...string) func(context.Context) error { //nolint:cyclop,funlen,gocognit if server == nil { server = grpc.NewServer() } if len(addresses) == 0 { - addresses = []string{":8080"} + address := "localhost:8080" + if a := os.Getenv("PORT"); a != "" { + address = ":" + a + } + addresses = []string{address} } // Register health service if necessary. diff --git a/grpc/server_test.go b/grpc/server_test.go index 67514c3..7673ba5 100644 --- a/grpc/server_test.go +++ b/grpc/server_test.go @@ -220,10 +220,10 @@ func TestRun(t *testing.T) { require.NoError(t, err) require.Equal(t, grpc_health_v1.HealthCheckResponse_SERVING, hcResp.GetStatus()) - rfClient := grpc_reflection_v1.NewServerReflectionClient(conn) - rfResp, err := rfClient.ServerReflectionInfo(ctx) + refClient := grpc_reflection_v1.NewServerReflectionClient(conn) + stream, err := refClient.ServerReflectionInfo(ctx) require.NoError(t, err) - require.NotNil(t, rfResp) + require.NoError(t, stream.CloseSend()) if testcase.check != nil { testcase.check(conn) diff --git a/log/option.go b/log/option.go new file mode 100644 index 0000000..5003aaa --- /dev/null +++ b/log/option.go @@ -0,0 +1,46 @@ +// Copyright (c) 2024 The nilgo authors +// Use of this source code is governed by a MIT license found in the LICENSE file. + +package log + +import ( + "context" + "log/slog" +) + +// WithHandler provides a customized slog.Handler. +// +// By default, it uses the default handler in [slog]. +func WithHandler(handler slog.Handler) Option { + return func(options *options) { + options.handler = handler + } +} + +// WithSampler provides a sampler function which decides whether Info logs should write to output. +// +// By default, it disables simpling with nil sampler. +func WithSampler(sampler func(context.Context) bool) Option { + return func(options *options) { + options.sampler = sampler + } +} + +// WithLogAsTraceEvent enables logging as trace event. +// +// It could significantly reduce the log volume then cost as trace is priced by number of span. +func WithLogAsTraceEvent() Option { + return func(options *options) { + options.asTraceEvent = true + } +} + +type ( + // Option configures the logger with specific options. + Option func(*options) + options struct { + handler slog.Handler + sampler func(context.Context) bool + asTraceEvent bool + } +) diff --git a/log/slog.go b/log/slog.go new file mode 100644 index 0000000..f0e7a90 --- /dev/null +++ b/log/slog.go @@ -0,0 +1,66 @@ +// Copyright (c) 2024 The nilgo authors +// Use of this source code is governed by a MIT license found in the LICENSE file. + +// Package log provides a structured logging base on [slog]. +// +// # Sampling +// +// It supports sampling Info logs to reduce the log volume using sampler provided by WithSampler. +// However, if there exists an Error log, it records buffered Info logs for same request event they are not sampled. +// It provides context for root cause analysis while Error happens. +// +// # Trace Integration +// +// It supports recording log records as trace span's events if it's enabled by WithLogAsTraceEvent. +// It could significantly reduce the log volume then cost as trace is priced by number of span. +// +// # Rate limiting +// +// Applications often experience runs of errors, either because of a bug or because of a misbehaving user. +// Logging errors is usually a good idea, but it can easily make this bad situation worse: +// not only is your application coping with a flood of errors, it's also spending extra CPU cycles and I/O +// logging those errors. Since writes are typically serialized, logging limits throughput when you need it most. +// +// Rate limiting fixes this problem by dropping repetitive log entries. Under normal conditions, +// your application writes out every entry. When similar entries are logged hundreds or thousands of times each second, +// though, it begins dropping duplicates to preserve throughput. +package log + +import ( + "context" + "log/slog" + + "github.com/nil-go/sloth/otel" + "github.com/nil-go/sloth/rate" + "github.com/nil-go/sloth/sampling" + "go.opentelemetry.io/otel/trace" +) + +// New creates a new slog.Logger with the given Option(s). +func New(opts ...Option) *slog.Logger { + option := options{} + for _, opt := range opts { + opt(&option) + } + if option.handler == nil { + return slog.Default() + } + + var traceOpt []otel.Option + if option.asTraceEvent { + // If the logger is configured to log as trace event, it disables sampling. + // However, sampling handler still can buffer and logs if there is a error log, + // or there is no valid trace context. + option.sampler = func(ctx context.Context) bool { return !trace.SpanContextFromContext(ctx).IsValid() } + traceOpt = append(traceOpt, otel.WithRecordEvent(true)) + } + + var handler slog.Handler + handler = rate.New(option.handler) + if option.sampler != nil { + handler = sampling.New(handler, option.sampler) + } + handler = otel.New(handler, traceOpt...) + + return slog.New(handler) +} diff --git a/log/slog_test.go b/log/slog_test.go new file mode 100644 index 0000000..e9b588f --- /dev/null +++ b/log/slog_test.go @@ -0,0 +1,100 @@ +// Copyright (c) 2024 The nilgo authors +// Use of this source code is governed by a MIT license found in the LICENSE file. + +package log_test + +import ( + "bytes" + "context" + "log/slog" + "testing" + + "github.com/nil-go/sloth/sampling" + "go.opentelemetry.io/otel/trace" + + "github.com/nil-go/nilgo/internal/assert" + "github.com/nil-go/nilgo/log" +) + +func TestNew(t *testing.T) { + t.Parallel() + + testcases := []struct { + description string + opts []log.Option + fn func(context.Context, *slog.Logger) + expected string + }{ + { + description: "with handler", + fn: func(ctx context.Context, logger *slog.Logger) { + logger.InfoContext(ctx, "info log") + logger.ErrorContext(ctx, "error log") + }, + expected: `{"level":"INFO","msg":"info log"} +{"level":"ERROR","msg":"error log"} +`, + }, + { + description: "with sampler (info only)", + opts: []log.Option{ + log.WithSampler(func(context.Context) bool { return false }), + }, + fn: func(ctx context.Context, logger *slog.Logger) { + logger.InfoContext(ctx, "info log") + }, + }, + { + description: "with sampler", + opts: []log.Option{ + log.WithSampler(func(context.Context) bool { return false }), + }, + fn: func(ctx context.Context, logger *slog.Logger) { + logger.InfoContext(ctx, "info log") + logger.ErrorContext(ctx, "error log") + }, + expected: `{"level":"INFO","msg":"info log"} +{"level":"ERROR","msg":"error log"} +`, + }, + { + description: "with log as trace event", + opts: []log.Option{ + log.WithLogAsTraceEvent(), + }, + fn: func(ctx context.Context, logger *slog.Logger) { + ctx = trace.ContextWithSpanContext(ctx, trace.NewSpanContext(trace.SpanContextConfig{ + TraceID: [16]byte{75, 249, 47, 53, 119, 179, 77, 166, 163, 206, 146, 157, 14, 14, 71, 54}, + SpanID: [8]byte{0, 240, 103, 170, 11, 169, 2, 183}, + TraceFlags: trace.TraceFlags(1), + })) + logger.InfoContext(ctx, "info log") + }, + }, + } + + for _, testcase := range testcases { + testcase := testcase + + t.Run(testcase.description, func(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + logger := log.New(append(testcase.opts, log.WithHandler(slog.NewJSONHandler(&buf, &slog.HandlerOptions{ + ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr { + if a.Key == slog.TimeKey && len(groups) == 0 { + return slog.Attr{} + } + + return a + }, + })))...) + + ctx, cancel := sampling.WithBuffer(context.Background()) + defer cancel() + testcase.fn(ctx, logger) + + assert.Equal(t, testcase.expected, buf.String()) + }) + } +} diff --git a/run.go b/run.go new file mode 100644 index 0000000..4adf021 --- /dev/null +++ b/run.go @@ -0,0 +1,70 @@ +// Copyright (c) 2024 The nilgo authors +// Use of this source code is governed by a MIT license found in the LICENSE file. + +package nilgo + +import ( + "context" + "fmt" + "log/slog" + + "github.com/nil-go/konf" + + "github.com/nil-go/nilgo/config" + "github.com/nil-go/nilgo/log" + "github.com/nil-go/nilgo/run" +) + +// Run runs application with the given arguments synchronously. +// +// The runner passed in are running parallel without explicit order, +// which means it should not have temporal dependency between each other. +// +// The running can be interrupted if any runner returns non-nil error, or it receives an OS signal. +// It waits all runners return unless it's forcefully killed by OS. +// +// For now, it only can pass one of following types for args: +// - config.Option +// - log.Option +// - run.Option +// - func(context.Context) error +func Run(args ...any) error { + var ( + configOpts []config.Option + logOpts []log.Option + runOpts []run.Option + runners []func(context.Context) error + ) + for _, arg := range args { + switch opt := arg.(type) { + case config.Option: + configOpts = append(configOpts, opt) + case log.Option: + logOpts = append(logOpts, opt) + case run.Option: + runOpts = append(runOpts, opt) + case func(context.Context) error: + runners = append(runners, opt) + default: + return fmt.Errorf("unknown argument type: %T", opt) //nolint:goerr113 + } + } + + // Initialize the global konf.Config. + cfg, err := config.New(configOpts...) + if err != nil { + return fmt.Errorf("init config: %w", err) + } + konf.SetDefault(cfg) + + // Initialize the global slog.Logger. + logger := log.New(logOpts...) + slog.SetDefault(logger) + + runner := run.New(append(runOpts, run.WithPreRun(cfg.Watch))...) + if err := runner.Run(context.Background(), runners...); err != nil { + return fmt.Errorf("run: %w", err) + } + + return nil +} diff --git a/run/runner.go b/run/runner.go index 8a84a4d..0ead789 100644 --- a/run/runner.go +++ b/run/runner.go @@ -50,7 +50,7 @@ func (r Runner) Run(ctx context.Context, runs ...func(context.Context) error) er run := run allRuns = append(allRuns, func(ctx context.Context) error { - defer waitGroup.Done() + waitGroup.Done() return run(ctx) }, diff --git a/run/runner_test.go b/run/runner_test.go index 73d37d8..cb19682 100644 --- a/run/runner_test.go +++ b/run/runner_test.go @@ -35,8 +35,12 @@ func TestRunner_Run(t *testing.T) { }, { description: "with pre-run", - runner: run.New(run.WithPreRun(func(context.Context) error { return nil })), - ran: true, + runner: run.New(run.WithPreRun(func(ctx context.Context) error { + <-ctx.Done() + + return nil + })), + ran: true, }, { description: "pre-run error", diff --git a/run_test.go b/run_test.go new file mode 100644 index 0000000..a75012c --- /dev/null +++ b/run_test.go @@ -0,0 +1,97 @@ +// Copyright (c) 2024 The nilgo authors +// Use of this source code is governed by a MIT license found in the LICENSE file. + +package nilgo_test + +import ( + "bytes" + "context" + "errors" + "log/slog" + "testing" + "testing/fstest" + + "github.com/nil-go/konf" + + "github.com/nil-go/nilgo" + "github.com/nil-go/nilgo/config" + "github.com/nil-go/nilgo/internal/assert" + "github.com/nil-go/nilgo/log" + "github.com/nil-go/nilgo/run" +) + +func TestRun(t *testing.T) { + var ( + buf bytes.Buffer + started bool + ) + + err := nilgo.Run( + log.WithHandler(slog.NewJSONHandler(&buf, &slog.HandlerOptions{ + ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr { + if a.Key == slog.TimeKey && len(groups) == 0 { + return slog.Attr{} + } + + return a + }, + })), + config.WithFS(fstest.MapFS{"config/config.yaml": {Data: []byte("nilgo:\n source: fs")}}), + run.WithPreRun(func(context.Context) error { + started = true + + return nil + }), + func(context.Context) error { + slog.Info("info log", "source", konf.Get[string]("nilgo.source")) + + return nil + }, + ) + + assert.NoError(t, err) + assert.Equal(t, true, started) + assert.Equal(t, `{"level":"INFO","msg":"info log","source":"fs"} +`, buf.String()) +} + +func TestRun_error(t *testing.T) { + testcases := []struct { + description string + args []any + err string + }{ + { + description: "unknown argument type", + args: []any{"unknown"}, + err: "unknown argument type: string", + }, + { + description: "config error", + args: []any{ + config.With(func(*konf.Config) error { + return errors.New("config error") + }), + }, + err: "init config: config error", + }, + { + description: "runner error", + args: []any{ + func(context.Context) error { + return errors.New("runner error") + }, + }, + err: "run: runner error", + }, + } + + for _, testcase := range testcases { + testcase := testcase + + t.Run(testcase.description, func(t *testing.T) { + err := nilgo.Run(testcase.args...) + assert.Equal(t, testcase.err, err.Error()) + }) + } +}