From 78877cda84760775f8a06fa072ad1eaf611a7e13 Mon Sep 17 00:00:00 2001 From: Pedro Date: Thu, 5 Feb 2026 14:49:19 +0700 Subject: [PATCH 1/3] parse lua-load-per-thrad as an array --- config-parser/parsers/lua-load-per-thread.go | 186 +++++++++++++++++++ config-parser/section-parsers.go | 2 +- configuration/global.go | 84 ++++++++- 3 files changed, 262 insertions(+), 10 deletions(-) create mode 100644 config-parser/parsers/lua-load-per-thread.go diff --git a/config-parser/parsers/lua-load-per-thread.go b/config-parser/parsers/lua-load-per-thread.go new file mode 100644 index 00000000..e5324b46 --- /dev/null +++ b/config-parser/parsers/lua-load-per-thread.go @@ -0,0 +1,186 @@ +/* +Copyright 2019 HAProxy Technologies + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either expressed or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package parsers + +import ( + "github.com/haproxytech/client-native/v6/config-parser/common" + "github.com/haproxytech/client-native/v6/config-parser/errors" + "github.com/haproxytech/client-native/v6/config-parser/types" +) + +type LuaLoadPerThread struct { + data []types.LuaLoad + preComments []string // comments that appear before the actual line +} + +func (p *LuaLoadPerThread) Init() { + p.data = []types.LuaLoad{} + p.preComments = []string{} +} + +func (p *LuaLoadPerThread) GetParserName() string { + return "lua-load-per-thread" +} + +func (p *LuaLoadPerThread) Get(createIfNotExist bool) (common.ParserData, error) { + if len(p.data) == 0 && !createIfNotExist { + return nil, errors.ErrFetch + } + return p.data, nil +} + +func (p *LuaLoadPerThread) GetPreComments() ([]string, error) { + return p.preComments, nil +} + +func (p *LuaLoadPerThread) SetPreComments(preComments []string) { + p.preComments = preComments +} + +func (p *LuaLoadPerThread) GetOne(index int) (common.ParserData, error) { + if index < 0 || index >= len(p.data) { + return nil, errors.ErrFetch + } + return p.data[index], nil +} + +func (p *LuaLoadPerThread) Delete(index int) error { + if index < 0 || index >= len(p.data) { + return errors.ErrFetch + } + copy(p.data[index:], p.data[index+1:]) + p.data[len(p.data)-1] = types.LuaLoad{} + p.data = p.data[:len(p.data)-1] + return nil +} + +func (p *LuaLoadPerThread) Insert(data common.ParserData, index int) error { + if data == nil { + return errors.ErrInvalidData + } + switch newValue := data.(type) { + case []types.LuaLoad: + p.data = newValue + case *types.LuaLoad: + if index > -1 { + if index > len(p.data) { + return errors.ErrIndexOutOfRange + } + p.data = append(p.data, types.LuaLoad{}) + copy(p.data[index+1:], p.data[index:]) + p.data[index] = *newValue + } else { + p.data = append(p.data, *newValue) + } + case types.LuaLoad: + if index > -1 { + if index > len(p.data) { + return errors.ErrIndexOutOfRange + } + p.data = append(p.data, types.LuaLoad{}) + copy(p.data[index+1:], p.data[index:]) + p.data[index] = newValue + } else { + p.data = append(p.data, newValue) + } + default: + return errors.ErrInvalidData + } + return nil +} + +func (p *LuaLoadPerThread) Set(data common.ParserData, index int) error { + if data == nil { + p.Init() + return nil + } + switch newValue := data.(type) { + case []types.LuaLoad: + p.data = newValue + case *types.LuaLoad: + if index > -1 && index < len(p.data) { + p.data[index] = *newValue + } else if index == -1 { + p.data = append(p.data, *newValue) + } else { + return errors.ErrIndexOutOfRange + } + case types.LuaLoad: + if index > -1 && index < len(p.data) { + p.data[index] = newValue + } else if index == -1 { + p.data = append(p.data, newValue) + } else { + return errors.ErrIndexOutOfRange + } + default: + return errors.ErrInvalidData + } + return nil +} + +func (p *LuaLoadPerThread) PreParse(line string, parts []string, preComments []string, comment string) (string, error) { + changeState, err := p.Parse(line, parts, comment) + if err == nil && preComments != nil { + p.preComments = append(p.preComments, preComments...) + } + return changeState, err +} + +func (p *LuaLoadPerThread) Parse(line string, parts []string, comment string) (string, error) { + if parts[0] == "lua-load-per-thread" { + data, err := p.parse(line, parts, comment) + if err != nil { + if _, ok := err.(*errors.ParseError); ok { + return "", err + } + return "", &errors.ParseError{Parser: "LuaLoadPerThread", Line: line} + } + p.data = append(p.data, *data) + return "", nil + } + return "", &errors.ParseError{Parser: "LuaLoadPerThread", Line: line} +} + +func (p *LuaLoadPerThread) Result() ([]common.ReturnResultLine, error) { + if len(p.data) == 0 { + return nil, errors.ErrFetch + } + result := make([]common.ReturnResultLine, len(p.data)) + for index, data := range p.data { + result[index] = common.ReturnResultLine{ + Data: "lua-load-per-thread " + data.File, + Comment: data.Comment, + } + } + return result, nil +} + +func (p *LuaLoadPerThread) ResultAll() ([]common.ReturnResultLine, []string, error) { + return p.Result() +} + +func (p *LuaLoadPerThread) parse(line string, parts []string, comment string) (*types.LuaLoad, error) { + if len(parts) < 2 { + return nil, &errors.ParseError{Parser: "LuaLoadPerThread", Line: line} + } + lua := &types.LuaLoad{ + File: parts[1], + Comment: comment, + } + return lua, nil +} diff --git a/config-parser/section-parsers.go b/config-parser/section-parsers.go index 1cd38ad7..0cd9e680 100644 --- a/config-parser/section-parsers.go +++ b/config-parser/section-parsers.go @@ -437,7 +437,7 @@ func (p *configParser) getGlobalParser() *Parsers { //nolint: maintidx addParser(parser, &sequence, &simple.Enabled{Name: "insecure-setuid-wanted"}) addParser(parser, &sequence, &simple.String{Name: "issuers-chain-path"}) addParser(parser, &sequence, &simple.Enabled{Name: "h2-workaround-bogus-websocket-clients"}) - addParser(parser, &sequence, &simple.String{Name: "lua-load-per-thread"}) + addParser(parser, &sequence, &parsers.LuaLoadPerThread{}) addParser(parser, &sequence, &simple.Number{Name: "mworker-max-reloads"}) addParser(parser, &sequence, &simple.String{Name: "node"}) addParser(parser, &sequence, &parsers.NumaCPUMapping{}) diff --git a/configuration/global.go b/configuration/global.go index e4ced5cc..1882a8b9 100644 --- a/configuration/global.go +++ b/configuration/global.go @@ -260,13 +260,26 @@ func parseLuaOptions(p parser.Parser) (*models.LuaOptions, error) { options := &models.LuaOptions{} isEmpty := true - luaLoadPerThread, err := parseStringOption(p, "lua-load-per-thread") - if err != nil { - return nil, err - } - if luaLoadPerThread != "" { - isEmpty = false - options.LoadPerThread = luaLoadPerThread + // lua-load-per-thread can appear multiple times, so we need to handle it as an array + // For backward compatibility with the spec (which defines it as string), we'll take the last one + // TODO: Update spec to support array and change model accordingly + var luaLoadPerThreads []*models.LuaLoad + cpLuaLoadPerThreads, err := p.Get(parser.Global, parser.GlobalSectionName, "lua-load-per-thread") + if err == nil { + luas, ok := cpLuaLoadPerThreads.([]types.LuaLoad) + if !ok { + return nil, misc.CreateTypeAssertError("lua-load-per-thread") + } + for _, l := range luas { + file := l.File + luaLoadPerThreads = append(luaLoadPerThreads, &models.LuaLoad{File: &file}) + } + // For now, keep the last one in LoadPerThread for backward compatibility + // Once spec is updated to array, this can be removed + if len(luas) > 0 { + isEmpty = false + options.LoadPerThread = luas[len(luas)-1].File + } } var luaPrependPath []*models.LuaPrependPath @@ -3114,10 +3127,63 @@ func serializePerformanceOptions(p parser.Parser, options *models.PerformanceOpt func serializeLuaOptions(p parser.Parser, options *models.LuaOptions) error { if options == nil { options = &models.LuaOptions{} + // If LuaOptions is nil, try to preserve existing lua-load-per-thread from parser + // This is important for PostRawConfiguration to preserve all instances + cpLuaLoadPerThreads, err := p.Get(parser.Global, parser.GlobalSectionName, "lua-load-per-thread") + if err == nil { + luas, ok := cpLuaLoadPerThreads.([]types.LuaLoad) + if ok && len(luas) > 0 { + // Parser already has all instances, just preserve them by not overwriting + // The parser will write them back correctly + // For backward compatibility with spec (string), set last one + options.LoadPerThread = luas[len(luas)-1].File + // Continue to serialize other lua options + } else { + // Fallback: try as single string for backward compatibility + existingLoadPerThread, err := parseStringOption(p, "lua-load-per-thread") + if err == nil && existingLoadPerThread != "" { + options.LoadPerThread = existingLoadPerThread + } + } + } } - if err := serializeStringOption(p, "lua-load-per-thread", options.LoadPerThread); err != nil { - return err + // Serialize lua-load-per-thread as array to support multiple instances + // First check if parser already has multiple instances (from PostRawConfiguration) + cpLuaLoadPerThreads, err := p.Get(parser.Global, parser.GlobalSectionName, "lua-load-per-thread") + if err == nil { + existingLuas, ok := cpLuaLoadPerThreads.([]types.LuaLoad) + if ok && len(existingLuas) > 0 { + // Parser has multiple instances, preserve them all + // Only update if LoadPerThread is explicitly set and different + if options.LoadPerThread != "" && (len(existingLuas) == 0 || existingLuas[len(existingLuas)-1].File != options.LoadPerThread) { + // Replace with new value (single instance for now until spec is updated) + luaLoadPerThreads := []types.LuaLoad{ + {File: options.LoadPerThread}, + } + if err := p.Set(parser.Global, parser.GlobalSectionName, "lua-load-per-thread", luaLoadPerThreads); err != nil { + return err + } + } + // Otherwise, keep existing instances - don't overwrite + } else if options.LoadPerThread != "" { + // No existing instances, create new one + luaLoadPerThreads := []types.LuaLoad{ + {File: options.LoadPerThread}, + } + if err := p.Set(parser.Global, parser.GlobalSectionName, "lua-load-per-thread", luaLoadPerThreads); err != nil { + return err + } + } + // If options.LoadPerThread is empty and no existing instances, don't clear - might be intentional + } else if options.LoadPerThread != "" { + // Parser doesn't have it, create new one + luaLoadPerThreads := []types.LuaLoad{ + {File: options.LoadPerThread}, + } + if err := p.Set(parser.Global, parser.GlobalSectionName, "lua-load-per-thread", luaLoadPerThreads); err != nil { + return err + } } luaLoads := []types.LuaLoad{} From ae7f42d7770da08bff9e1e265b0e43c5870d0dd0 Mon Sep 17 00:00:00 2001 From: Pedro Date: Thu, 5 Feb 2026 14:57:43 +0700 Subject: [PATCH 2/3] update return --- config-parser/parsers/lua-load-per-thread.go | 3 +- .../lua-load-per-thread_generated_test.go | 120 ++++++++++++++++++ 2 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 config-parser/tests/lua-load-per-thread_generated_test.go diff --git a/config-parser/parsers/lua-load-per-thread.go b/config-parser/parsers/lua-load-per-thread.go index e5324b46..6a3f68c9 100644 --- a/config-parser/parsers/lua-load-per-thread.go +++ b/config-parser/parsers/lua-load-per-thread.go @@ -171,7 +171,8 @@ func (p *LuaLoadPerThread) Result() ([]common.ReturnResultLine, error) { } func (p *LuaLoadPerThread) ResultAll() ([]common.ReturnResultLine, []string, error) { - return p.Result() + res, err := p.Result() + return res, p.preComments, err } func (p *LuaLoadPerThread) parse(line string, parts []string, comment string) (*types.LuaLoad, error) { diff --git a/config-parser/tests/lua-load-per-thread_generated_test.go b/config-parser/tests/lua-load-per-thread_generated_test.go new file mode 100644 index 00000000..b1ebd672 --- /dev/null +++ b/config-parser/tests/lua-load-per-thread_generated_test.go @@ -0,0 +1,120 @@ +// Code generated by go generate; DO NOT EDIT. +/* +Copyright 2019 HAProxy Technologies + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tests + +import ( + "fmt" + "strings" + "testing" + + "github.com/haproxytech/client-native/v6/config-parser/parsers" +) + +func TestLuaLoadPerThread(t *testing.T) { + tests := map[string]bool{ + "lua-load-per-thread /etc/haproxy/lua/foo.lua": true, + "lua-load-per-thread": false, + "---": false, + "--- ---": false, + } + parser := &parsers.LuaLoadPerThread{} + for command, shouldPass := range tests { + t.Run(command, func(t *testing.T) { + line := strings.TrimSpace(command) + lines := strings.SplitN(line, "\n", -1) + var err error + parser.Init() + if len(lines) > 1 { + for _, line = range lines { + line = strings.TrimSpace(line) + if err = ProcessLine(line, parser); err != nil { + break + } + } + } else { + err = ProcessLine(line, parser) + } + if shouldPass { + if err != nil { + t.Error(err) + return + } + result, err := parser.Result() + if err != nil { + t.Error(err) + return + } + var returnLine string + if result[0].Comment == "" { + returnLine = result[0].Data + } else { + returnLine = fmt.Sprintf("%s # %s", result[0].Data, result[0].Comment) + } + if command != returnLine { + t.Errorf("error: has [%s] expects [%s]", returnLine, command) + } + } else { + if err == nil { + t.Errorf("error: did not throw error for line [%s]", line) + } + _, parseErr := parser.Result() + if parseErr == nil { + t.Errorf("error: did not throw error on result for line [%s]", line) + } + } + }) + } +} + +func TestLuaLoadPerThreadMultiple(t *testing.T) { + parser := &parsers.LuaLoadPerThread{} + parser.Init() + + // Test multiple instances + lines := []string{ + "lua-load-per-thread /etc/haproxy/lua/ja4.lua", + "lua-load-per-thread /etc/haproxy/lua/ja4h.lua", + } + + for _, line := range lines { + err := ProcessLine(line, parser) + if err != nil { + t.Errorf("error parsing line [%s]: %v", line, err) + return + } + } + + result, err := parser.Result() + if err != nil { + t.Errorf("error getting result: %v", err) + return + } + + if len(result) != 2 { + t.Errorf("expected 2 results, got %d", len(result)) + return + } + + if result[0].Data != "lua-load-per-thread /etc/haproxy/lua/ja4.lua" { + t.Errorf("expected first result to be 'lua-load-per-thread /etc/haproxy/lua/ja4.lua', got '%s'", result[0].Data) + } + + if result[1].Data != "lua-load-per-thread /etc/haproxy/lua/ja4h.lua" { + t.Errorf("expected second result to be 'lua-load-per-thread /etc/haproxy/lua/ja4h.lua', got '%s'", result[1].Data) + } +} From ff8ff569eb07409b4668e3d062355d9cf2a0c40f Mon Sep 17 00:00:00 2001 From: Pedro Date: Thu, 5 Feb 2026 15:00:28 +0700 Subject: [PATCH 3/3] add test --- test/lua_load_per_thread_test.go | 134 +++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 test/lua_load_per_thread_test.go diff --git a/test/lua_load_per_thread_test.go b/test/lua_load_per_thread_test.go new file mode 100644 index 00000000..6449ba52 --- /dev/null +++ b/test/lua_load_per_thread_test.go @@ -0,0 +1,134 @@ +// Copyright 2019 HAProxy Technologies +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +package test + +import ( + "context" + "os" + "strings" + "testing" + + "github.com/haproxytech/client-native/v6/configuration" + "github.com/haproxytech/client-native/v6/configuration/options" + "github.com/stretchr/testify/require" +) + +// TestLuaLoadPerThreadMultipleInstances tests that multiple instances of +// lua-load-per-thread are preserved when using PostRawConfiguration +func TestLuaLoadPerThreadMultipleInstances(t *testing.T) { + // Create a temporary config file + tmpFile, err := os.CreateTemp("", "haproxy_test_*.cfg") + require.NoError(t, err) + defer os.Remove(tmpFile.Name()) + tmpFile.Close() + + // Initial config with multiple lua-load-per-thread instances + initialConfig := `# _version=1 +global + lua-load-per-thread /etc/haproxy/lua/ja4.lua + lua-load-per-thread /etc/haproxy/lua/ja4h.lua + stats socket /var/run/haproxy.sock level admin +defaults + mode http +` + + err = os.WriteFile(tmpFile.Name(), []byte(initialConfig), 0644) + require.NoError(t, err) + + // Create configuration client + client, err := configuration.New(context.Background(), + options.ConfigurationFile(tmpFile.Name()), + options.UsePersistentTransactions, + options.TransactionsDir("/tmp"), + ) + require.NoError(t, err) + + // Get raw configuration + _, rawConfig, err := client.GetRawConfiguration("", 0) + require.NoError(t, err) + + // Verify both instances are present + require.Contains(t, rawConfig, "lua-load-per-thread /etc/haproxy/lua/ja4.lua", "First instance should be present") + require.Contains(t, rawConfig, "lua-load-per-thread /etc/haproxy/lua/ja4h.lua", "Second instance should be present") + + // Post the raw configuration back (this is what was failing before) + err = client.PostRawConfiguration(&rawConfig, 0, true) + require.NoError(t, err) + + // Get the configuration again to verify both instances are still there + _, rawConfigAfter, err := client.GetRawConfiguration("", 0) + require.NoError(t, err) + + // Count occurrences + count1 := strings.Count(rawConfigAfter, "lua-load-per-thread /etc/haproxy/lua/ja4.lua") + count2 := strings.Count(rawConfigAfter, "lua-load-per-thread /etc/haproxy/lua/ja4h.lua") + + require.Equal(t, 1, count1, "First instance should appear exactly once") + require.Equal(t, 1, count2, "Second instance should appear exactly once") + + // Verify both are still present + require.Contains(t, rawConfigAfter, "lua-load-per-thread /etc/haproxy/lua/ja4.lua", "First instance should still be present after PostRawConfiguration") + require.Contains(t, rawConfigAfter, "lua-load-per-thread /etc/haproxy/lua/ja4h.lua", "Second instance should still be present after PostRawConfiguration") +} + +// TestLuaLoadPerThreadStructuredAPI tests that the structured API works +// (though it will only return the last instance due to spec limitation) +func TestLuaLoadPerThreadStructuredAPI(t *testing.T) { + // Create a temporary config file + tmpFile, err := os.CreateTemp("", "haproxy_test_*.cfg") + require.NoError(t, err) + defer os.Remove(tmpFile.Name()) + tmpFile.Close() + + // Initial config with multiple lua-load-per-thread instances + initialConfig := `# _version=1 +global + lua-load-per-thread /etc/haproxy/lua/ja4.lua + lua-load-per-thread /etc/haproxy/lua/ja4h.lua + stats socket /var/run/haproxy.sock level admin +defaults + mode http +` + + err = os.WriteFile(tmpFile.Name(), []byte(initialConfig), 0644) + require.NoError(t, err) + + // Create configuration client + client, err := configuration.New(context.Background(), + options.ConfigurationFile(tmpFile.Name()), + options.UsePersistentTransactions, + options.TransactionsDir("/tmp"), + ) + require.NoError(t, err) + + // Get global configuration via structured API + _, global, err := client.GetGlobalConfiguration("") + require.NoError(t, err) + + // Due to spec limitation (string not array), only last instance is returned + // This is expected behavior until spec is updated + if global.LuaOptions != nil && global.LuaOptions.LoadPerThread != "" { + // Should be the last one (ja4h.lua) + require.Equal(t, "/etc/haproxy/lua/ja4h.lua", global.LuaOptions.LoadPerThread, + "Structured API returns last instance (spec limitation)") + } + + // Verify raw config still has both + _, rawConfig, err := client.GetRawConfiguration("", 0) + require.NoError(t, err) + require.Contains(t, rawConfig, "lua-load-per-thread /etc/haproxy/lua/ja4.lua") + require.Contains(t, rawConfig, "lua-load-per-thread /etc/haproxy/lua/ja4h.lua") +}