diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..4e13df0 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,29 @@ +name: Go Lint +on: + push: + tags: + - v* + branches: + - master + - main + pull_request: +permissions: + contents: read + # Optional: allow read access to pull request. Use with `only-new-issues` option. + # pull-requests: read +jobs: + golangci: + name: lint + runs-on: ubuntu-latest + steps: + - uses: actions/setup-go@v4 + with: + go-version: '1.19' + cache: false + - uses: actions/checkout@v3 + - name: golangci-lint + uses: golangci/golangci-lint-action@v3 + with: + version: v1.52.2 + args: --sort-results --disable unused + diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..0ba2675 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,21 @@ +name: Test + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v3 + with: + go-version: 1.19 + + - name: Test + run: go test -v -race ./... diff --git a/.gitignore b/.gitignore index 5a1d8d1..ec6531c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,37 @@ -dist/ -*.json -.idea/ -*.iml \ No newline at end of file +/dist + +# Compiled Object files, Static and Dynamic libs (Shared Objects) +*.o +*.a +*.so + +go.work + +# Folders +_obj +_test + +# Architecture specific extensions/prefixes +*.[568vq] +[568vq].out + +*.cgo1.go +*.cgo2.c +_cgo_defun.c +_cgo_gotypes.go +_cgo_export.* + +_testmain.go + +*.exe +*.test +*.prof + +.idea/* +.DS_STORE +.vscode/* + +*.wat + +vendor/ +.bin/ diff --git a/README.md b/README.md index 4ebbd6d..dd7f633 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ This is an implementation that uses our Go Server SDK to initialize and start multiple servers that emulate the response format of the -bucketing-api server. This allows SDK's where implementing the WebAssembly bucketing library as a core isn't possible to +Bucketing API server. This allows SDK's where implementing the WebAssembly bucketing library as a core isn't possible to benefit from the Local Bucketing benefits of the DevCycle platform. ## Usage @@ -11,12 +11,9 @@ The application is delivered in multiple formats - a Docker image, a deb, and RP format for local building and implementation. The proxy handles two modes of operation - you can expose the HTTP server over a TCP port, or over Unix domain sockets. -The latter is recommended for servers that will deploy this in a fashion where the proxy is running on the same machine -as the SDK, -preventing the need for network calls. +The latter is recommended for servers that will deploy this with the proxy running on the same machine as the SDK, preventing the need for network calls. -The HTTP server mode is a 1:1 replacement for the bucketing-api - and can be used in place where there is no SDK in use -as well. +The HTTP server mode is a 1:1 replacement for the Bucketing API used by all SDKs in cloud bucketing mode, or can be used directly without an SDK as an API. ### Docker @@ -31,6 +28,8 @@ We also provide the raw application binary to wrap in your own daemon manager, o Either a path to a config file which allows specifying multiple instances of a proxy, or environment variables can be used to configure the proxy. +A simple healthcheck for each proxy instance can be performed by sending a GET request to the `/healthz` endpoint. + ### Command Line Arguments | ARGUMENT | TYPE | DEFAULT | REQUIRED | DESCRIPTION | @@ -42,6 +41,7 @@ used to configure the proxy. | KEY | TYPE | DEFAULT | REQUIRED | DESCRIPTION | |--------------------------------------------------------|---------------|---------|----------|---------------------------------------------------------------------------------| +| DVC_LB_PROXY_CONFIG | String | | | The path to a JSON configuration file. | | DVC_LB_PROXY_UNIX_SOCKET_PATH | String | | | The path to the Unix socket. | | DVC_LB_PROXY_HTTP_PORT | Integer | 8080 | | The port to listen on for HTTP requests. Defaults to 8080. | | DVC_LB_PROXY_UNIX_SOCKET_ENABLED | True or False | false | | Whether to enable the Unix socket. Defaults to false. | diff --git a/cmd/main.go b/cmd/main.go index 9317c68..3f39a77 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -1,146 +1,86 @@ package main import ( - "encoding/json" + "context" "flag" "fmt" - devcycle "github.com/devcyclehq/go-server-sdk/v2" - lbproxy "github.com/devcyclehq/local-bucketing-proxy" - "github.com/gin-gonic/gin" - "github.com/kelseyhightower/envconfig" "log" "os" "os/signal" - "runtime" "syscall" -) -var ( - configJSONPath string - config lbproxy.ProxyConfig + lbproxy "github.com/devcyclehq/local-bucketing-proxy" + "github.com/kelseyhightower/envconfig" ) const ( - EnvVarPrefix = "DVC_LB_PROXY" - Version = "0.1.0" -) + Version = "0.1.0" + EnvConfigFormat = ` +This application can also be configured via the environment. The following environment +variables can be used: -func init() { +{{printf "%-54s" "KEY"}} {{ printf "%-11s" "TYPE" }} DEFAULT REQUIRED DESCRIPTION +{{range .}}{{usage_key . | printf "%-54s"}} {{usage_type . | printf "%-11s"}} {{usage_default .}} {{usage_required . | printf "%5s" }} {{usage_description .}} +{{end}}` +) - if os.Getenv(EnvVarPrefix+"_CONFIG") != "" { - configJSONPath = os.Getenv(EnvVarPrefix + "_PROXY_CONFIG") - } else { - flag.StringVar(&configJSONPath, "config", "", "Path to config.json file") - } +func main() { + var configPath string + flag.StringVar(&configPath, "config", "", "The path to a JSON config file.") - if os.Getenv(EnvVarPrefix+"_DEBUG") == "" { - gin.SetMode(gin.ReleaseMode) - } flag.Usage = func() { - fmt.Fprintf(os.Stderr, "DevCycle Local Bucketing Proxy Version %s\n", Version) + log.Printf("DevCycle Local Bucketing Proxy Version %s\n", Version) - fmt.Fprintf(os.Stderr, "Usage: %s [options]\n", os.Args[0]) + log.Printf("Usage: %s [options]\n", os.Args[0]) flag.PrintDefaults() - fmt.Printf("\nAlternatively, ") - _ = envconfig.Usagef(EnvVarPrefix, &lbproxy.ProxyInstance{}, os.Stderr, envconfig.DefaultTableFormat) + _ = envconfig.Usagef(lbproxy.EnvVarPrefix, &lbproxy.FullEnvConfig{}, os.Stderr, EnvConfigFormat) } flag.Parse() - // Load config - if configJSONPath == "" { - instance, err := initializeProxyInstanceFromEnv() - if err != nil { - log.Println("Failed to initialize proxy instance from environment variables. Please either set the config path or set the environment variables:") - log.Println(err) - err = envconfig.Usage(EnvVarPrefix, instance) - if err != nil { - log.Println(err) - } - return - } - defer func() { - err = os.Remove(instance.UnixSocketPath) - }() - config = lbproxy.ProxyConfig{Instances: []lbproxy.ProxyInstance{*instance}} - } else { - configFile, err := os.ReadFile(configJSONPath) - if err != nil { - log.Println("Failed to read config file, writing an empty configuration file to the specified path.") - hostname, err := os.Hostname() - if err != nil { - hostname = "unknown" - } - config := lbproxy.ProxyConfig{Instances: []lbproxy.ProxyInstance{{ - UnixSocketPath: "/tmp/devcycle.sock", - HTTPPort: 8080, - UnixSocketEnabled: false, - HTTPEnabled: true, - SDKKey: "", - PlatformData: devcycle.PlatformData{ - SdkType: "server", - SdkVersion: devcycle.VERSION, - PlatformVersion: runtime.Version(), - Platform: "Go", - Hostname: hostname, - }, - SDKConfig: lbproxy.SDKConfig{ - EventFlushIntervalMS: 0, - ConfigPollingIntervalMS: 0, - RequestTimeout: 0, - DisableAutomaticEventLogging: false, - DisableCustomEventLogging: false, - MaxEventQueueSize: 0, - FlushEventQueueSize: 0, - ConfigCDNURI: "", - EventsAPIURI: "", - }, - }}} - config.Default() - bytes, err := json.Marshal(config) - if err != nil { - log.Println("Failed to marshal config to JSON.") - return - } - err = os.WriteFile(configJSONPath, bytes, 0644) - if err != nil { - log.Println("Failed to write config to file.") - return - } - } - - err = json.Unmarshal(configFile, &config) - if err != nil { - return - } + config, err := lbproxy.ParseConfig(configPath) + if err != nil { + log.Printf("Failed to parse config: %s", err) + log.Fatal("Please either set the config path or set the environment variables") } -} - -func main() { if len(config.Instances) == 0 { - fmt.Println("No instances found in config.") + log.Fatalf("No instances found in config. Use %s -config to create a sample config file.", os.Args[0]) return } // Create router for each instance for _, instance := range config.Instances { + log.Printf("Creating bucketing proxy instance: %+v", instance) + // Create client _, err := lbproxy.NewBucketingProxyInstance(instance) if err != nil { - fmt.Println(err) - return + log.Fatal(err) } + defer func(path string) { + err = os.Remove(path) + }(instance.UnixSocketPath) } + + ctx, cancel := context.WithCancel(context.Background()) + // Use a buffered channel, so we don't miss any signals c := make(chan os.Signal, 1) - signal.Notify(c, os.Interrupt, os.Kill, syscall.SIGTERM) + signal.Notify(c, os.Interrupt, syscall.SIGTERM) - // Block until a signal is received. - s := <-c - fmt.Println("Got signal:", s) -} + go func() { + // Block until a signal is received. + s := <-c + fmt.Printf("Received signal: %s, shutting down", s) + + for _, instance := range config.Instances { + err := instance.Close() + if err != nil { + log.Printf("Failed to shut down instance: %s", err) + } + } + + cancel() + }() -func initializeProxyInstanceFromEnv() (*lbproxy.ProxyInstance, error) { - instance := &lbproxy.ProxyInstance{} - err := envconfig.Process(EnvVarPrefix, instance) - return instance, err + <-ctx.Done() } diff --git a/config.json.example b/config.json.example index a5623ad..f04782d 100644 --- a/config.json.example +++ b/config.json.example @@ -5,7 +5,7 @@ "httpPort": 8080, "unixSocketEnabled": false, "httpEnabled": true, - "sdkKey": "", + "sdkKey": "dvc_YOUR_KEY_HERE", "platformData": { "sdkType": "server", "sdkVersion": "2.10.2", @@ -17,7 +17,7 @@ "sdkConfig": { "eventFlushIntervalMS": 3000, "configPollingIntervalMS": 30000, - "requestTimeout": 3000, + "requestTimeout": 60000, "maxEventsPerFlush": 10000, "minEventsPerFlush": 100, "configCDNURI": "https://config-cdn.devcycle.com", diff --git a/go.mod b/go.mod index 8df9e88..c414c19 100644 --- a/go.mod +++ b/go.mod @@ -5,10 +5,14 @@ go 1.19 require ( github.com/devcyclehq/go-server-sdk/v2 v2.10.2 github.com/gin-gonic/gin v1.8.2 + github.com/kelseyhightower/envconfig v1.4.0 + github.com/kr/pretty v0.3.1 + github.com/stretchr/testify v1.8.2 ) require ( github.com/bytecodealliance/wasmtime-go/v6 v6.0.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/gin-contrib/sse v0.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect @@ -18,13 +22,15 @@ require ( github.com/jarcoal/httpmock v1.2.0 // indirect github.com/jolestar/go-commons-pool/v2 v2.1.2 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/kr/pretty v0.3.1 // indirect + github.com/kr/text v0.2.0 // indirect github.com/leodido/go-urn v1.2.2 // indirect github.com/matryer/try v0.0.0-20161228173917-9ac251b645a2 // indirect github.com/mattn/go-isatty v0.0.16 // indirect github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.0.6 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rogpeppe/go-internal v1.9.0 // indirect github.com/twmb/murmur3 v1.1.7 // indirect github.com/ugorji/go/codec v1.2.7 // indirect golang.org/x/crypto v0.7.0 // indirect @@ -34,4 +40,5 @@ require ( golang.org/x/text v0.8.0 // indirect google.golang.org/protobuf v1.29.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 02d0bb2..3ab692b 100644 --- a/go.sum +++ b/go.sum @@ -34,6 +34,8 @@ github.com/jolestar/go-commons-pool/v2 v2.1.2 h1:E+XGo58F23t7HtZiC/W6jzO2Ux2IccS github.com/jolestar/go-commons-pool/v2 v2.1.2/go.mod h1:r4NYccrkS5UqP1YQI1COyTZ9UjPJAAGTUxzcsK1kqhY= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= +github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= diff --git a/http_endpoints.go b/http_endpoints.go index 1795b54..f67b25c 100644 --- a/http_endpoints.go +++ b/http_endpoints.go @@ -3,13 +3,18 @@ package local_bucketing_proxy import ( "encoding/json" "fmt" - devcycle "github.com/devcyclehq/go-server-sdk/v2" - "github.com/gin-gonic/gin" "io" "net/http" "strings" + + devcycle "github.com/devcyclehq/go-server-sdk/v2" + "github.com/gin-gonic/gin" ) +func Health(c *gin.Context) { + c.Status(200) +} + func Variable(client *devcycle.Client) gin.HandlerFunc { return func(c *gin.Context) { user := getUserFromBody(c) @@ -57,7 +62,6 @@ func Feature(client *devcycle.Client) gin.HandlerFunc { return } c.JSON(http.StatusOK, allFeatures) - return } } diff --git a/options.go b/options.go index 3605bdf..73a40d4 100644 --- a/options.go +++ b/options.go @@ -1,24 +1,24 @@ package local_bucketing_proxy import ( - devcycle "github.com/devcyclehq/go-server-sdk/v2" + "encoding/json" + "fmt" + "log" + "os" + "runtime" "time" + + devcycle "github.com/devcyclehq/go-server-sdk/v2" + "github.com/gin-gonic/gin" + "github.com/kelseyhightower/envconfig" ) -type ProxyConfig struct { - Instances []ProxyInstance `json:"instances"` -} +const ( + EnvVarPrefix = "DVC_LB_PROXY" +) -type SDKConfig struct { - EventFlushIntervalMS time.Duration `json:"eventFlushIntervalMS,omitempty" split_words:"true" desc:"The interval at which events are flushed to the events api in milliseconds."` - ConfigPollingIntervalMS time.Duration `json:"configPollingIntervalMS,omitempty" split_words:"true" desc:"The interval at which the SDK polls the config CDN for updates in milliseconds."` - RequestTimeout time.Duration `json:"requestTimeout,omitempty" split_words:"true" desc:"The timeout for requests to the config CDN and events API in milliseconds."` - DisableAutomaticEventLogging bool `json:"disableAutomaticEventLogging,omitempty" split_words:"true" default:"false" desc:"Whether to disable automatic event logging. Defaults to false."` - DisableCustomEventLogging bool `json:"disableCustomEventLogging,omitempty" split_words:"true" default:"false" desc:"Whether to disable custom event logging. Defaults to false."` - MaxEventQueueSize int `json:"maxEventsPerFlush,omitempty" split_words:"true" desc:"The maximum number of events to be in the queue before dropping events."` - FlushEventQueueSize int `json:"minEventsPerFlush,omitempty" split_words:"true" desc:"The minimum number of events to be in the queue before flushing events."` - ConfigCDNURI string `json:"configCDNURI,omitempty" envconfig:"CONFIG_CDN_URI" desc:"The URI of the Config CDN - leave unspecified if not needing an outbound proxy."` - EventsAPIURI string `json:"eventsAPIURI,omitempty" envconvig:"EVENTS_API_URI" desc:"The URI of the Events API - leave unspecified if not needing an outbound proxy."` +type ProxyConfig struct { + Instances []*ProxyInstance `json:"instances"` } type ProxyInstance struct { @@ -32,6 +32,18 @@ type ProxyInstance struct { dvcClient *devcycle.Client } +type SDKConfig struct { + EventFlushIntervalMS int64 `json:"eventFlushIntervalMS,omitempty" split_words:"true" desc:"The interval at which events are flushed to the events api in milliseconds."` + ConfigPollingIntervalMS int64 `json:"configPollingIntervalMS,omitempty" split_words:"true" desc:"The interval at which the SDK polls the config CDN for updates in milliseconds."` + RequestTimeout int64 `json:"requestTimeout,omitempty" split_words:"true" desc:"The timeout for requests to the config CDN and events API in milliseconds."` + DisableAutomaticEventLogging bool `json:"disableAutomaticEventLogging,omitempty" split_words:"true" default:"false" desc:"Whether to disable automatic event logging. Defaults to false."` + DisableCustomEventLogging bool `json:"disableCustomEventLogging,omitempty" split_words:"true" default:"false" desc:"Whether to disable custom event logging. Defaults to false."` + MaxEventQueueSize int `json:"maxEventsPerFlush,omitempty" split_words:"true" desc:"The maximum number of events to be in the queue before dropping events."` + FlushEventQueueSize int `json:"minEventsPerFlush,omitempty" split_words:"true" desc:"The minimum number of events to be in the queue before flushing events."` + ConfigCDNURI string `json:"configCDNURI,omitempty" envconfig:"CONFIG_CDN_URI" desc:"The URI of the Config CDN - leave unspecified if not needing an outbound proxy."` + EventsAPIURI string `json:"eventsAPIURI,omitempty" envconfig:"EVENTS_API_URI" desc:"The URI of the Events API - leave unspecified if not needing an outbound proxy."` +} + func (i *ProxyInstance) Close() error { return i.dvcClient.Close() } @@ -40,9 +52,9 @@ func (i *ProxyInstance) BuildDevCycleOptions() *devcycle.Options { options := devcycle.Options{ EnableEdgeDB: false, EnableCloudBucketing: false, - EventFlushIntervalMS: i.SDKConfig.EventFlushIntervalMS, - ConfigPollingIntervalMS: i.SDKConfig.ConfigPollingIntervalMS, - RequestTimeout: i.SDKConfig.RequestTimeout, + EventFlushIntervalMS: time.Duration(i.SDKConfig.EventFlushIntervalMS) * time.Millisecond, + ConfigPollingIntervalMS: time.Duration(i.SDKConfig.ConfigPollingIntervalMS) * time.Millisecond, + RequestTimeout: time.Duration(i.SDKConfig.RequestTimeout) * time.Millisecond, DisableAutomaticEventLogging: i.SDKConfig.DisableAutomaticEventLogging, DisableCustomEventLogging: i.SDKConfig.DisableCustomEventLogging, MaxEventQueueSize: i.SDKConfig.MaxEventQueueSize, @@ -82,7 +94,7 @@ func (c *SDKConfig) Default() { c.ConfigPollingIntervalMS = 30000 } if c.RequestTimeout == 0 { - c.RequestTimeout = 3000 + c.RequestTimeout = 30000 } if c.MaxEventQueueSize == 0 { c.MaxEventQueueSize = 10000 @@ -97,3 +109,104 @@ func (c *SDKConfig) Default() { c.EventsAPIURI = "https://events.devcycle.com" } } + +// For parsing just the config filename, before we know the intended config mechanism +type InitialConfig struct { + ConfigPath string `envconfig:"CONFIG" desc:"The path to a JSON config file."` + Debug bool `envconfig:"DEBUG" default:"false" desc:"Whether to enable debug mode."` +} + +// For parsing the full config along with the proxy settings +type FullEnvConfig struct { + InitialConfig + ProxyInstance +} + +// Parse the config from either a JSON file or environment variables +func ParseConfig(configPath string) (*ProxyConfig, error) { + var proxyConfig ProxyConfig + initialConfig := InitialConfig{ + ConfigPath: configPath, + } + + // Attempt to load initial config from environment, ignoring any errors + _ = envconfig.Process(EnvVarPrefix, &initialConfig) + + // Load full config from environment variables + if initialConfig.ConfigPath == "" { + var fullEnvConfig FullEnvConfig + log.Println("No config path provided, reading configuration from environment variables.") + err := envconfig.Process(EnvVarPrefix, &fullEnvConfig) + + if err != nil { + return nil, err + } + proxyConfig.Instances = append(proxyConfig.Instances, &fullEnvConfig.ProxyInstance) + } else { + // Load config from JSON file + log.Printf("Loading configuration from file: %s", initialConfig.ConfigPath) + configData, err := os.ReadFile(initialConfig.ConfigPath) + if err != nil { + log.Printf("Failed to read config file, writing a default configuration file to the specified path: %s", initialConfig.ConfigPath) + proxyConfig = SampleProxyConfig() + + sampleConfigData, err := json.MarshalIndent(proxyConfig, "", " ") + if err != nil { + return nil, fmt.Errorf("failed to write sample config to JSON: %w", err) + } + err = os.WriteFile(initialConfig.ConfigPath, sampleConfigData, 0644) + if err != nil { + return nil, fmt.Errorf("Failed to write sample config to file: %w", err) + } + log.Fatal("Add your SDK key to the config file and run this command again.") + } + + err = json.Unmarshal(configData, &proxyConfig) + if err != nil { + return nil, fmt.Errorf("failed to parse config from JSON: %w", err) + } + } + + if !initialConfig.Debug { + gin.SetMode(gin.ReleaseMode) + } + + return &proxyConfig, nil +} + +func SampleProxyConfig() ProxyConfig { + hostname, err := os.Hostname() + if err != nil { + hostname = "unknown" + } + + proxyConfig := ProxyConfig{ + Instances: []*ProxyInstance{{ + UnixSocketPath: "/tmp/devcycle.sock", + HTTPPort: 8080, + UnixSocketEnabled: false, + HTTPEnabled: true, + SDKKey: "", + PlatformData: devcycle.PlatformData{ + SdkType: "server", + SdkVersion: devcycle.VERSION, + PlatformVersion: runtime.Version(), + Platform: "Go", + Hostname: hostname, + }, + SDKConfig: SDKConfig{ + EventFlushIntervalMS: 0, + ConfigPollingIntervalMS: 0, + RequestTimeout: 0, + DisableAutomaticEventLogging: false, + DisableCustomEventLogging: false, + MaxEventQueueSize: 0, + FlushEventQueueSize: 0, + ConfigCDNURI: "", + EventsAPIURI: "", + }, + }}, + } + proxyConfig.Default() + return proxyConfig +} diff --git a/options_test.go b/options_test.go new file mode 100644 index 0000000..2faf315 --- /dev/null +++ b/options_test.go @@ -0,0 +1,201 @@ +package local_bucketing_proxy + +import ( + "os" + "testing" + "time" + + "github.com/devcyclehq/go-server-sdk/v2/api" + "github.com/kr/pretty" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseConfig(t *testing.T) { + tests := []struct { + name string + flag string + env map[string]string + expected *ProxyConfig + expectedErr string + }{ + { + name: "no config", + env: map[string]string{}, + expected: nil, + expectedErr: "required key SDK_KEY missing value", + }, + { + name: "minimum env config", + env: map[string]string{ + "SDK_KEY": "dvc-test-key", + }, + expected: &ProxyConfig{ + Instances: []*ProxyInstance{ + { + UnixSocketPath: "", + HTTPPort: 8080, + UnixSocketEnabled: false, + HTTPEnabled: true, + SDKKey: "dvc-test-key", + PlatformData: api.PlatformData{}, + SDKConfig: SDKConfig{}, + }, + }, + }, + }, + { + name: "all env config", + env: map[string]string{ + "SDK_KEY": "dvc-test-key", + "DEBUG": "True", + "UNIX_SOCKET_PATH": "/tmp/dvc2.sock", + "HTTP_PORT": "1234", + "UNIX_SOCKET_ENABLED": "True", + "HTTP_ENABLED": "False", + "PLATFORMDATA_SDKTYPE": "sdk type", + "PLATFORMDATA_SDKVERSION": "v1.2.3", + "PLATFORMDATA_PLATFORMVERSION": "v2.3.4", + "PLATFORMDATA_DEVICEMODEL": "device model", + "PLATFORMDATA_PLATFORM": "platform", + "PLATFORMDATA_HOSTNAME": "hostname", + "SDKCONFIG_EVENT_FLUSH_INTERVAL_MS": "60000", + "SDKCONFIG_CONFIG_POLLING_INTERVAL_MS": "120000", + "SDKCONFIG_REQUEST_TIMEOUT": "3000", + "SDKCONFIG_DISABLE_AUTOMATIC_EVENT_LOGGING": "True", + "SDKCONFIG_DISABLE_CUSTOM_EVENT_LOGGING": "True", + "SDKCONFIG_MAX_EVENT_QUEUE_SIZE": "123", + "SDKCONFIG_FLUSH_EVENT_QUEUE_SIZE": "456", + "SDKCONFIG_CONFIG_CDN_URI": "https://example.com/config", + "SDKCONFIG_EVENTS_API_URI": "https://example.com/events", + }, + expected: &ProxyConfig{ + Instances: []*ProxyInstance{ + { + UnixSocketPath: "/tmp/dvc2.sock", + HTTPPort: 1234, + UnixSocketEnabled: true, + HTTPEnabled: false, + SDKKey: "dvc-test-key", + PlatformData: api.PlatformData{ + SdkType: "sdk type", + SdkVersion: "v1.2.3", + PlatformVersion: "v2.3.4", + DeviceModel: "device model", + Platform: "platform", + Hostname: "hostname", + }, + SDKConfig: SDKConfig{ + EventFlushIntervalMS: time.Minute.Milliseconds(), + ConfigPollingIntervalMS: (2 * time.Minute).Milliseconds(), + RequestTimeout: (3 * time.Second).Milliseconds(), + DisableAutomaticEventLogging: true, + DisableCustomEventLogging: true, + MaxEventQueueSize: 123, + FlushEventQueueSize: 456, + ConfigCDNURI: "https://example.com/config", + EventsAPIURI: "https://example.com/events", + }, + }, + }, + }, + }, + { + name: "bad JSON config from flag", + flag: "./testdata/invalid.config.json", + env: map[string]string{}, + expectedErr: "failed to parse config from JSON: invalid character ',' looking for beginning of object key string", + }, + { + name: "minimum config from flag", + flag: "./testdata/minimum.config.json", + env: map[string]string{}, + expected: &ProxyConfig{ + Instances: []*ProxyInstance{ + { + UnixSocketPath: "", + HTTPPort: 0, + UnixSocketEnabled: false, + HTTPEnabled: false, + SDKKey: "dvc-sample-key", + PlatformData: api.PlatformData{}, + SDKConfig: SDKConfig{}, + }, + }, + }, + }, + { + name: "minimum config from file env var", + env: map[string]string{ + "CONFIG": "./testdata/minimum.config.json", + }, + expected: &ProxyConfig{ + Instances: []*ProxyInstance{ + { + UnixSocketPath: "", + HTTPPort: 0, + UnixSocketEnabled: false, + HTTPEnabled: false, + SDKKey: "dvc-sample-key", + PlatformData: api.PlatformData{}, + SDKConfig: SDKConfig{}, + }, + }, + }, + }, + { + name: "all config from flag", + flag: "./config.json.example", + env: map[string]string{}, + expected: &ProxyConfig{ + Instances: []*ProxyInstance{ + { + UnixSocketPath: "/tmp/devcycle.sock", + HTTPPort: 8080, + UnixSocketEnabled: false, + HTTPEnabled: true, + SDKKey: "dvc_YOUR_KEY_HERE", + PlatformData: api.PlatformData{ + SdkType: "server", + SdkVersion: "2.10.2", + PlatformVersion: "go1.20.3", + DeviceModel: "", + Platform: "Go", + Hostname: "localhost", + }, + SDKConfig: SDKConfig{ + EventFlushIntervalMS: (time.Second * 3).Milliseconds(), + ConfigPollingIntervalMS: (time.Second * 30).Milliseconds(), + RequestTimeout: (time.Second * 60).Milliseconds(), + DisableAutomaticEventLogging: false, + DisableCustomEventLogging: false, + MaxEventQueueSize: 10000, + FlushEventQueueSize: 100, + ConfigCDNURI: "https://config-cdn.devcycle.com", + EventsAPIURI: "https://events.devcycle.com", + }, + }, + }, + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + for key, value := range test.env { + _ = os.Setenv(EnvVarPrefix+"_"+key, value) + defer func(key string) { + _ = os.Unsetenv(EnvVarPrefix + "_" + key) + }(key) + } + actual, err := ParseConfig(test.flag) + if test.expectedErr != "" { + require.EqualError(t, err, test.expectedErr) + } else { + require.NoError(t, err) + if !assert.Equal(t, test.expected, actual) { + pretty.Println(test.name, actual) + } + } + }) + } +} diff --git a/proxy.go b/proxy.go index 8c36941..b1152f6 100644 --- a/proxy.go +++ b/proxy.go @@ -2,13 +2,15 @@ package local_bucketing_proxy import ( "fmt" - devcycle "github.com/devcyclehq/go-server-sdk/v2" - "github.com/gin-gonic/gin" + "log" "os" "strconv" + + devcycle "github.com/devcyclehq/go-server-sdk/v2" + "github.com/gin-gonic/gin" ) -func NewBucketingProxyInstance(instance ProxyInstance) (*ProxyInstance, error) { +func NewBucketingProxyInstance(instance *ProxyInstance) (*ProxyInstance, error) { options := instance.BuildDevCycleOptions() client, err := devcycle.NewClient(instance.SDKKey, options) instance.dvcClient = client @@ -16,6 +18,8 @@ func NewBucketingProxyInstance(instance ProxyInstance) (*ProxyInstance, error) { r.Use(gin.Logger()) r.Use(gin.Recovery()) + r.GET("/healthz", Health) + bucketingApiV1 := r.Group("/v1/") bucketingApiV1.Use(DevCycleAuthRequired()) { @@ -28,15 +32,25 @@ func NewBucketingProxyInstance(instance ProxyInstance) (*ProxyInstance, error) { if instance.HTTPPort == 0 { return nil, fmt.Errorf("HTTP port must be set") } - go r.Run(":" + strconv.Itoa(instance.HTTPPort)) - fmt.Println("HTTP server started on port " + strconv.Itoa(instance.HTTPPort)) + go func() { + err := r.Run(":" + strconv.Itoa(instance.HTTPPort)) + if err != nil { + log.Printf("Error running HTTP server: %s", err) + } + }() + log.Printf("HTTP server started on port %d", instance.HTTPPort) } if instance.UnixSocketEnabled { if _, err := os.Stat(instance.UnixSocketPath); err == nil { return nil, fmt.Errorf("unix socket path %s already exists. Skipping instance creation", instance.UnixSocketPath) } - go r.RunUnix(instance.UnixSocketPath) - fmt.Println("Running on unix socket: " + instance.UnixSocketPath) + go func() { + err := r.RunUnix(instance.UnixSocketPath) + if err != nil { + log.Printf("Error running Unix socket server: %s", err) + } + }() + log.Printf("Running on unix socket: %s", instance.UnixSocketPath) } - return &instance, err + return instance, err } diff --git a/testdata/invalid.config.json b/testdata/invalid.config.json new file mode 100644 index 0000000..a340451 --- /dev/null +++ b/testdata/invalid.config.json @@ -0,0 +1 @@ +{,,,,,, diff --git a/testdata/minimum.config.json b/testdata/minimum.config.json new file mode 100644 index 0000000..e0a1812 --- /dev/null +++ b/testdata/minimum.config.json @@ -0,0 +1,7 @@ +{ + "instances": [ + { + "sdkKey": "dvc-sample-key" + } + ] +} \ No newline at end of file