Skip to content

Commit 5837316

Browse files
sarg3ntclaude
andauthored
feat: add HTTP keep-alive support to HAProxy backend configuration (#108)
* feat: add HTTP keep-alive support to HAProxy backend configuration * fix(haproxy): gate http-keep-alive on http mode; clean up keep-alive test Address Copilot review findings on PR #108: - Only emit `option http-keep-alive` when backend.Mode == "http"; emitting it for tcp-mode backends produces an invalid stanza. - Drop BackendSSL from the keep-alive test (it was generating `verify` with an empty value, unrelated to keep-alive). Add a separate test asserting the directive is suppressed for tcp-mode backends. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 48f6c7a commit 5837316

4 files changed

Lines changed: 81 additions & 34 deletions

File tree

gearbox-agent/internal/framework/services/compose/parser.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ type BackendConfig struct {
124124
ACLIP string `json:"acl_ip,omitempty"`
125125
BackendSSL bool `json:"backend_ssl"`
126126
BackendSSLVerify string `json:"backend_ssl_verify"`
127+
HTTPKeepAlive bool `json:"http_keep_alive"`
127128
}
128129

129130
// Container represents a container in a Docker Compose service.
@@ -142,7 +143,7 @@ type ComposeFile struct {
142143
// Service represents a service in a docker-compose.yml file.
143144
type Service struct {
144145
Labels any `yaml:"labels"`
145-
NetworkMode string `yaml:"network_mode"`
146+
NetworkMode string `yaml:"network_mode"`
146147
DependsOn any `yaml:"depends_on"`
147148
Other map[string]any `yaml:",inline"`
148149
}
@@ -378,6 +379,7 @@ func (p *Parser) extractBackendConfig(labels map[string]string, appName, service
378379
ACLIP: aclIP,
379380
BackendSSL: labels[LabelPrefix+"backend.ssl"] == "true",
380381
BackendSSLVerify: sslVerify,
382+
HTTPKeepAlive: labels[LabelPrefix+"backend.http_keep_alive"] == "true",
381383
}
382384

383385
return config

gearbox-agent/internal/framework/services/compose/parser_test.go

Lines changed: 27 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -74,28 +74,27 @@ func TestParseFile_AllLabels(t *testing.T) {
7474
t.Fatalf("Failed to create app dir: %v", err)
7575
}
7676

77-
composeContent := `
78-
services:
79-
api:
80-
image: api:latest
81-
labels:
82-
haproxy.enable: "true"
83-
haproxy.hostname: "api.example.com"
84-
haproxy.backend.server: "api:3000"
85-
haproxy.backend.name: "custom_backend"
86-
haproxy.backend.mode: "http"
87-
haproxy.backend.balance: "leastconn"
88-
haproxy.backend.check: "true"
89-
haproxy.backend.check.interval: "10s"
90-
haproxy.backend.check.fall: "5"
91-
haproxy.backend.check.rise: "3"
92-
haproxy.ssl.redirect: "true"
93-
haproxy.public: "true"
94-
haproxy.rate_limit: "200"
95-
haproxy.acl.ip: "10.0.0.0/8"
96-
haproxy.backend.ssl: "true"
97-
haproxy.backend.ssl.verify: "required"
98-
`
77+
composeContent := "services:\n" +
78+
" api:\n" +
79+
" image: api:latest\n" +
80+
" labels:\n" +
81+
" haproxy.enable: \"true\"\n" +
82+
" haproxy.hostname: \"api.example.com\"\n" +
83+
" haproxy.backend.server: \"api:3000\"\n" +
84+
" haproxy.backend.name: \"custom_backend\"\n" +
85+
" haproxy.backend.mode: \"http\"\n" +
86+
" haproxy.backend.balance: \"leastconn\"\n" +
87+
" haproxy.backend.check: \"true\"\n" +
88+
" haproxy.backend.check.interval: \"10s\"\n" +
89+
" haproxy.backend.check.fall: \"5\"\n" +
90+
" haproxy.backend.check.rise: \"3\"\n" +
91+
" haproxy.ssl.redirect: \"true\"\n" +
92+
" haproxy.public: \"true\"\n" +
93+
" haproxy.rate_limit: \"200\"\n" +
94+
" haproxy.acl.ip: \"10.0.0.0/8\"\n" +
95+
" haproxy.backend.ssl: \"true\"\n" +
96+
" haproxy.backend.ssl.verify: \"required\"\n" +
97+
" haproxy.backend.http_keep_alive: \"true\"\n"
9998
composePath := filepath.Join(appDir, "docker-compose.yml")
10099
if err := os.WriteFile(composePath, []byte(composeContent), 0644); err != nil {
101100
t.Fatalf("Failed to write compose file: %v", err)
@@ -151,6 +150,9 @@ services:
151150
if b.BackendSSLVerify != "required" {
152151
t.Errorf("BackendSSLVerify = %q, want %q", b.BackendSSLVerify, "required")
153152
}
153+
if !b.HTTPKeepAlive {
154+
t.Error("HTTPKeepAlive should be true")
155+
}
154156
}
155157

156158
func TestParseFile_DisabledService(t *testing.T) {
@@ -516,9 +518,9 @@ func TestGetOrDefault(t *testing.T) {
516518
}
517519

518520
tests := []struct {
519-
key string
520-
defVal string
521-
want string
521+
key string
522+
defVal string
523+
want string
522524
}{
523525
{"key1", "default", "value1"},
524526
{"key2", "default", "default"}, // Empty string uses default

gearbox-agent/internal/framework/services/haproxy/generator.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,9 @@ func (g *Generator) GenerateBackendConfig() string {
120120
lines = append(lines, fmt.Sprintf("backend %s", backend.BackendName))
121121
lines = append(lines, fmt.Sprintf(" mode %s", backend.Mode))
122122
lines = append(lines, fmt.Sprintf(" balance %s", backend.Balance))
123+
if backend.HTTPKeepAlive && backend.Mode == "http" {
124+
lines = append(lines, " option http-keep-alive")
125+
}
123126

124127
// Build server options
125128
var serverOpts []string

gearbox-agent/internal/framework/services/haproxy/generator_test.go

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -322,15 +322,15 @@ func TestGenerator_GenerateBackendConfig_NoCheck(t *testing.T) {
322322
func TestGenerator_GenerateBackendConfig_TCPMode(t *testing.T) {
323323
backends := []compose.BackendConfig{
324324
{
325-
BackendName: "tcp_backend",
326-
Hostname: "tcp.example.com",
327-
Server: "tcp-server:3306",
328-
Mode: "tcp",
329-
Balance: "leastconn",
330-
Check: true,
325+
BackendName: "tcp_backend",
326+
Hostname: "tcp.example.com",
327+
Server: "tcp-server:3306",
328+
Mode: "tcp",
329+
Balance: "leastconn",
330+
Check: true,
331331
CheckInterval: "10s",
332-
CheckFall: "2",
333-
CheckRise: "3",
332+
CheckFall: "2",
333+
CheckRise: "3",
334334
},
335335
}
336336

@@ -459,3 +459,43 @@ func TestGenerator_GenerateBackendConfig_SSLWithRequiredVerify(t *testing.T) {
459459
t.Error("GenerateBackendConfig() missing 'verify required' option")
460460
}
461461
}
462+
463+
func TestGenerator_GenerateBackendConfig_HTTPKeepAlive(t *testing.T) {
464+
backends := []compose.BackendConfig{
465+
{
466+
BackendName: "keepalive_backend",
467+
Hostname: "idrac.example.com",
468+
Server: "10.0.0.6:443",
469+
Mode: "http",
470+
Balance: "roundrobin",
471+
HTTPKeepAlive: true,
472+
},
473+
}
474+
475+
gen := NewGenerator(backends)
476+
config := gen.GenerateBackendConfig()
477+
478+
if !strings.Contains(config, "option http-keep-alive") {
479+
t.Error("GenerateBackendConfig() missing 'option http-keep-alive' directive")
480+
}
481+
}
482+
483+
func TestGenerator_GenerateBackendConfig_HTTPKeepAlive_TCPModeSkipped(t *testing.T) {
484+
backends := []compose.BackendConfig{
485+
{
486+
BackendName: "keepalive_tcp_backend",
487+
Hostname: "tcp.example.com",
488+
Server: "10.0.0.7:3306",
489+
Mode: "tcp",
490+
Balance: "roundrobin",
491+
HTTPKeepAlive: true,
492+
},
493+
}
494+
495+
gen := NewGenerator(backends)
496+
config := gen.GenerateBackendConfig()
497+
498+
if strings.Contains(config, "option http-keep-alive") {
499+
t.Error("GenerateBackendConfig() emitted 'option http-keep-alive' for tcp-mode backend")
500+
}
501+
}

0 commit comments

Comments
 (0)