From 9c585e2de8d85fb77941d1ecdde60a6072b3a79b Mon Sep 17 00:00:00 2001 From: ginuerzh Date: Sat, 20 Jun 2026 18:56:08 +0800 Subject: [PATCH] feat(parser): support multiple -C config files, merged left-to-right MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow passing -C multiple times to load and merge several configuration files in order. Slice fields are appended; scalar fields (TLS, Log, API, Metrics, Profiling) are overridden by later files — the same semantics as the existing mergeConfig() used for CLI+config merging. - Change Args.CfgFile string to Args.CfgFiles []string - Extract readConfig() helper from Parse() for single-source loading - Loop over CfgFiles in Parse(), merging each into the accumulator - Add Quotas to mergeConfig (was missing from the slice list) - Add TestParse_MultiFile integration test for two-file merge Closes go-gost/gost#150 --- config/parsing/parser/parse_test.go | 66 ++++++++++++++++++++++++-- config/parsing/parser/parser.go | 73 ++++++++++++++++++----------- 2 files changed, 109 insertions(+), 30 deletions(-) diff --git a/config/parsing/parser/parse_test.go b/config/parsing/parser/parse_test.go index 8920bc1f..5e157ce2 100644 --- a/config/parsing/parser/parse_test.go +++ b/config/parsing/parser/parse_test.go @@ -1,6 +1,8 @@ package parser import ( + "os" + "path/filepath" "testing" "github.com/go-gost/x/config" @@ -47,6 +49,7 @@ func TestMergeConfig_AppendsAllSlices(t *testing.T) { SDs: []*config.SDConfig{{Name: "sd1"}}, Recorders: []*config.RecorderConfig{{Name: "rec1"}}, Limiters: []*config.LimiterConfig{{Name: "l1"}}, + Quotas: []*config.QuotaConfig{{Name: "q1"}}, CLimiters: []*config.LimiterConfig{{Name: "cl1"}}, RLimiters: []*config.LimiterConfig{{Name: "rl1"}}, Loggers: []*config.LoggerConfig{{Name: "log1"}}, @@ -66,6 +69,7 @@ func TestMergeConfig_AppendsAllSlices(t *testing.T) { SDs: []*config.SDConfig{{Name: "sd2"}}, Recorders: []*config.RecorderConfig{{Name: "rec2"}}, Limiters: []*config.LimiterConfig{{Name: "l2"}}, + Quotas: []*config.QuotaConfig{{Name: "q2"}}, CLimiters: []*config.LimiterConfig{{Name: "cl2"}}, RLimiters: []*config.LimiterConfig{{Name: "rl2"}}, Loggers: []*config.LoggerConfig{{Name: "log2"}}, @@ -111,6 +115,9 @@ func TestMergeConfig_AppendsAllSlices(t *testing.T) { if len(got.Limiters) != 2 { t.Fatal("limiters not properly appended") } + if len(got.Quotas) != 2 { + t.Fatal("quotas not properly appended") + } if len(got.CLimiters) != 2 { t.Fatal("climiters not properly appended") } @@ -194,7 +201,7 @@ func TestMergeConfig_ScalarKeepsCfg1WhenCfg2Nil(t *testing.T) { func TestInit(t *testing.T) { Init(Args{ - CfgFile: "test.yml", + CfgFiles: []string{"test.yml"}, Services: []string{"s1"}, Nodes: []string{"n1"}, Debug: true, @@ -203,8 +210,8 @@ func TestInit(t *testing.T) { MetricsAddr: ":9090", }) - if defaultParser.args.CfgFile != "test.yml" { - t.Fatalf("CfgFile not set: got %q", defaultParser.args.CfgFile) + if len(defaultParser.args.CfgFiles) != 1 || defaultParser.args.CfgFiles[0] != "test.yml" { + t.Fatalf("CfgFiles not set: got %v", defaultParser.args.CfgFiles) } if len(defaultParser.args.Services) != 1 || defaultParser.args.Services[0] != "s1" { t.Fatal("Services not set") @@ -225,3 +232,56 @@ func TestInit(t *testing.T) { t.Fatal("MetricsAddr not set") } } + +func TestParse_MultiFile(t *testing.T) { + // Create two temporary config files. + cfg1 := &config.Config{ + Services: []*config.ServiceConfig{{Name: "s1", Addr: ":8080"}}, + } + cfg2 := &config.Config{ + Services: []*config.ServiceConfig{{Name: "s2", Addr: ":8081"}}, + Log: &config.LogConfig{Level: "debug"}, + Quotas: []*config.QuotaConfig{{Name: "q1"}}, + } + + tmpDir := t.TempDir() + file1 := filepath.Join(tmpDir, "cfg1.yml") + file2 := filepath.Join(tmpDir, "cfg2.yml") + + for _, entry := range []struct { + path string + cfg *config.Config + }{ + {file1, cfg1}, + {file2, cfg2}, + } { + f, err := os.Create(entry.path) + if err != nil { + t.Fatal(err) + } + if err := entry.cfg.Write(f, "yaml"); err != nil { + f.Close() + t.Fatal(err) + } + f.Close() + } + + Init(Args{ + CfgFiles: []string{file1, file2}, + }) + + got, err := Parse() + if err != nil { + t.Fatalf("Parse(): %v", err) + } + + if len(got.Services) != 2 { + t.Fatalf("expected 2 services, got %d", len(got.Services)) + } + if got.Log == nil || got.Log.Level != "debug" { + t.Fatal("Log.Level not set from cfg2") + } + if len(got.Quotas) != 1 || got.Quotas[0].Name != "q1" { + t.Fatal("Quotas not merged from cfg2") + } +} diff --git a/config/parsing/parser/parser.go b/config/parsing/parser/parser.go index 765ef416..3e4c25b3 100644 --- a/config/parsing/parser/parser.go +++ b/config/parsing/parser/parser.go @@ -35,9 +35,9 @@ func Parse() (*config.Config, error) { // Args holds the raw CLI flags and positional arguments that Init will merge // into the final configuration. type Args struct { - // CfgFile is the path to a YAML or JSON config file, "-" for stdin, or - // an inline JSON string. - CfgFile string + // CfgFiles is the list of YAML or JSON config file paths, "-" for stdin, or + // an inline JSON string. Multiple files are merged left-to-right. + CfgFiles []string // Services is the list of service definitions from -L flags. Services []string @@ -65,31 +65,16 @@ type parser struct { func (p *parser) Parse() (*config.Config, error) { cfg := &config.Config{} - if cfgFile := strings.TrimSpace(p.args.CfgFile); cfgFile != "" { - if cfgFile == "-" { // stdin - br := bufio.NewReader(os.Stdin) - b, err := br.Peek(1) - if err != nil { - return nil, err - } - if b[0] == '{' { - if err := cfg.Read(br, "json"); err != nil { - return nil, err - } - } else { - if err := cfg.Read(br, "yaml"); err != nil { - return nil, err - } - } - } else if strings.HasPrefix(cfgFile, "{") && strings.HasSuffix(cfgFile, "}") { // inline - if err := json.Unmarshal([]byte(cfgFile), cfg); err != nil { - return nil, err - } - } else { - if err := cfg.ReadFile(cfgFile); err != nil { // file - return nil, err - } + for _, cfgFile := range p.args.CfgFiles { + cfgFile = strings.TrimSpace(cfgFile) + if cfgFile == "" { + continue } + fcfg, err := readConfig(cfgFile) + if err != nil { + return nil, err + } + cfg = mergeConfig(cfg, fcfg) } cmdCfg, err := cmd.BuildConfigFromCmd(p.args.Services, p.args.Nodes) @@ -193,6 +178,39 @@ func (p *parser) Parse() (*config.Config, error) { return cfg, nil } +// readConfig reads a single configuration source, which may be "-" (stdin), +// an inline JSON string starting with "{", or a file path. +func readConfig(cfgFile string) (*config.Config, error) { + cfg := &config.Config{} + + if cfgFile == "-" { // stdin + br := bufio.NewReader(os.Stdin) + b, err := br.Peek(1) + if err != nil { + return nil, err + } + if b[0] == '{' { + if err := cfg.Read(br, "json"); err != nil { + return nil, err + } + } else { + if err := cfg.Read(br, "yaml"); err != nil { + return nil, err + } + } + } else if strings.HasPrefix(cfgFile, "{") && strings.HasSuffix(cfgFile, "}") { // inline + if err := json.Unmarshal([]byte(cfgFile), cfg); err != nil { + return nil, err + } + } else { + if err := cfg.ReadFile(cfgFile); err != nil { // file + return nil, err + } + } + + return cfg, nil +} + func mergeConfig(cfg1, cfg2 *config.Config) *config.Config { if cfg1 == nil { return cfg2 @@ -214,6 +232,7 @@ func mergeConfig(cfg1, cfg2 *config.Config) *config.Config { SDs: append(cfg1.SDs, cfg2.SDs...), Recorders: append(cfg1.Recorders, cfg2.Recorders...), Limiters: append(cfg1.Limiters, cfg2.Limiters...), + Quotas: append(cfg1.Quotas, cfg2.Quotas...), CLimiters: append(cfg1.CLimiters, cfg2.CLimiters...), RLimiters: append(cfg1.RLimiters, cfg2.RLimiters...), Loggers: append(cfg1.Loggers, cfg2.Loggers...),