Resilient HAProxy config: isolate broken services on validation failure

When haproxy -c fails, parse ALERT line numbers, map to service domains
via # service: comments in generated config, exclude broken services,
and regenerate. One retry — prevents a single bad registration from
blocking all config updates.
This commit is contained in:
2026-07-10 07:00:27 +00:00
parent 68d6fde80d
commit 6aa1e7d438
4 changed files with 221 additions and 9 deletions

View File

@@ -156,6 +156,7 @@ frontend http_in
continue // handled below
}
aclName := "is_l7_" + sanitizeName(route.Name)
fmt.Fprintf(&sb, " # service: %s\n", route.Domain)
fmt.Fprintf(&sb, " acl %s req_ssl_sni -m str %s\n", aclName, route.Domain)
fmt.Fprintf(&sb, " use_backend be_l7_termination if %s\n", aclName)
}
@@ -165,6 +166,7 @@ frontend http_in
continue
}
aclName := "is_l7_" + sanitizeName(route.Name)
fmt.Fprintf(&sb, " # service: %s\n", route.Domain)
fmt.Fprintf(&sb, " acl %s req_ssl_sni -m end .%s\n", aclName, route.Domain)
fmt.Fprintf(&sb, " acl %s req_ssl_sni -m str %s\n", aclName, route.Domain)
fmt.Fprintf(&sb, " use_backend be_l7_termination if %s\n", aclName)
@@ -180,6 +182,7 @@ frontend http_in
}
hasExact = true
aclName := "is_" + sanitizeName(inst.Name)
fmt.Fprintf(&sb, " # service: %s\n", inst.Domain)
fmt.Fprintf(&sb, " acl %s req_ssl_sni -m str %s\n", aclName, inst.Domain)
fmt.Fprintf(&sb, " use_backend be_https_%s if %s\n", sanitizeName(inst.Name), aclName)
}
@@ -194,6 +197,7 @@ frontend http_in
continue // already handled in step 2
}
aclName := "is_" + sanitizeName(inst.Name)
fmt.Fprintf(&sb, " # service: %s\n", inst.Domain)
fmt.Fprintf(&sb, " acl %s req_ssl_sni -m end .%s\n", aclName, inst.Domain)
fmt.Fprintf(&sb, " acl %s req_ssl_sni -m str %s\n", aclName, inst.Domain)
fmt.Fprintf(&sb, " use_backend be_https_%s if %s\n", sanitizeName(inst.Name), aclName)
@@ -209,6 +213,7 @@ frontend http_in
// Instance L4 backends
for _, inst := range instances {
fmt.Fprintf(&sb, "# service: %s\n", inst.Domain)
fmt.Fprintf(&sb, "backend be_https_%s\n", sanitizeName(inst.Name))
sb.WriteString(" mode tcp\n")
sb.WriteString(" option tcp-check\n")
@@ -239,6 +244,7 @@ frontend http_in
// Host ACLs for all services
for _, httpRoute := range opts.HTTPRoutes {
hostACL := "host_" + sanitizeName(httpRoute.Name)
fmt.Fprintf(&sb, " # service: %s\n", httpRoute.Domain)
if httpRoute.Subdomains {
fmt.Fprintf(&sb, " acl %s hdr(host) -i -m end .%s\n", hostACL, httpRoute.Domain)
fmt.Fprintf(&sb, " acl %s hdr(host) -i -m str %s\n", hostACL, httpRoute.Domain)
@@ -319,6 +325,7 @@ frontend http_in
routeName := sanitizeName(httpRoute.Name)
for i, rb := range httpRoute.Routes {
backendName := fmt.Sprintf("be_l7_%s_%d", routeName, i)
fmt.Fprintf(&sb, "# service: %s\n", httpRoute.Domain)
fmt.Fprintf(&sb, "backend %s\n", backendName)
sb.WriteString(" mode http\n")
if rb.HealthPath != "" {
@@ -359,6 +366,61 @@ func sortedKeys(m map[string]string) []string {
return keys
}
// ParseValidationErrors extracts line numbers from haproxy -c ALERT output.
// HAProxy format: [ALERT] (pid) : config : parsing [/path/to/file:LINE]: message
func ParseValidationErrors(output string) []int {
var lines []int
for _, line := range strings.Split(output, "\n") {
if !strings.Contains(line, "[ALERT]") || !strings.Contains(line, "parsing") {
continue
}
// Find the bracketed path:line — e.g., [/tmp/test.cfg:111]
bracketStart := strings.LastIndex(line, "[")
bracketEnd := strings.Index(line[bracketStart:], "]")
if bracketStart < 0 || bracketEnd < 0 {
continue
}
bracket := line[bracketStart+1 : bracketStart+bracketEnd]
// Extract line number after the last colon in the bracket
colonIdx := strings.LastIndex(bracket, ":")
if colonIdx < 0 {
continue
}
if n, err := strconv.Atoi(bracket[colonIdx+1:]); err == nil {
lines = append(lines, n)
}
}
return lines
}
// FindBrokenServices maps failing line numbers to service domains using
// "# service: domain" comment markers in the generated config.
// For each failing line, scans backwards to find the nearest marker.
func FindBrokenServices(config string, failingLines []int) []string {
configLines := strings.Split(config, "\n")
seen := map[string]bool{}
var broken []string
for _, lineNum := range failingLines {
if lineNum < 1 || lineNum > len(configLines) {
continue
}
// Scan backwards from the failing line to find the nearest service marker
for i := lineNum - 1; i >= 0; i-- {
trimmed := strings.TrimSpace(configLines[i])
if strings.HasPrefix(trimmed, "# service: ") {
domain := strings.TrimPrefix(trimmed, "# service: ")
if !seen[domain] {
seen[domain] = true
broken = append(broken, domain)
}
break
}
}
}
return broken
}
// sanitizeName converts a name to a valid HAProxy identifier (alphanumeric + underscore)
func sanitizeName(name string) string {
return strings.Map(func(r rune) rune {

View File

@@ -726,3 +726,110 @@ func TestGenerateWithOpts_NoL7Fields_BackwardCompat(t *testing.T) {
t.Errorf("unexpected response header in simple route, got:\n%s", out)
}
}
// --- Service marker and validation error tests ---
func TestGenerateWithOpts_ServiceMarkers(t *testing.T) {
m := NewManager("")
instances := []InstanceRoute{
{Name: "cloud", Domain: "cloud.example.com", BackendIP: "10.0.0.1", Subdomains: true},
}
httpRoutes := []HTTPRoute{
{Name: "api", Domain: "api.example.com", Routes: []HTTPRouteBackend{{Backend: "10.0.0.5:8080"}}},
}
out := m.GenerateWithOpts(instances, nil, GenerateOpts{HTTPRoutes: httpRoutes})
// L4 instance should have service marker
if !strings.Contains(out, "# service: cloud.example.com") {
t.Errorf("expected service marker for cloud.example.com, got:\n%s", out)
}
// L7 HTTP should have service marker
if !strings.Contains(out, "# service: api.example.com") {
t.Errorf("expected service marker for api.example.com, got:\n%s", out)
}
}
func TestParseValidationErrors(t *testing.T) {
output := `[NOTICE] (123) : haproxy version is 3.0.10
[WARNING] (123) : config : parsing [/tmp/test.cfg:42] : some warning
[ALERT] (123) : config : parsing [/tmp/test.cfg:111]: 'http-response set-header' expects 'if'
[ALERT] (123) : config : parsing [/tmp/test.cfg:115]: unknown keyword
[ALERT] (123) : config : Error(s) found in configuration file : /tmp/test.cfg`
lines := ParseValidationErrors(output)
if len(lines) != 2 {
t.Fatalf("expected 2 failing lines, got %d: %v", len(lines), lines)
}
if lines[0] != 111 {
t.Errorf("expected first line 111, got %d", lines[0])
}
if lines[1] != 115 {
t.Errorf("expected second line 115, got %d", lines[1])
}
}
func TestParseValidationErrors_NoAlerts(t *testing.T) {
output := `[NOTICE] (123) : haproxy version is 3.0.10
[WARNING] (123) : config : parsing [/tmp/test.cfg:42] : some warning
Warnings were found.`
lines := ParseValidationErrors(output)
if len(lines) != 0 {
t.Errorf("expected 0 failing lines for warnings-only output, got %d", len(lines))
}
}
func TestFindBrokenServices(t *testing.T) {
config := `# boilerplate
frontend l7_https
bind 127.0.0.1:8443 ssl crt /etc/haproxy/certs/
mode http
# service: good.example.com
acl host_good hdr(host) -i good.example.com
# service: bad.example.com
acl host_bad hdr(host) -i bad.example.com
http-response set-header X-Bad broken value if host_bad
# service: also-good.example.com
acl host_also_good hdr(host) -i also-good.example.com
`
// Line 10 is the broken http-response line (1-indexed)
broken := FindBrokenServices(config, []int{10})
if len(broken) != 1 {
t.Fatalf("expected 1 broken service, got %d: %v", len(broken), broken)
}
if broken[0] != "bad.example.com" {
t.Errorf("expected bad.example.com, got %s", broken[0])
}
}
func TestFindBrokenServices_MultipleLinesOneDomain(t *testing.T) {
config := `line1
# service: bad.example.com
line3
line4
line5
`
// Multiple lines from the same service should deduplicate
broken := FindBrokenServices(config, []int{3, 4, 5})
if len(broken) != 1 {
t.Fatalf("expected 1 broken service (deduped), got %d: %v", len(broken), broken)
}
}
func TestFindBrokenServices_NoMarker(t *testing.T) {
config := `line1
line2
line3
`
// No service markers — should return empty
broken := FindBrokenServices(config, []int{2})
if len(broken) != 0 {
t.Errorf("expected 0 broken services when no markers, got %d", len(broken))
}
}