diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..7f618d5 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,3 @@ +* @samcm +* @savid +* @mattevans \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..11258d1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,24 @@ +name: ci + +on: + push: + branches: + - master + pull_request: + +permissions: + contents: read + +jobs: + go-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/workflows/go-setup + - run: go test -v -race ./... + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: golangci/golangci-lint-action@v3 diff --git a/.github/workflows/go-setup/action.yml b/.github/workflows/go-setup/action.yml new file mode 100644 index 0000000..99ff074 --- /dev/null +++ b/.github/workflows/go-setup/action.yml @@ -0,0 +1,17 @@ +name: 'Go Setup' +description: 'Sets up Go environment with caching' + +inputs: + go-version: + description: 'Go version to use' + required: false + default: '1.23.4' + +runs: + using: "composite" + steps: + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ inputs.go-version }} + cache: true \ No newline at end of file diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..67ba395 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,75 @@ +linters-settings: + errcheck: + check-type-assertions: true + goconst: + min-len: 2 + min-occurrences: 3 + gocritic: + enabled-tags: + - diagnostic + - experimental + - opinionated + - performance + - style + govet: + enable: + - shadow + nolintlint: + require-explanation: true + require-specific: true +issues: + exclude-rules: + # Exclude some linters from running on tests files. + - path: _test\.go + linters: + - gocritic + - gosec + - wsl +linters: + disable-all: true + enable: + - asasalint + - bidichk + - bodyclose + - containedctx + - decorder + - dogsled + - durationcheck + - errcheck + - errname + - copyloopvar + - goconst + - gocyclo + - gofmt + - godot + - goimports + - goprintffuncname + - gosec + - goheader + - gosimple + - govet + - ineffassign + - misspell + - nakedret + - nilerr + - nilnil + - nlreturn + - nolintlint + - nosprintfhostport + - prealloc + - predeclared + - promlinter + - reassign + - staticcheck + - stylecheck + - tagliatelle + - thelper + - tparallel + - typecheck + - unconvert + - unused + - whitespace + - wsl + +run: + issues-exit-code: 1 \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5be835b --- /dev/null +++ b/Makefile @@ -0,0 +1,2 @@ +proto: + buf generate diff --git a/README.md b/README.md new file mode 100644 index 0000000..d173952 --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +# bamboo + +Bamboo is a collection of reusable Go packages used across EthPandaOps services, particularly [xatu](https://github.com/ethpandaops/xatu) and [contributoor](https://github.com/ethpandaops/contributoor). + +## 📦 Packages + +- `pkg/clockdrift` - NTP-based clock drift detection and compensation +- `proto` - Shared protocol buffer definitions + +## 🔨 Development + +
+ Go Tests + + Execute the full test suite: + + ```bash + go test ./... + ``` + + Run with coverage: + + ```bash + go test -failfast -cover -coverpkg=./... -coverprofile=coverage.out ./... && go tool cover -html=coverage.out + ``` +
+ diff --git a/buf.gen.yaml b/buf.gen.yaml new file mode 100644 index 0000000..3e8ee72 --- /dev/null +++ b/buf.gen.yaml @@ -0,0 +1,13 @@ +version: v2 +managed: + enabled: true + disable: + - module: buf.build/bufbuild/protovalidate + file_option: go_package_prefix +plugins: + - remote: buf.build/protocolbuffers/go:v1.34.2 + out: proto + opt: paths=source_relative + - remote: buf.build/bufbuild/validate-go:v1.1.0 + out: proto + opt: paths=source_relative diff --git a/buf.lock b/buf.lock new file mode 100644 index 0000000..e4ca18b --- /dev/null +++ b/buf.lock @@ -0,0 +1,6 @@ +# Generated by buf. DO NOT EDIT. +version: v2 +deps: + - name: buf.build/bufbuild/protovalidate + commit: a3320276596649bcad929ac829d451f4 + digest: b5:285a6d3a423b195a21f45aacc97ee222ac09cfb01a42f0d546aa51d92177b0b9d00eb9ae93e72dabbbefdc77f35a4c7a11f15d913cc08da764fcb6071f85d148 diff --git a/buf.yaml b/buf.yaml new file mode 100644 index 0000000..ccdf285 --- /dev/null +++ b/buf.yaml @@ -0,0 +1,12 @@ +version: v2 +modules: + - path: proto + name: github.com/ethpandaops/bamboo +lint: + use: + - STANDARD +breaking: + use: + - FILE +deps: + - buf.build/bufbuild/protovalidate diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8b0daaa --- /dev/null +++ b/go.mod @@ -0,0 +1,32 @@ +module github.com/ethpandaops/bamboo + +go 1.23.4 + +require ( + buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.2-20241127180247-a33202765966.1 + github.com/beevik/ntp v1.4.3 + github.com/bufbuild/protovalidate-go v0.8.2 + github.com/go-co-op/gocron v1.37.0 + github.com/sirupsen/logrus v1.9.3 + github.com/stretchr/testify v1.10.0 + google.golang.org/protobuf v1.36.2 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + cel.dev/expr v0.18.0 // indirect + github.com/antlr4-go/antlr/v4 v4.13.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/google/cel-go v0.22.1 // indirect + github.com/google/uuid v1.4.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect + github.com/stoewer/go-strcase v1.3.0 // indirect + go.uber.org/atomic v1.9.0 // indirect + golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 // indirect + golang.org/x/net v0.26.0 // indirect + golang.org/x/sys v0.21.0 // indirect + golang.org/x/text v0.16.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..44c5b62 --- /dev/null +++ b/go.sum @@ -0,0 +1,80 @@ +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.2-20241127180247-a33202765966.1 h1:BICM6du/XzvEgeorNo4xgohK3nMTmEPViGyd5t7xVqk= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.2-20241127180247-a33202765966.1/go.mod h1:JnMVLi3qrNYPODVpEKG7UjHLl/d2zR221e66YCSmP2Q= +cel.dev/expr v0.18.0 h1:CJ6drgk+Hf96lkLikr4rFf19WrU0BOWEihyZnI2TAzo= +cel.dev/expr v0.18.0/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw= +github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= +github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= +github.com/beevik/ntp v1.4.3 h1:PlbTvE5NNy4QHmA4Mg57n7mcFTmr1W1j3gcK7L1lqho= +github.com/beevik/ntp v1.4.3/go.mod h1:Unr8Zg+2dRn7d8bHFuehIMSvvUYssHMxW3Q5Nx4RW5Q= +github.com/bufbuild/protovalidate-go v0.8.2 h1:sgzXHkHYP6HnAsL2Rd3I1JxkYUyEQUv9awU1PduMxbM= +github.com/bufbuild/protovalidate-go v0.8.2/go.mod h1:K6w8iPNAXBoIivVueSELbUeUl+MmeTQfCDSug85pn3M= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/envoyproxy/protoc-gen-validate v1.1.0 h1:tntQDh69XqOCOZsDz0lVJQez/2L6Uu2PdjCQwWCJ3bM= +github.com/envoyproxy/protoc-gen-validate v1.1.0/go.mod h1:sXRDRVmzEbkM7CVcM06s9shE/m23dg3wzjl0UWqJ2q4= +github.com/go-co-op/gocron v1.37.0 h1:ZYDJGtQ4OMhTLKOKMIch+/CY70Brbb1dGdooLEhh7b0= +github.com/go-co-op/gocron v1.37.0/go.mod h1:3L/n6BkO7ABj+TrfSVXLRzsP26zmikL4ISkLQ0O8iNY= +github.com/google/cel-go v0.22.1 h1:AfVXx3chM2qwoSbM7Da8g8hX8OVSkBFwX+rz2+PcK40= +github.com/google/cel-go v0.22.1/go.mod h1:BuznPXXfQDpXKWQ9sPW3TzlAJN5zzFe+i9tIs0yC4s8= +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/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= +github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +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/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.8.1 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XFkP+Eg= +github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= +github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 h1:aAcj0Da7eBAtrTp03QXWvm88pSyOt+UgdZw2BFZ+lEw= +golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8/go.mod h1:CQ1k9gNrJ50XIzaKCRR2hssIjF07kZFEiieALBM/ARQ= +golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= +golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7 h1:YcyjlL1PRr2Q17/I0dPk2JmYS5CDXfcdb2Z3YRioEbw= +google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7/go.mod h1:OCdP9MfskevB/rbYvHTsXTtKC+3bHWajPdoKgjcYkfo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7 h1:2035KHhUv+EpyB+hWgJnaWKJOdX1E95w2S8Rr4uWKTs= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= +google.golang.org/protobuf v1.36.2 h1:R8FeyR1/eLmkutZOM5CWghmo5itiG9z0ktFlTVLuTmU= +google.golang.org/protobuf v1.36.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/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/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +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/pkg/clockdrift/README.md b/pkg/clockdrift/README.md new file mode 100644 index 0000000..5ff6434 --- /dev/null +++ b/pkg/clockdrift/README.md @@ -0,0 +1,29 @@ +# clockdrift + +The `clockdrift` package provides a service for detecting and compensating for system clock drift using NTP (Network Time Protocol). + +## Usage + +```go +import "github.com/ethpandaops/bamboo/pkg/clockdrift" + +// Create a new service. +config := &clockdrift.ClockDriftConfig{ + NTPServer: "pool.ntp.org", + SyncInterval: 5 * time.Minute, +} +service := clockdrift.NewService(logger, config) + +// Start the service. +if err := service.Start(context.Background()); err != nil { + log.Fatal(err) +} +defer service.Stop(context.Background()) + +// Get current drift. +drift := service.GetDrift() + +// Get time adjusted for drift. +now := service.Now() +``` + diff --git a/pkg/clockdrift/clockdrift.go b/pkg/clockdrift/clockdrift.go new file mode 100644 index 0000000..c39523a --- /dev/null +++ b/pkg/clockdrift/clockdrift.go @@ -0,0 +1,107 @@ +package clockdrift + +import ( + "context" + "sync" + "time" + + "github.com/beevik/ntp" + "github.com/go-co-op/gocron" + "github.com/sirupsen/logrus" +) + +// ClockDrift is the interface that wraps the methods for the clock drift service. +type ClockDrift interface { + // GetDrift returns the current clock drift. + GetDrift() time.Duration + // Now returns the current time adjusted for clock drift. + Now() time.Time +} + +// Service is the clock drift service. +type Service struct { + config *ClockDriftConfig + log logrus.FieldLogger + scheduler *gocron.Scheduler + clockDrift time.Duration + mu sync.RWMutex +} + +// ClockDriftConfig is the configuration for the clock drift service. +type ClockDriftConfig struct { + // NTP server to use for syncing + NTPServer string `yaml:"ntpServer" default:"pool.ntp.org"` + // How often to sync clock drift + SyncInterval time.Duration `yaml:"syncInterval" default:"5m"` +} + +// NewService creates a new clock drift service. +func NewService(log logrus.FieldLogger, config *ClockDriftConfig) *Service { + return &Service{ + config: config, + log: log.WithField("service", "clockdrift"), + scheduler: gocron.NewScheduler(time.Local), + } +} + +// Start starts the clock drift service. +func (s *Service) Start(_ context.Context) error { + if err := s.syncDrift(); err != nil { + s.log.WithError(err).Error("Failed initial clock drift sync") + } + + if _, err := s.scheduler.Every(s.config.SyncInterval).Do(func() { + if err := s.syncDrift(); err != nil { + s.log.WithError(err).Error("Failed to sync clock drift") + } + }); err != nil { + return err + } + + s.scheduler.StartAsync() + + return nil +} + +// Stop stops the clock drift service. +func (s *Service) Stop(_ context.Context) error { + s.scheduler.Stop() + + return nil +} + +// GetDrift returns the current clock drift. +func (s *Service) GetDrift() time.Duration { + s.mu.RLock() + defer s.mu.RUnlock() + + return s.clockDrift +} + +// Now returns the current time adjusted for clock drift. +func (s *Service) Now() time.Time { + return time.Now().Add(s.GetDrift()) +} + +func (s *Service) syncDrift() error { + response, err := ntp.Query(s.config.NTPServer) + if err != nil { + return err + } + + if err = response.Validate(); err != nil { + return err + } + + s.mu.Lock() + s.clockDrift = response.ClockOffset + s.mu.Unlock() + + s.log.WithField("drift", s.clockDrift).Info("Updated clock drift") + + if s.clockDrift > 2*time.Second || s.clockDrift < -2*time.Second { + s.log.WithField("drift", s.clockDrift).Warn("Large clock drift detected") + } + + return nil +} diff --git a/pkg/clockdrift/clockdrift_test.go b/pkg/clockdrift/clockdrift_test.go new file mode 100644 index 0000000..bd5d985 --- /dev/null +++ b/pkg/clockdrift/clockdrift_test.go @@ -0,0 +1,132 @@ +package clockdrift + +import ( + "context" + "testing" + "time" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewService(t *testing.T) { + log := logrus.New() + config := &ClockDriftConfig{ + NTPServer: "pool.ntp.org", + SyncInterval: 5 * time.Minute, + } + + service := NewService(log, config) + assert.NotNil(t, service) + assert.Equal(t, config, service.config) + assert.NotNil(t, service.scheduler) +} + +func TestService_StartStop(t *testing.T) { + log := logrus.New() + config := &ClockDriftConfig{ + NTPServer: "pool.ntp.org", + SyncInterval: 5 * time.Minute, + } + + service := NewService(log, config) + ctx := context.Background() + + // Test Start. + err := service.Start(ctx) + require.NoError(t, err) + assert.True(t, service.scheduler.IsRunning()) + + // Test Stop. + err = service.Stop(ctx) + require.NoError(t, err) + assert.False(t, service.scheduler.IsRunning()) +} + +func TestService_GetDrift(t *testing.T) { + log := logrus.New() + config := &ClockDriftConfig{ + NTPServer: "pool.ntp.org", + SyncInterval: 5 * time.Minute, + } + + service := NewService(log, config) + + // Initial drift should be zero + assert.Equal(t, time.Duration(0), service.GetDrift()) + + // Set a mock drift + service.mu.Lock() + service.clockDrift = 1 * time.Second + service.mu.Unlock() + + // Check if we can read the drift + assert.Equal(t, 1*time.Second, service.GetDrift()) +} + +func TestService_Now(t *testing.T) { + log := logrus.New() + config := &ClockDriftConfig{ + NTPServer: "pool.ntp.org", + SyncInterval: 5 * time.Minute, + } + + service := NewService(log, config) + + // Set a known drift. + service.mu.Lock() + service.clockDrift = 1 * time.Second + service.mu.Unlock() + + // Get time before and after calling Now(). + beforeTime := time.Now() + driftTime := service.Now() + afterTime := time.Now() + + // The drift-adjusted time should be greater than beforeTime + drift + // and less than afterTime + drift. + assert.True(t, driftTime.After(beforeTime.Add(service.GetDrift()))) + assert.True(t, driftTime.Before(afterTime.Add(service.GetDrift()))) +} + +func TestService_SyncDrift(t *testing.T) { + log := logrus.New() + config := &ClockDriftConfig{ + NTPServer: "pool.ntp.org", + SyncInterval: 5 * time.Minute, + } + + service := NewService(log, config) + + // Test actual NTP sync. + err := service.syncDrift() + require.NoError(t, err) + + // Verify that we got some drift value. + drift := service.GetDrift() + assert.NotEqual(t, time.Duration(0), drift) +} + +func TestService_SyncDriftWithLargeDrift(t *testing.T) { + log := logrus.New() + config := &ClockDriftConfig{ + NTPServer: "pool.ntp.org", + SyncInterval: 5 * time.Minute, + } + + service := NewService(log, config) + + // Set a large drift manually to test warning. + service.mu.Lock() + service.clockDrift = 3 * time.Second + service.mu.Unlock() + + // Verify the drift is large. + drift := service.GetDrift() + assert.Equal(t, 3*time.Second, drift) +} + +func TestService_Interface(t *testing.T) { + var _ ClockDrift = (*Service)(nil) +} diff --git a/proto/contributoor/config/v1/config.go b/proto/contributoor/config/v1/config.go new file mode 100644 index 0000000..b01cd3d --- /dev/null +++ b/proto/contributoor/config/v1/config.go @@ -0,0 +1,184 @@ +package config + +import ( + "encoding/json" + "net" + "net/url" + "os" + "strings" + + "github.com/bufbuild/protovalidate-go" + "google.golang.org/protobuf/encoding/protojson" + "gopkg.in/yaml.v3" +) + +const ( + defaultMetricsHost = "127.0.0.1" + defaultMetricsPort = "9090" + defaultPprofHost = "127.0.0.1" + defaultPprofPort = "6060" +) + +var localHostnames = map[string]bool{ + "localhost": true, + "127.0.0.1": true, + "0.0.0.0": true, +} + +// isRunningInDocker checks if we're actually running inside a Docker container +// by looking for container-specific files and environment variables. +var isRunningInDocker = func() bool { + // Check for .dockerenv file. + if _, err := os.Stat("/.dockerenv"); err == nil { + return true + } + + // Check for docker-specific cgroup. + if data, err := os.ReadFile("/proc/1/cgroup"); err == nil { + return strings.Contains(string(data), "docker") + } + + return false +} + +// NewConfigFromPath loads a config from a YAML file and validates it. +func NewConfigFromPath(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + var yamlMap map[string]interface{} + if yerr := yaml.Unmarshal(data, &yamlMap); yerr != nil { + return nil, yerr + } + + jsonBytes, err := json.Marshal(yamlMap) + if err != nil { + return nil, err + } + + cfg := &Config{} + if jerr := protojson.Unmarshal(jsonBytes, cfg); jerr != nil { + return nil, jerr + } + + validator, err := protovalidate.New() + if err != nil { + return nil, err + } + + if verr := validator.Validate(cfg); verr != nil { + return nil, verr + } + + return cfg, nil +} + +// DisplayName returns the display name of the network. +func (n NetworkName) DisplayName() string { + switch n { + case NetworkName_NETWORK_NAME_MAINNET: + return "Mainnet" + case NetworkName_NETWORK_NAME_SEPOLIA: + return "Sepolia" + case NetworkName_NETWORK_NAME_HOLESKY: + return "Holesky" + default: + return "Unknown" + } +} + +// DisplayName returns the display name of the run method. +func (r RunMethod) DisplayName() string { + switch r { + case RunMethod_RUN_METHOD_DOCKER: + return "Docker" + case RunMethod_RUN_METHOD_SYSTEMD: + return "Systemd" + case RunMethod_RUN_METHOD_BINARY: + return "Binary" + default: + return "Unknown" + } +} + +// ParseAddress parses an address string into host and port components. +// If the address is empty, returns the default host and port. +// If only port is specified (":8080"), returns default host and the specified port. +func ParseAddress(address, defaultHost, defaultPort string) (host, port string) { + if address == "" { + return defaultHost, defaultPort + } + + // Handle ":port" format. + if strings.HasPrefix(address, ":") { + return defaultHost, strings.TrimPrefix(address, ":") + } + + // Parse as URL to handle http:// format. + u, err := url.Parse(address) + if err == nil && u.Host != "" { + h, p, e := net.SplitHostPort(u.Host) + if e == nil { + return h, p + } + } + + // Try to split raw host:port. + host, port, err = net.SplitHostPort(address) + if err == nil { + return host, port + } + + return defaultHost, defaultPort +} + +// GetMetricsHostPort returns the metrics host and port. +// If MetricsAddress is not set, returns default values. +func (c *Config) GetMetricsHostPort() (host, port string) { + if c.MetricsAddress == "" { + return "", "" + } + + return ParseAddress(c.MetricsAddress, defaultMetricsHost, defaultMetricsPort) +} + +// GetPprofHostPort returns the pprof host and port. +// If PprofAddress is not set, returns empty strings. +func (c *Config) GetPprofHostPort() (host, port string) { + if c.PprofAddress == "" { + return "", "" + } + + return ParseAddress(c.PprofAddress, defaultPprofHost, defaultPprofPort) +} + +// NodeAddress returns the beacon node address, rewriting local addresses +// to use host.docker.internal when running in Docker. +// Docker containers can't directly access the host via localhost/127.0.0.1. +// We rewrite these to host.docker.internal which resolves differently per platform: +// - macOS: Built-in DNS name that points to the Docker Desktop VM's gateway +// - Linux: Maps to host-gateway via extra_hosts in docker-compose.yml +// This provides a consistent way to access the host machine across platforms. +func (c *Config) NodeAddress() string { + // Only rewrite if: + // 1. We have a beacon node address. + // 2. Docker is configured as the run method. + // 3. We're actually running inside a Docker container. + if c.BeaconNodeAddress == "" || + c.RunMethod != RunMethod_RUN_METHOD_DOCKER || + !isRunningInDocker() { + return c.BeaconNodeAddress + } + + // Check if URL points to a local address. + for hostname := range localHostnames { + if strings.Contains(c.BeaconNodeAddress, hostname) { + // Replace the local hostname with host.docker.internal. + return strings.Replace(c.BeaconNodeAddress, hostname, "host.docker.internal", 1) + } + } + + return c.BeaconNodeAddress +} diff --git a/proto/contributoor/config/v1/config.pb.go b/proto/contributoor/config/v1/config.pb.go new file mode 100644 index 0000000..c849205 --- /dev/null +++ b/proto/contributoor/config/v1/config.pb.go @@ -0,0 +1,478 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.34.2 +// protoc (unknown) +// source: contributoor/config/v1/config.proto + +package config + +import ( + _ "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// RunMethod defines how the service is run. +type RunMethod int32 + +const ( + // Invalid, should not be used. + RunMethod_RUN_METHOD_UNSPECIFIED RunMethod = 0 + // Run using docker. + RunMethod_RUN_METHOD_DOCKER RunMethod = 1 + // Run using systemd/launchd service manager. + RunMethod_RUN_METHOD_SYSTEMD RunMethod = 2 + // Run directly as a binary. + RunMethod_RUN_METHOD_BINARY RunMethod = 3 +) + +// Enum value maps for RunMethod. +var ( + RunMethod_name = map[int32]string{ + 0: "RUN_METHOD_UNSPECIFIED", + 1: "RUN_METHOD_DOCKER", + 2: "RUN_METHOD_SYSTEMD", + 3: "RUN_METHOD_BINARY", + } + RunMethod_value = map[string]int32{ + "RUN_METHOD_UNSPECIFIED": 0, + "RUN_METHOD_DOCKER": 1, + "RUN_METHOD_SYSTEMD": 2, + "RUN_METHOD_BINARY": 3, + } +) + +func (x RunMethod) Enum() *RunMethod { + p := new(RunMethod) + *p = x + return p +} + +func (x RunMethod) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (RunMethod) Descriptor() protoreflect.EnumDescriptor { + return file_contributoor_config_v1_config_proto_enumTypes[0].Descriptor() +} + +func (RunMethod) Type() protoreflect.EnumType { + return &file_contributoor_config_v1_config_proto_enumTypes[0] +} + +func (x RunMethod) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use RunMethod.Descriptor instead. +func (RunMethod) EnumDescriptor() ([]byte, []int) { + return file_contributoor_config_v1_config_proto_rawDescGZIP(), []int{0} +} + +// NetworkName defines which Ethereum network the beacon node is running on. +type NetworkName int32 + +const ( + // Invalid, should not be used. + NetworkName_NETWORK_NAME_UNSPECIFIED NetworkName = 0 + // Ethereum mainnet. + NetworkName_NETWORK_NAME_MAINNET NetworkName = 1 + // Sepolia testnet. + NetworkName_NETWORK_NAME_SEPOLIA NetworkName = 2 + // Holesky testnet. + NetworkName_NETWORK_NAME_HOLESKY NetworkName = 3 +) + +// Enum value maps for NetworkName. +var ( + NetworkName_name = map[int32]string{ + 0: "NETWORK_NAME_UNSPECIFIED", + 1: "NETWORK_NAME_MAINNET", + 2: "NETWORK_NAME_SEPOLIA", + 3: "NETWORK_NAME_HOLESKY", + } + NetworkName_value = map[string]int32{ + "NETWORK_NAME_UNSPECIFIED": 0, + "NETWORK_NAME_MAINNET": 1, + "NETWORK_NAME_SEPOLIA": 2, + "NETWORK_NAME_HOLESKY": 3, + } +) + +func (x NetworkName) Enum() *NetworkName { + p := new(NetworkName) + *p = x + return p +} + +func (x NetworkName) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (NetworkName) Descriptor() protoreflect.EnumDescriptor { + return file_contributoor_config_v1_config_proto_enumTypes[1].Descriptor() +} + +func (NetworkName) Type() protoreflect.EnumType { + return &file_contributoor_config_v1_config_proto_enumTypes[1] +} + +func (x NetworkName) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use NetworkName.Descriptor instead. +func (NetworkName) EnumDescriptor() ([]byte, []int) { + return file_contributoor_config_v1_config_proto_rawDescGZIP(), []int{1} +} + +// Config represents the main configuration for the contributoor service. +type Config struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // LogLevel is the log level to use. + LogLevel string `protobuf:"bytes,1,opt,name=log_level,json=logLevel,proto3" json:"log_level,omitempty"` + // Version of the contributoor service running. + Version string `protobuf:"bytes,2,opt,name=version,proto3" json:"version,omitempty"` + // Directory where contributoor houses its configuration and related data. + ContributoorDirectory string `protobuf:"bytes,3,opt,name=contributoor_directory,json=contributoorDirectory,proto3" json:"contributoor_directory,omitempty"` + // RunMethod is the method used to run the contributoorservice. + RunMethod RunMethod `protobuf:"varint,4,opt,name=run_method,json=runMethod,proto3,enum=config.v1.RunMethod" json:"run_method,omitempty"` + // NetworkName is the name of the network the beacon node is running on. + NetworkName NetworkName `protobuf:"varint,5,opt,name=network_name,json=networkName,proto3,enum=config.v1.NetworkName" json:"network_name,omitempty"` + // BeaconNodeAddress is the address of the beacon node. + BeaconNodeAddress string `protobuf:"bytes,6,opt,name=beacon_node_address,json=beaconNodeAddress,proto3" json:"beacon_node_address,omitempty"` + // MetricsAddress is the address to serve metrics on. + MetricsAddress string `protobuf:"bytes,7,opt,name=metrics_address,json=metricsAddress,proto3" json:"metrics_address,omitempty"` + // PprofAddress is the address to serve pprof on. + PprofAddress string `protobuf:"bytes,8,opt,name=pprof_address,json=pprofAddress,proto3" json:"pprof_address,omitempty"` + // OutputServer is the configuration for the output server. + OutputServer *OutputServer `protobuf:"bytes,9,opt,name=output_server,json=outputServer,proto3" json:"output_server,omitempty"` +} + +func (x *Config) Reset() { + *x = Config{} + if protoimpl.UnsafeEnabled { + mi := &file_contributoor_config_v1_config_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Config) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Config) ProtoMessage() {} + +func (x *Config) ProtoReflect() protoreflect.Message { + mi := &file_contributoor_config_v1_config_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Config.ProtoReflect.Descriptor instead. +func (*Config) Descriptor() ([]byte, []int) { + return file_contributoor_config_v1_config_proto_rawDescGZIP(), []int{0} +} + +func (x *Config) GetLogLevel() string { + if x != nil { + return x.LogLevel + } + return "" +} + +func (x *Config) GetVersion() string { + if x != nil { + return x.Version + } + return "" +} + +func (x *Config) GetContributoorDirectory() string { + if x != nil { + return x.ContributoorDirectory + } + return "" +} + +func (x *Config) GetRunMethod() RunMethod { + if x != nil { + return x.RunMethod + } + return RunMethod_RUN_METHOD_UNSPECIFIED +} + +func (x *Config) GetNetworkName() NetworkName { + if x != nil { + return x.NetworkName + } + return NetworkName_NETWORK_NAME_UNSPECIFIED +} + +func (x *Config) GetBeaconNodeAddress() string { + if x != nil { + return x.BeaconNodeAddress + } + return "" +} + +func (x *Config) GetMetricsAddress() string { + if x != nil { + return x.MetricsAddress + } + return "" +} + +func (x *Config) GetPprofAddress() string { + if x != nil { + return x.PprofAddress + } + return "" +} + +func (x *Config) GetOutputServer() *OutputServer { + if x != nil { + return x.OutputServer + } + return nil +} + +// OutputServer represents configuration for the output server. +type OutputServer struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Address of the output server, eg: where the Xatu output server is running. + Address string `protobuf:"bytes,1,opt,name=address,proto3" json:"address,omitempty"` + // Optional credentials for authentication, this is a base64 encoded string in the format of "username:password". + Credentials string `protobuf:"bytes,2,opt,name=credentials,proto3" json:"credentials,omitempty"` + // TLS is whether to use TLS for the output server. + Tls bool `protobuf:"varint,3,opt,name=tls,proto3" json:"tls,omitempty"` +} + +func (x *OutputServer) Reset() { + *x = OutputServer{} + if protoimpl.UnsafeEnabled { + mi := &file_contributoor_config_v1_config_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *OutputServer) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OutputServer) ProtoMessage() {} + +func (x *OutputServer) ProtoReflect() protoreflect.Message { + mi := &file_contributoor_config_v1_config_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OutputServer.ProtoReflect.Descriptor instead. +func (*OutputServer) Descriptor() ([]byte, []int) { + return file_contributoor_config_v1_config_proto_rawDescGZIP(), []int{1} +} + +func (x *OutputServer) GetAddress() string { + if x != nil { + return x.Address + } + return "" +} + +func (x *OutputServer) GetCredentials() string { + if x != nil { + return x.Credentials + } + return "" +} + +func (x *OutputServer) GetTls() bool { + if x != nil { + return x.Tls + } + return false +} + +var File_contributoor_config_v1_config_proto protoreflect.FileDescriptor + +var file_contributoor_config_v1_config_proto_rawDesc = []byte{ + 0x0a, 0x23, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x6f, 0x6f, 0x72, 0x2f, 0x63, + 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2f, 0x76, 0x31, 0x2f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x09, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x76, 0x31, + 0x1a, 0x1b, 0x62, 0x75, 0x66, 0x2f, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x2f, 0x76, + 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xc3, 0x03, + 0x0a, 0x06, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1b, 0x0a, 0x09, 0x6c, 0x6f, 0x67, 0x5f, + 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x6c, 0x6f, 0x67, + 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, + 0x35, 0x0a, 0x16, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x6f, 0x6f, 0x72, 0x5f, + 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x15, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x6f, 0x6f, 0x72, 0x44, 0x69, 0x72, + 0x65, 0x63, 0x74, 0x6f, 0x72, 0x79, 0x12, 0x3d, 0x0a, 0x0a, 0x72, 0x75, 0x6e, 0x5f, 0x6d, 0x65, + 0x74, 0x68, 0x6f, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x14, 0x2e, 0x63, 0x6f, 0x6e, + 0x66, 0x69, 0x67, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x75, 0x6e, 0x4d, 0x65, 0x74, 0x68, 0x6f, 0x64, + 0x42, 0x08, 0xba, 0x48, 0x05, 0x82, 0x01, 0x02, 0x20, 0x00, 0x52, 0x09, 0x72, 0x75, 0x6e, 0x4d, + 0x65, 0x74, 0x68, 0x6f, 0x64, 0x12, 0x43, 0x0a, 0x0c, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, + 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x16, 0x2e, 0x63, 0x6f, + 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x76, 0x31, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4e, + 0x61, 0x6d, 0x65, 0x42, 0x08, 0xba, 0x48, 0x05, 0x82, 0x01, 0x02, 0x20, 0x00, 0x52, 0x0b, 0x6e, + 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x3b, 0x0a, 0x13, 0x62, 0x65, + 0x61, 0x63, 0x6f, 0x6e, 0x5f, 0x6e, 0x6f, 0x64, 0x65, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, + 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x42, 0x0b, 0xba, 0x48, 0x08, 0xd8, 0x01, 0x01, 0x72, + 0x03, 0x88, 0x01, 0x01, 0x52, 0x11, 0x62, 0x65, 0x61, 0x63, 0x6f, 0x6e, 0x4e, 0x6f, 0x64, 0x65, + 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x27, 0x0a, 0x0f, 0x6d, 0x65, 0x74, 0x72, 0x69, + 0x63, 0x73, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x0e, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, + 0x12, 0x23, 0x0a, 0x0d, 0x70, 0x70, 0x72, 0x6f, 0x66, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, + 0x73, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x70, 0x70, 0x72, 0x6f, 0x66, 0x41, 0x64, + 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x3c, 0x0a, 0x0d, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x5f, + 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x63, + 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x76, 0x31, 0x2e, 0x4f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x53, + 0x65, 0x72, 0x76, 0x65, 0x72, 0x52, 0x0c, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x53, 0x65, 0x72, + 0x76, 0x65, 0x72, 0x22, 0x69, 0x0a, 0x0c, 0x4f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x53, 0x65, 0x72, + 0x76, 0x65, 0x72, 0x12, 0x25, 0x0a, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x42, 0x0b, 0xba, 0x48, 0x08, 0xd8, 0x01, 0x01, 0x72, 0x03, 0x88, 0x01, + 0x01, 0x52, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x20, 0x0a, 0x0b, 0x63, 0x72, + 0x65, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x61, 0x6c, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x0b, 0x63, 0x72, 0x65, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x61, 0x6c, 0x73, 0x12, 0x10, 0x0a, 0x03, + 0x74, 0x6c, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x03, 0x74, 0x6c, 0x73, 0x2a, 0x6d, + 0x0a, 0x09, 0x52, 0x75, 0x6e, 0x4d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x12, 0x1a, 0x0a, 0x16, 0x52, + 0x55, 0x4e, 0x5f, 0x4d, 0x45, 0x54, 0x48, 0x4f, 0x44, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, + 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x15, 0x0a, 0x11, 0x52, 0x55, 0x4e, 0x5f, 0x4d, + 0x45, 0x54, 0x48, 0x4f, 0x44, 0x5f, 0x44, 0x4f, 0x43, 0x4b, 0x45, 0x52, 0x10, 0x01, 0x12, 0x16, + 0x0a, 0x12, 0x52, 0x55, 0x4e, 0x5f, 0x4d, 0x45, 0x54, 0x48, 0x4f, 0x44, 0x5f, 0x53, 0x59, 0x53, + 0x54, 0x45, 0x4d, 0x44, 0x10, 0x02, 0x12, 0x15, 0x0a, 0x11, 0x52, 0x55, 0x4e, 0x5f, 0x4d, 0x45, + 0x54, 0x48, 0x4f, 0x44, 0x5f, 0x42, 0x49, 0x4e, 0x41, 0x52, 0x59, 0x10, 0x03, 0x2a, 0x79, 0x0a, + 0x0b, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1c, 0x0a, 0x18, + 0x4e, 0x45, 0x54, 0x57, 0x4f, 0x52, 0x4b, 0x5f, 0x4e, 0x41, 0x4d, 0x45, 0x5f, 0x55, 0x4e, 0x53, + 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x18, 0x0a, 0x14, 0x4e, 0x45, + 0x54, 0x57, 0x4f, 0x52, 0x4b, 0x5f, 0x4e, 0x41, 0x4d, 0x45, 0x5f, 0x4d, 0x41, 0x49, 0x4e, 0x4e, + 0x45, 0x54, 0x10, 0x01, 0x12, 0x18, 0x0a, 0x14, 0x4e, 0x45, 0x54, 0x57, 0x4f, 0x52, 0x4b, 0x5f, + 0x4e, 0x41, 0x4d, 0x45, 0x5f, 0x53, 0x45, 0x50, 0x4f, 0x4c, 0x49, 0x41, 0x10, 0x02, 0x12, 0x18, + 0x0a, 0x14, 0x4e, 0x45, 0x54, 0x57, 0x4f, 0x52, 0x4b, 0x5f, 0x4e, 0x41, 0x4d, 0x45, 0x5f, 0x48, + 0x4f, 0x4c, 0x45, 0x53, 0x4b, 0x59, 0x10, 0x03, 0x42, 0xa4, 0x01, 0x0a, 0x0d, 0x63, 0x6f, 0x6d, + 0x2e, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x76, 0x31, 0x42, 0x0b, 0x43, 0x6f, 0x6e, 0x66, + 0x69, 0x67, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x50, 0x01, 0x5a, 0x41, 0x67, 0x69, 0x74, 0x68, 0x75, + 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x65, 0x74, 0x68, 0x70, 0x61, 0x6e, 0x64, 0x61, 0x6f, 0x70, + 0x73, 0x2f, 0x62, 0x61, 0x6d, 0x62, 0x6f, 0x6f, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x63, + 0x6f, 0x6e, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x6f, 0x6f, 0x72, 0x2f, 0x63, 0x6f, 0x6e, 0x66, + 0x69, 0x67, 0x2f, 0x76, 0x31, 0x3b, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0xa2, 0x02, 0x03, 0x43, + 0x58, 0x58, 0xaa, 0x02, 0x09, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x56, 0x31, 0xca, 0x02, + 0x09, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x5c, 0x56, 0x31, 0xe2, 0x02, 0x15, 0x43, 0x6f, 0x6e, + 0x66, 0x69, 0x67, 0x5c, 0x56, 0x31, 0x5c, 0x47, 0x50, 0x42, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, + 0x74, 0x61, 0xea, 0x02, 0x0a, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x3a, 0x3a, 0x56, 0x31, 0x62, + 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_contributoor_config_v1_config_proto_rawDescOnce sync.Once + file_contributoor_config_v1_config_proto_rawDescData = file_contributoor_config_v1_config_proto_rawDesc +) + +func file_contributoor_config_v1_config_proto_rawDescGZIP() []byte { + file_contributoor_config_v1_config_proto_rawDescOnce.Do(func() { + file_contributoor_config_v1_config_proto_rawDescData = protoimpl.X.CompressGZIP(file_contributoor_config_v1_config_proto_rawDescData) + }) + return file_contributoor_config_v1_config_proto_rawDescData +} + +var file_contributoor_config_v1_config_proto_enumTypes = make([]protoimpl.EnumInfo, 2) +var file_contributoor_config_v1_config_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_contributoor_config_v1_config_proto_goTypes = []any{ + (RunMethod)(0), // 0: config.v1.RunMethod + (NetworkName)(0), // 1: config.v1.NetworkName + (*Config)(nil), // 2: config.v1.Config + (*OutputServer)(nil), // 3: config.v1.OutputServer +} +var file_contributoor_config_v1_config_proto_depIdxs = []int32{ + 0, // 0: config.v1.Config.run_method:type_name -> config.v1.RunMethod + 1, // 1: config.v1.Config.network_name:type_name -> config.v1.NetworkName + 3, // 2: config.v1.Config.output_server:type_name -> config.v1.OutputServer + 3, // [3:3] is the sub-list for method output_type + 3, // [3:3] is the sub-list for method input_type + 3, // [3:3] is the sub-list for extension type_name + 3, // [3:3] is the sub-list for extension extendee + 0, // [0:3] is the sub-list for field type_name +} + +func init() { file_contributoor_config_v1_config_proto_init() } +func file_contributoor_config_v1_config_proto_init() { + if File_contributoor_config_v1_config_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_contributoor_config_v1_config_proto_msgTypes[0].Exporter = func(v any, i int) any { + switch v := v.(*Config); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_contributoor_config_v1_config_proto_msgTypes[1].Exporter = func(v any, i int) any { + switch v := v.(*OutputServer); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_contributoor_config_v1_config_proto_rawDesc, + NumEnums: 2, + NumMessages: 2, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_contributoor_config_v1_config_proto_goTypes, + DependencyIndexes: file_contributoor_config_v1_config_proto_depIdxs, + EnumInfos: file_contributoor_config_v1_config_proto_enumTypes, + MessageInfos: file_contributoor_config_v1_config_proto_msgTypes, + }.Build() + File_contributoor_config_v1_config_proto = out.File + file_contributoor_config_v1_config_proto_rawDesc = nil + file_contributoor_config_v1_config_proto_goTypes = nil + file_contributoor_config_v1_config_proto_depIdxs = nil +} diff --git a/proto/contributoor/config/v1/config.pb.validate.go b/proto/contributoor/config/v1/config.pb.validate.go new file mode 100644 index 0000000..a77e7e5 --- /dev/null +++ b/proto/contributoor/config/v1/config.pb.validate.go @@ -0,0 +1,284 @@ +// Code generated by protoc-gen-validate. DO NOT EDIT. +// source: contributoor/config/v1/config.proto + +package config + +import ( + "bytes" + "errors" + "fmt" + "net" + "net/mail" + "net/url" + "regexp" + "sort" + "strings" + "time" + "unicode/utf8" + + "google.golang.org/protobuf/types/known/anypb" +) + +// ensure the imports are used +var ( + _ = bytes.MinRead + _ = errors.New("") + _ = fmt.Print + _ = utf8.UTFMax + _ = (*regexp.Regexp)(nil) + _ = (*strings.Reader)(nil) + _ = net.IPv4len + _ = time.Duration(0) + _ = (*url.URL)(nil) + _ = (*mail.Address)(nil) + _ = anypb.Any{} + _ = sort.Sort +) + +// Validate checks the field values on Config with the rules defined in the +// proto definition for this message. If any rules are violated, the first +// error encountered is returned, or nil if there are no violations. +func (m *Config) Validate() error { + return m.validate(false) +} + +// ValidateAll checks the field values on Config with the rules defined in the +// proto definition for this message. If any rules are violated, the result is +// a list of violation errors wrapped in ConfigMultiError, or nil if none found. +func (m *Config) ValidateAll() error { + return m.validate(true) +} + +func (m *Config) validate(all bool) error { + if m == nil { + return nil + } + + var errors []error + + // no validation rules for LogLevel + + // no validation rules for Version + + // no validation rules for ContributoorDirectory + + // no validation rules for RunMethod + + // no validation rules for NetworkName + + // no validation rules for BeaconNodeAddress + + // no validation rules for MetricsAddress + + // no validation rules for PprofAddress + + if all { + switch v := interface{}(m.GetOutputServer()).(type) { + case interface{ ValidateAll() error }: + if err := v.ValidateAll(); err != nil { + errors = append(errors, ConfigValidationError{ + field: "OutputServer", + reason: "embedded message failed validation", + cause: err, + }) + } + case interface{ Validate() error }: + if err := v.Validate(); err != nil { + errors = append(errors, ConfigValidationError{ + field: "OutputServer", + reason: "embedded message failed validation", + cause: err, + }) + } + } + } else if v, ok := interface{}(m.GetOutputServer()).(interface{ Validate() error }); ok { + if err := v.Validate(); err != nil { + return ConfigValidationError{ + field: "OutputServer", + reason: "embedded message failed validation", + cause: err, + } + } + } + + if len(errors) > 0 { + return ConfigMultiError(errors) + } + + return nil +} + +// ConfigMultiError is an error wrapping multiple validation errors returned by +// Config.ValidateAll() if the designated constraints aren't met. +type ConfigMultiError []error + +// Error returns a concatenation of all the error messages it wraps. +func (m ConfigMultiError) Error() string { + var msgs []string + for _, err := range m { + msgs = append(msgs, err.Error()) + } + return strings.Join(msgs, "; ") +} + +// AllErrors returns a list of validation violation errors. +func (m ConfigMultiError) AllErrors() []error { return m } + +// ConfigValidationError is the validation error returned by Config.Validate if +// the designated constraints aren't met. +type ConfigValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e ConfigValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e ConfigValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e ConfigValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e ConfigValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e ConfigValidationError) ErrorName() string { return "ConfigValidationError" } + +// Error satisfies the builtin error interface +func (e ConfigValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sConfig.%s: %s%s", + key, + e.field, + e.reason, + cause) +} + +var _ error = ConfigValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = ConfigValidationError{} + +// Validate checks the field values on OutputServer with the rules defined in +// the proto definition for this message. If any rules are violated, the first +// error encountered is returned, or nil if there are no violations. +func (m *OutputServer) Validate() error { + return m.validate(false) +} + +// ValidateAll checks the field values on OutputServer with the rules defined +// in the proto definition for this message. If any rules are violated, the +// result is a list of violation errors wrapped in OutputServerMultiError, or +// nil if none found. +func (m *OutputServer) ValidateAll() error { + return m.validate(true) +} + +func (m *OutputServer) validate(all bool) error { + if m == nil { + return nil + } + + var errors []error + + // no validation rules for Address + + // no validation rules for Credentials + + // no validation rules for Tls + + if len(errors) > 0 { + return OutputServerMultiError(errors) + } + + return nil +} + +// OutputServerMultiError is an error wrapping multiple validation errors +// returned by OutputServer.ValidateAll() if the designated constraints aren't met. +type OutputServerMultiError []error + +// Error returns a concatenation of all the error messages it wraps. +func (m OutputServerMultiError) Error() string { + var msgs []string + for _, err := range m { + msgs = append(msgs, err.Error()) + } + return strings.Join(msgs, "; ") +} + +// AllErrors returns a list of validation violation errors. +func (m OutputServerMultiError) AllErrors() []error { return m } + +// OutputServerValidationError is the validation error returned by +// OutputServer.Validate if the designated constraints aren't met. +type OutputServerValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e OutputServerValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e OutputServerValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e OutputServerValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e OutputServerValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e OutputServerValidationError) ErrorName() string { return "OutputServerValidationError" } + +// Error satisfies the builtin error interface +func (e OutputServerValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sOutputServer.%s: %s%s", + key, + e.field, + e.reason, + cause) +} + +var _ error = OutputServerValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = OutputServerValidationError{} diff --git a/proto/contributoor/config/v1/config.proto b/proto/contributoor/config/v1/config.proto new file mode 100644 index 0000000..75e3cd0 --- /dev/null +++ b/proto/contributoor/config/v1/config.proto @@ -0,0 +1,69 @@ +syntax = "proto3"; + +package config.v1; + +option go_package = "github.com/ethpandaops/bamboo/proto/contributoor/config/v1;config"; + +import "buf/validate/validate.proto"; + +// RunMethod defines how the service is run. +enum RunMethod { + // Invalid, should not be used. + RUN_METHOD_UNSPECIFIED = 0; + // Run using docker. + RUN_METHOD_DOCKER = 1; + // Run using systemd/launchd service manager. + RUN_METHOD_SYSTEMD = 2; + // Run directly as a binary. + RUN_METHOD_BINARY = 3; +} + +// NetworkName defines which Ethereum network the beacon node is running on. +enum NetworkName { + // Invalid, should not be used. + NETWORK_NAME_UNSPECIFIED = 0; + // Ethereum mainnet. + NETWORK_NAME_MAINNET = 1; + // Sepolia testnet. + NETWORK_NAME_SEPOLIA = 2; + // Holesky testnet. + NETWORK_NAME_HOLESKY = 3; +} + +// Config represents the main configuration for the contributoor service. +message Config { + // LogLevel is the log level to use. + string log_level = 1; + // Version of the contributoor service running. + string version = 2; + // Directory where contributoor houses its configuration and related data. + string contributoor_directory = 3; + // RunMethod is the method used to run the contributoorservice. + RunMethod run_method = 4 [(buf.validate.field).enum = { not_in: [0] }]; + // NetworkName is the name of the network the beacon node is running on. + NetworkName network_name = 5 [(buf.validate.field).enum = { not_in: [0] }]; + // BeaconNodeAddress is the address of the beacon node. + string beacon_node_address = 6 [ + (buf.validate.field).string.uri = true, + (buf.validate.field).ignore = IGNORE_IF_UNPOPULATED + ]; + // MetricsAddress is the address to serve metrics on. + string metrics_address = 7; + // PprofAddress is the address to serve pprof on. + string pprof_address = 8; + // OutputServer is the configuration for the output server. + OutputServer output_server = 9; +} + +// OutputServer represents configuration for the output server. +message OutputServer { + // Address of the output server, eg: where the Xatu output server is running. + string address = 1 [ + (buf.validate.field).string.uri = true, + (buf.validate.field).ignore = IGNORE_IF_UNPOPULATED + ]; + // Optional credentials for authentication, this is a base64 encoded string in the format of "username:password". + string credentials = 2; + // TLS is whether to use TLS for the output server. + bool tls = 3; +} diff --git a/proto/contributoor/config/v1/config_test.go b/proto/contributoor/config/v1/config_test.go new file mode 100644 index 0000000..76b9593 --- /dev/null +++ b/proto/contributoor/config/v1/config_test.go @@ -0,0 +1,342 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewConfigFromPath(t *testing.T) { + tests := []struct { + name string + config string + expectError bool + }{ + { + name: "valid config", + config: `version: 0.0.2 +networkName: NETWORK_NAME_MAINNET +beaconNodeAddress: http://localhost:5052 +contributoorDirectory: /tmp/contributoor +runMethod: RUN_METHOD_DOCKER +`, + expectError: false, + }, + { + name: "invalid network name", + config: `version: 0.0.2 +networkName: INVALID_NETWORK +beaconNodeAddress: http://localhost:5052 +contributoorDirectory: /tmp/contributoor +runMethod: RUN_METHOD_DOCKER +`, + expectError: true, + }, + { + name: "invalid network name", + config: `version: 0.0.2 +networkName: INVALID_NETWORK +beaconNodeAddress: http://localhost:5052 +contributoorDirectory: /tmp/contributoor +runMethod: RUN_METHOD_DOCKER +`, + expectError: true, + }, + { + name: "missing required field", + config: `version: 0.0.2 +networkName: NETWORK_NAME_MAINNET +`, + expectError: true, + }, + { + name: "invalid yaml", + config: `{[invalid yaml`, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a temporary file + tmpFile := filepath.Join(t.TempDir(), "config.yaml") + err := os.WriteFile(tmpFile, []byte(tt.config), 0o600) + require.NoError(t, err) + + // Test the config loading + cfg, err := NewConfigFromPath(tmpFile) + if tt.expectError { + require.Error(t, err) + + return + } + + require.NoError(t, err) + require.NotNil(t, cfg) + + // Assert we have valid config. + if !tt.expectError { + require.Equal(t, "0.0.2", cfg.Version) + require.Equal(t, NetworkName_NETWORK_NAME_MAINNET, cfg.NetworkName) + require.Equal(t, "http://localhost:5052", cfg.BeaconNodeAddress) + require.Equal(t, "/tmp/contributoor", cfg.ContributoorDirectory) + } + }) + } +} + +func TestNewConfigFromPath_NonExistentFile(t *testing.T) { + _, err := NewConfigFromPath("non_existent_file.yaml") + require.Error(t, err) +} + +func TestParseAddress(t *testing.T) { + tests := []struct { + name string + address string + defaultHost string + defaultPort string + expectedHost string + expectedPort string + }{ + { + name: "empty address returns defaults", + address: "", + defaultHost: "127.0.0.1", + defaultPort: "9090", + expectedHost: "127.0.0.1", + expectedPort: "9090", + }, + { + name: "port only returns default host", + address: ":8080", + defaultHost: "127.0.0.1", + defaultPort: "9090", + expectedHost: "127.0.0.1", + expectedPort: "8080", + }, + { + name: "full address", + address: "localhost:8080", + defaultHost: "127.0.0.1", + defaultPort: "9090", + expectedHost: "localhost", + expectedPort: "8080", + }, + { + name: "http url", + address: "http://localhost:8080", + defaultHost: "127.0.0.1", + defaultPort: "9090", + expectedHost: "localhost", + expectedPort: "8080", + }, + { + name: "https url", + address: "https://example.com:8080", + defaultHost: "127.0.0.1", + defaultPort: "9090", + expectedHost: "example.com", + expectedPort: "8080", + }, + { + name: "invalid address returns defaults", + address: "not:a:valid:address", + defaultHost: "127.0.0.1", + defaultPort: "9090", + expectedHost: "127.0.0.1", + expectedPort: "9090", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + host, port := ParseAddress(tt.address, tt.defaultHost, tt.defaultPort) + assert.Equal(t, tt.expectedHost, host) + assert.Equal(t, tt.expectedPort, port) + }) + } +} + +func TestConfig_GetMetricsHostPort(t *testing.T) { + tests := []struct { + name string + config *Config + expectedHost string + expectedPort string + }{ + { + name: "empty address returns empty strings", + config: &Config{MetricsAddress: ""}, + expectedHost: "", + expectedPort: "", + }, + { + name: "port only", + config: &Config{MetricsAddress: ":8080"}, + expectedHost: defaultMetricsHost, + expectedPort: "8080", + }, + { + name: "full address", + config: &Config{MetricsAddress: "localhost:8080"}, + expectedHost: "localhost", + expectedPort: "8080", + }, + { + name: "http url", + config: &Config{MetricsAddress: "http://localhost:8080"}, + expectedHost: "localhost", + expectedPort: "8080", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + host, port := tt.config.GetMetricsHostPort() + assert.Equal(t, tt.expectedHost, host) + assert.Equal(t, tt.expectedPort, port) + }) + } +} + +func TestConfig_GetPprofHostPort(t *testing.T) { + tests := []struct { + name string + config *Config + expectedHost string + expectedPort string + }{ + { + name: "empty address returns empty strings", + config: &Config{PprofAddress: ""}, + expectedHost: "", + expectedPort: "", + }, + { + name: "port only", + config: &Config{PprofAddress: ":8080"}, + expectedHost: defaultPprofHost, + expectedPort: "8080", + }, + { + name: "full address", + config: &Config{PprofAddress: "localhost:8080"}, + expectedHost: "localhost", + expectedPort: "8080", + }, + { + name: "http url", + config: &Config{PprofAddress: "http://localhost:8080"}, + expectedHost: "localhost", + expectedPort: "8080", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + host, port := tt.config.GetPprofHostPort() + assert.Equal(t, tt.expectedHost, host) + assert.Equal(t, tt.expectedPort, port) + }) + } +} + +func TestConfig_NodeAddress(t *testing.T) { + // Mock isRunningInDocker for testing + originalIsRunningInDocker := isRunningInDocker + defer func() { isRunningInDocker = originalIsRunningInDocker }() + + tests := []struct { + name string + config *Config + inDocker bool + expectedAddress string + }{ + { + name: "docker mode + in docker container + local url", + config: &Config{ + BeaconNodeAddress: "http://localhost:5052", + RunMethod: RunMethod_RUN_METHOD_DOCKER, + }, + inDocker: true, + expectedAddress: "http://host.docker.internal:5052", + }, + { + name: "docker mode + in docker container + 127.0.0.1", + config: &Config{ + BeaconNodeAddress: "http://127.0.0.1:5052", + RunMethod: RunMethod_RUN_METHOD_DOCKER, + }, + inDocker: true, + expectedAddress: "http://host.docker.internal:5052", + }, + { + name: "docker mode + in docker container + 0.0.0.0", + config: &Config{ + BeaconNodeAddress: "http://0.0.0.0:5052", + RunMethod: RunMethod_RUN_METHOD_DOCKER, + }, + inDocker: true, + expectedAddress: "http://host.docker.internal:5052", + }, + { + name: "docker mode + in docker container + remote url", + config: &Config{ + BeaconNodeAddress: "http://example.com:5052", + RunMethod: RunMethod_RUN_METHOD_DOCKER, + }, + inDocker: true, + expectedAddress: "http://example.com:5052", + }, + { + name: "docker mode + NOT in docker + local url", + config: &Config{ + BeaconNodeAddress: "http://localhost:5052", + RunMethod: RunMethod_RUN_METHOD_DOCKER, + }, + inDocker: false, + expectedAddress: "http://localhost:5052", + }, + { + name: "non-docker mode + in docker + local url", + config: &Config{ + BeaconNodeAddress: "http://localhost:5052", + RunMethod: RunMethod_RUN_METHOD_BINARY, + }, + inDocker: true, + expectedAddress: "http://localhost:5052", + }, + { + name: "docker mode + in docker + local url with path", + config: &Config{ + BeaconNodeAddress: "http://localhost:5052/eth/v1/node/syncing", + RunMethod: RunMethod_RUN_METHOD_DOCKER, + }, + inDocker: true, + expectedAddress: "http://host.docker.internal:5052/eth/v1/node/syncing", + }, + { + name: "empty address", + config: &Config{ + BeaconNodeAddress: "", + RunMethod: RunMethod_RUN_METHOD_DOCKER, + }, + inDocker: true, + expectedAddress: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Mock isRunningInDocker for this test + isRunningInDocker = func() bool { return tt.inDocker } + + got := tt.config.NodeAddress() + assert.Equal(t, tt.expectedAddress, got) + }) + } +}