diff --git a/CHANGELOG.md b/CHANGELOG.md index 4103b03c..31ec88b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## master +- Add `anycable.toml` support. ([@palkan][]) + - Print metrics with keys sorted alphabetically. ([@palkan][]) - Upgrade to Go 1.23. ([@palkan][]) diff --git a/cli/cli.go b/cli/cli.go index 9442aaf6..56c4198d 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -162,7 +162,11 @@ func (r *Runner) Run() error { r.log.Info(fmt.Sprintf("Starting %s %s%s (pid: %d, open file limit: %s, gomaxprocs: %d)", r.name, version.Version(), mrubySupport, os.Getpid(), utils.OpenFileLimit(), numProcs)) - if r.config.IsPublic() { + if r.config.ConfigFilePath != "" { + r.log.Info(fmt.Sprintf("Using configuration from file: %s", r.config.ConfigFilePath)) + } + + if r.config.PublicMode { r.log.Warn("Server is running in the public mode") } diff --git a/cli/options.go b/cli/options.go index 915b80ef..0805a40e 100644 --- a/cli/options.go +++ b/cli/options.go @@ -2,6 +2,7 @@ package cli import ( "fmt" + "os" "regexp" "strings" @@ -49,14 +50,24 @@ func WithCLICustomOptions(factory customOptionsFactory) cliOption { } } +const DefaultConfigPath = "/etc/anycable/anycable.toml" +const CurrentConfigPath = "./anycable.toml" + // NewConfigFromCLI reads config from os.Args. It returns config, error (if any) and a bool value // indicating that the execution was interrupted (e.g., usage message or version was shown), no further action required. func NewConfigFromCLI(args []string, opts ...cliOption) (*config.Config, error, bool) { c := config.NewConfig() + if _, err := os.Stat(CurrentConfigPath); err == nil { + args = append([]string{args[0], "--config-path", CurrentConfigPath}, args[1:]...) + } else if _, err := os.Stat(DefaultConfigPath); err == nil { + args = append([]string{args[0], "--config-path", DefaultConfigPath}, args[1:]...) + } + var path, headers, cookieFilter, mtags string var broadcastAdapters string var cliInterrupted = true + var shouldPrintConfig = false var metricsFilter string var enatsRoutes, enatsGateways string var presets string @@ -71,7 +82,42 @@ func NewConfigFromCLI(args []string, opts ...cliOption) (*config.Config, error, _, _ = fmt.Fprintf(cCtx.App.Writer, "%v\n", cCtx.App.Version) } - flags := []cli.Flag{} + flags := []cli.Flag{ + &cli.BoolFlag{ + Name: "ignore-config-path", + Usage: "Ignore configuration files", + Action: func(ctx *cli.Context, val bool) error { + if val { + c.ConfigFilePath = "none" + } + return nil + }, + }, + &cli.StringFlag{ + Name: "config-path", + Usage: "Path to the TOML configuration file", + Action: func(ctx *cli.Context, path string) error { + if c.ConfigFilePath == "none" { + c.ConfigFilePath = "" + return nil + } + + c.ConfigFilePath = path + + // check if file exists and try to load config from it + if err := c.LoadFromFile(); err != nil { + return err + } + + return nil + }, + }, + &cli.BoolFlag{ + Name: "print-config", + Usage: "Print configuration and exit", + Destination: &shouldPrintConfig, + }, + } flags = append(flags, serverCLIFlags(&c, &path)...) flags = append(flags, sslCLIFlags(&c)...) flags = append(flags, broadcastCLIFlags(&c, &broadcastAdapters)...) @@ -295,6 +341,10 @@ Use broadcast_key instead.`) } // Nullify none secrets + if c.Secret == "none" { + c.Secret = "" + } + if c.Streams.Secret == "none" { c.Streams.Secret = "" } @@ -329,6 +379,11 @@ Use broadcast_key instead.`) c.HTTPBroadcast.SecretBase = "" } + if shouldPrintConfig { + fmt.Print(c.Display()) + return &c, nil, true + } + return &c, nil, false } @@ -453,6 +508,18 @@ func serverCLIFlags(c *config.Config, path *string) []cli.Flag { Destination: &c.App.ShutdownDisconnectPoolSize, Hidden: true, }, + + &cli.StringFlag{ + Name: "node_id", + Usage: "Unique node identifier", + Value: c.ID, + Hidden: true, + Action: func(ctx *cli.Context, val string) error { + c.ID = val + c.UserProvidedID = true + return nil + }, + }, }) } @@ -479,7 +546,6 @@ func broadcastCLIFlags(c *config.Config, adapters *string) []cli.Flag { &cli.StringFlag{ Name: "broadcast_adapter", Usage: "Broadcasting adapter to use (http, redisx, redis or nats). You can specify multiple at once via a comma-separated list", - Value: strings.Join(c.BroadcastAdapters, ","), Destination: adapters, }, &cli.StringFlag{ diff --git a/config/config.go b/config/config.go index f4813600..21724191 100644 --- a/config/config.go +++ b/config/config.go @@ -1,6 +1,11 @@ package config import ( + "fmt" + "os" + "strings" + + "github.com/BurntSushi/toml" "github.com/anycable/anycable-go/broadcast" "github.com/anycable/anycable-go/broker" "github.com/anycable/anycable-go/enats" @@ -15,20 +20,22 @@ import ( "github.com/anycable/anycable-go/sse" "github.com/anycable/anycable-go/streams" "github.com/anycable/anycable-go/ws" + "github.com/joomcode/errorx" nanoid "github.com/matoous/go-nanoid" ) // Config contains main application configuration type Config struct { - ID string - Secret string - BroadcastKey string - SkipAuth bool - PublicMode bool - BroadcastAdapters []string - PubSubAdapter string - UserPresets []string + ID string `toml:"node_id"` + UserProvidedID bool + Secret string `toml:"secret"` + BroadcastKey string `toml:"broadcast_key"` + SkipAuth bool `toml:"noauth"` + PublicMode bool `toml:"public"` + BroadcastAdapters []string `toml:"broadcast_adapters"` + PubSubAdapter string `toml:"pubsub_adapter"` + UserPresets []string `toml:"presets"` Log logger.Config Server server.Config App node.Config @@ -45,6 +52,8 @@ type Config struct { EmbeddedNats enats.Config SSE sse.Config Streams streams.Config + + ConfigFilePath string } // NewConfig returns a new empty config @@ -75,6 +84,89 @@ func NewConfig() Config { return config } -func (c Config) IsPublic() bool { - return c.SkipAuth && c.Streams.Public +func (c *Config) LoadFromFile() error { + bytes, err := os.ReadFile(c.ConfigFilePath) + + if err != nil { + return errorx.Decorate(err, "failed to read config file") + } + + prevID := c.ID + _, err = toml.Decode(string(bytes), &c) + + if err != nil { + return errorx.Decorate(err, "failed to parse TOML configuration") + } + + if c.ID != prevID { + c.UserProvidedID = true + } + + return nil +} + +func (c Config) Display() string { + var result strings.Builder + + result.WriteString("# AnyCable server configuration.\n# Read more at https://docs.anycable.io/anycable-go/configuration\n\n") + + result.WriteString("# General settings\n\n") + + result.WriteString("# Public mode disables connection authentication, pub/sub streams and broadcasts verification\n") + if c.PublicMode { + result.WriteString(fmt.Sprintf("public = %t\n\n", c.PublicMode)) + } else { + result.WriteString("# public = false\n\n") + } + + result.WriteString("# Disable connection authentication only\n") + if c.SkipAuth { + result.WriteString(fmt.Sprintf("noauth = %t\n\n", c.SkipAuth)) + } else { + result.WriteString("# noauth = false\n\n") + } + + result.WriteString("# Application instance ID\n") + if c.UserProvidedID { + result.WriteString(fmt.Sprintf("node_id = \"%s\"\n\n", c.ID)) + } else { + result.WriteString("# node_id = \"\"\n\n") + } + + result.WriteString("# The application secret key\n") + + if c.Secret != "" { + result.WriteString(fmt.Sprintf("secret = \"%s\"\n\n", c.Secret)) + } else { + result.WriteString("secret = \"none\"\n\n") + } + + result.WriteString("# Broadcasting adapters for app-to-clients messages\n") + result.WriteString(fmt.Sprintf("broadcast_adapters = [\"%s\"]\n\n", strings.Join(c.BroadcastAdapters, "\", \""))) + + result.WriteString("# Broadcasting authorization key\n") + + if c.BroadcastKey != "" { // nolint: gocritic + result.WriteString(fmt.Sprintf("broadcast_key = \"%s\"\n\n", c.BroadcastKey)) + } else if c.Secret != "" { + result.WriteString("# broadcast_key = \"\"\n\n") + } else { + result.WriteString("broadcast_key = \"none\"\n\n") + } + + result.WriteString("# Pub/sub adapter for inter-node communication\n") + if c.PubSubAdapter == "" { + result.WriteString("# pubsub_adapter = \"redis\" # or \"nats\"\n\n") + } else { + result.WriteString(fmt.Sprintf("pubsub_adapter = \"%s\"\n\n", c.PubSubAdapter)) + } + + result.WriteString("# User-provided configuration presets\n") + if len(c.UserPresets) == 0 { + result.WriteString("# presets = [\"broker\"]\n\n") + } else { + result.WriteString(fmt.Sprintf("presets = [\"%s\"]\n\n", strings.Join(c.UserPresets, "\", \""))) + } + + return result.String() } diff --git a/docs/configuration.md b/docs/configuration.md index 49a96b81..f0388e86 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,6 +1,6 @@ -# AnyCable-Go configuration +# AnyCable server configuration -You can configure AnyCable-Go via CLI options, e.g.: +You can configure AnyCable server via CLI options, e.g.: ```sh $ anycable-go --rpc_host=localhost:50051 --headers=cookie \ @@ -10,6 +10,8 @@ $ anycable-go --rpc_host=localhost:50051 --headers=cookie \ Or via the corresponding environment variables (i.e. `ANYCABLE_RPC_HOST`, `ANYCABLE_REDIS_URL`, etc.). +Finally, you can also store configuration in a `.toml` file (see [configuration files](#configuration-files)). + ## Primary settings Here is the list of the most commonly used configuration parameters. @@ -258,5 +260,31 @@ You can find the actual value for GOMAXPROCS in the starting logs: INFO 2022-06-30T03:31:21.848Z context=main Starting AnyCable 1.2.0-c4f1c6e (with mruby 1.2.0 (2015-11-17)) (pid: 39705, open file limit: 524288, gomaxprocs: 8) ``` +## Configuration files + +Since v1.5.4, you can also provide configuration via a TOML file. This is recommended for applications that require a lot of settings or have complex configurations. + +AnyCable will look for a configuration file in the following locations: + +- `./anycable.toml` +- `/ets/anycable/anycable.toml` + +You can also specify the path to the configuration file using the `--config-path` option, e.g.: + +```sh +$ anycable-go --config-path=/path/to/anycable.toml + +2024-10-07 17:52:37.139 INF Starting AnyCable 1.5.4-87217bb (pid: 80235, open file limit: 122880, gomaxprocs: 8) nodeid=BzeSHV +2024-10-07 17:52:37.139 INF Using configuration from file: ./path/to/anycable.toml nodeid=BzeSHV +``` + +You can generate a sample configuration file (including the currently provided configuration) using the `--print-config` option: + +```sh +$ anycable-go --print-config > anycable.toml +``` + +Finally, if there is a configuration file but you don't want to use it, you can disable it using the `--ignore-config-path` option. + [automaxprocs]: https://github.com/uber-go/automaxprocs [NATS]: https://nats.io diff --git a/features/file_config.testfile b/features/file_config.testfile new file mode 100644 index 00000000..6e2b4a6d --- /dev/null +++ b/features/file_config.testfile @@ -0,0 +1,42 @@ +# Generate a configuration file with provided values +run :anycable_gen_config, + ["sh", "-c", './dist/anycable-go --noauth --port 2024 --broadcast_adapter=http,redisx --print-config > ./anycable.toml'], + env: {"ANYCABLE_SECRET" => "file-secret", "ANYCABLE_NODE_ID" => "node-1"}, clean_env: true + +unless File.exist?("anycable.toml") + fail "Config file hasn't been generated" +end + +at_exit { File.delete("anycable.toml") } + +# Load from the default local path +run :anycable_load_config, "dist/anycable-go --print-config", clean_env: true + +config = PerfectTOML.parse(stdout(:anycable_load_config)) + +$errors = [] +def assert_equal(field, expected, actual) + $errors << "Expected #{field} to be #{expected}, got #{actual.nil? ? '' : actual}" unless expected == actual +end + +# top-level params +assert_equal("node ID", "node-1", config["node_id"]) +assert_equal("noauth", true, config["noauth"]) +assert_equal("secret", "file-secret", config["secret"]) +assert_equal("broadcast adapters", %w[http redisx], config["broadcast_adapters"]) + +# nested params: TODO +# assert_equal("server.port", 2024, config.dig("server", "port")) + +if $errors.any? + fail $errors.join("\n") +end + +# Ignoring the config file +run :anycable_ignore_config_path, "dist/anycable-go --ignore-config-path --print-config", clean_env: true + +config = PerfectTOML.parse(stdout(:anycable_ignore_config_path)) + +$errors.clear +assert_equal("node ID", nil, config["node_id"]) +assert_equal("secret", nil, config["secret"]) diff --git a/features/runner.rb b/features/runner.rb index def4ec8c..03f9bfde 100644 --- a/features/runner.rb +++ b/features/runner.rb @@ -7,9 +7,13 @@ gemfile(retried, quiet: true) do source "https://rubygems.org" + gem "logger" + gem "ostruct" + gem "childprocess", "~> 4.1" gem "jwt" gem "activesupport", "~> 7.0.0" + gem "perfect_toml" end rescue raise if retried @@ -66,12 +70,21 @@ def launch(name, cmd, env: {}, debug: ENV["DEBUG"] == "true", capture_output: fa process.start end - def run(name, cmd, timeout: RUN_TIMEOUT) + def run(name, cmd, env: {}, clean_env: false, timeout: RUN_TIMEOUT) log(:info) { "Running command: #{cmd}" } r, w = IO.pipe - process = ChildProcess.build(*cmd.split(/\s+/)) + cmd = cmd.is_a?(Array) ? cmd : cmd.split(/\s+/) + + process = ChildProcess.build(*cmd) + # set process environment variables + # Remove all ANYCABLE_ vars that could be set by the Makefile + if clean_env + ENV.each { |k, _| env[k] ||= nil if k.start_with?("ANYCABLE_") } + end + + process.environment.merge!(env) process.io.stdout = w process.io.stderr = w @@ -186,7 +199,7 @@ def log(level, &block) runner.load(script, filename) puts "All OK 👍" rescue => e - $stderr.puts e.message + "\n#{e.backtrace.take(5).join("\n")}" + $stderr.puts e.message + "\n#{e.backtrace.take(5).join("\n") if ENV["DEBUG"] == "true"}" exit(1) ensure runner.shutdown diff --git a/go.mod b/go.mod index a23ba0bc..55ce6861 100644 --- a/go.mod +++ b/go.mod @@ -36,6 +36,7 @@ replace github.com/mitchellh/go-mruby => ./vendorlib/go-mruby require github.com/sony/gobreaker v1.0.0 require ( + github.com/BurntSushi/toml v1.4.0 // indirect github.com/bufbuild/protocompile v0.14.1 // indirect github.com/klauspost/compress v1.17.10 // indirect github.com/kr/pretty v0.2.0 // indirect diff --git a/go.sum b/go.sum index b5bf44ae..1645b9a2 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= +github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/FZambia/sentinel v1.1.1 h1:0ovTimlR7Ldm+wR15GgO+8C2dt7kkn+tm3PQS+Qk3Ek= github.com/FZambia/sentinel v1.1.1/go.mod h1:ytL1Am/RLlAoAXG6Kj5LNuw/TRRQrv2rt2FT26vP5gI= github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw=