diff --git a/pkg/toolsets/netedge/router.go b/pkg/toolsets/netedge/router.go index 3cdc9a11e..1c7606735 100644 --- a/pkg/toolsets/netedge/router.go +++ b/pkg/toolsets/netedge/router.go @@ -18,22 +18,25 @@ const ( ingressNamespace = "openshift-ingress" defaultIngressControllerName = "default" routerContainerName = "router" -) -var ( - podGVR = schema.GroupVersionResource{ - Group: "", - Version: "v1", - Resource: "pods", - } + defaultConfigTailLines int64 = 200 + defaultSessionLimit int64 = 50 ) +var podGVR = schema.GroupVersionResource{ + Group: "", + Version: "v1", + Resource: "pods", +} + +var haproxySectionKeywords = []string{"global", "defaults", "frontend", "backend", "listen"} + func initRouter() []api.ServerTool { return []api.ServerTool{ { Tool: api.Tool{ Name: "get_router_config", - Description: `Retrieve the current router's HAProxy configuration from the cluster.`, + Description: `Retrieve the current router's HAProxy configuration from the cluster. Supports filtering by section type (global/defaults/frontend/backend), substring filter on section headers, and line-count limiting via tail_lines.`, InputSchema: &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ @@ -41,6 +44,21 @@ func initRouter() []api.ServerTool { Type: "string", Description: "Router pod name (optional, chooses any existing if not provided)", }, + "tail_lines": { + Type: "integer", + Description: "Maximum number of lines to return from the end of the config output (default: 200)", + Default: api.ToRawMessage(defaultConfigTailLines), + Minimum: ptr.To(float64(1)), + }, + "section": { + Type: "string", + Description: "Filter to a specific HAProxy config section type", + Enum: []any{"global", "defaults", "frontend", "backend", "listen"}, + }, + "filter": { + Type: "string", + Description: "Substring filter applied to section headers (e.g. a route or backend name). Only sections whose header contains this string are returned.", + }, }, }, Annotations: api.ToolAnnotations{ @@ -77,7 +95,7 @@ func initRouter() []api.ServerTool { { Tool: api.Tool{ Name: "get_router_sessions", - Description: `Retrieve all active sessions from the router.`, + Description: `Retrieve active sessions from the router. Supports limiting the number of sessions returned and filtering by substring (e.g. backend name or source IP).`, InputSchema: &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ @@ -85,6 +103,16 @@ func initRouter() []api.ServerTool { Type: "string", Description: "Router pod name (optional, chooses any existing if not provided)", }, + "limit": { + Type: "integer", + Description: "Maximum number of session blocks to return (default: 50)", + Default: api.ToRawMessage(defaultSessionLimit), + Minimum: ptr.To(float64(1)), + }, + "filter": { + Type: "string", + Description: "Substring filter applied to each session block. Only sessions containing this string are returned (e.g. a backend name or source IP).", + }, }, }, Annotations: api.ToolAnnotations{ @@ -99,20 +127,26 @@ func initRouter() []api.ServerTool { } } -// getRouterConfig requires a live cluster as it reads the HAProxy configuration -// from a running router pod via exec. It cannot work against offline data (must-gather). func getRouterConfig(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + p := api.WrapParams(params) + pod := p.OptionalString("pod", "") + tailLines := p.OptionalInt64("tail_lines", defaultConfigTailLines) + section := p.OptionalString("section", "") + filter := p.OptionalString("filter", "") + if err := p.Err(); err != nil { + return api.NewToolCallResult("", fmt.Errorf("invalid parameters: %w", err)), nil + } + var results []string - pod, ok := params.GetArguments()["pod"].(string) - if !ok || pod == "" { - p, err := getAnyRouterPod(params, defaultIngressControllerName) + if pod == "" { + resolved, err := getAnyRouterPod(params, defaultIngressControllerName) if err != nil { results = append(results, "# Router configuration") results = append(results, fmt.Sprintf("Error getting router pod: %v", err)) return api.NewToolCallResult(strings.Join(results, "\n"), err), nil } - pod = p + pod = resolved } out, err := kubernetes.NewCore(params).PodsExec(params.Context, ingressNamespace, pod, routerContainerName, []string{"cat", "/var/lib/haproxy/conf/haproxy.config"}) @@ -122,9 +156,16 @@ func getRouterConfig(params api.ToolHandlerParams) (*api.ToolCallResult, error) return api.NewToolCallResult(strings.Join(results, "\n"), err), nil } + truncated, totalLines, shownLines, wasTruncated := truncateConfigOutput(out, tailLines, section, filter) + results = append(results, fmt.Sprintf("# Router configuration (pod: %s)", pod)) + if wasTruncated { + results = append(results, fmt.Sprintf("**Output truncated**: showing %d of %d total lines. Use `section`, `filter`, or increase `tail_lines` to refine.", shownLines, totalLines)) + } else if section != "" || filter != "" { + results = append(results, fmt.Sprintf("Showing %d lines (filtered from %d total).", shownLines, totalLines)) + } results = append(results, "```") - results = append(results, out) + results = append(results, truncated) results = append(results, "```") return api.NewToolCallResult(strings.Join(results, "\n"), nil), nil @@ -161,20 +202,25 @@ func getRouterInfo(params api.ToolHandlerParams) (*api.ToolCallResult, error) { return api.NewToolCallResult(strings.Join(results, "\n"), nil), nil } -// getRouterSessions requires a live cluster as it queries the HAProxy admin socket -// via exec on a running router pod. It cannot work against offline data (must-gather). func getRouterSessions(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + p := api.WrapParams(params) + pod := p.OptionalString("pod", "") + limit := p.OptionalInt64("limit", defaultSessionLimit) + filter := p.OptionalString("filter", "") + if err := p.Err(); err != nil { + return api.NewToolCallResult("", fmt.Errorf("invalid parameters: %w", err)), nil + } + var results []string - pod, ok := params.GetArguments()["pod"].(string) - if !ok || pod == "" { - p, err := getAnyRouterPod(params, defaultIngressControllerName) + if pod == "" { + resolved, err := getAnyRouterPod(params, defaultIngressControllerName) if err != nil { results = append(results, "# Router active sessions") results = append(results, fmt.Sprintf("Error getting router pod: %v", err)) return api.NewToolCallResult(strings.Join(results, "\n"), err), nil } - pod = p + pod = resolved } out, err := kubernetes.NewCore(params).PodsExec(params.Context, ingressNamespace, pod, routerContainerName, []string{"sh", "-c", "echo 'show sess all' | socat stdio /var/lib/haproxy/run/haproxy.sock"}) @@ -184,13 +230,123 @@ func getRouterSessions(params api.ToolHandlerParams) (*api.ToolCallResult, error return api.NewToolCallResult(strings.Join(results, "\n"), err), nil } + truncated, totalSessions, shownSessions, wasTruncated := truncateSessionsOutput(out, limit, filter) + results = append(results, fmt.Sprintf("# Router active sessions (pod: %s)", pod)) + if wasTruncated { + results = append(results, fmt.Sprintf("**Output truncated**: showing %d of %d total sessions. Use `filter` to narrow results or increase `limit` to see more.", shownSessions, totalSessions)) + } else if filter != "" { + results = append(results, fmt.Sprintf("Showing %d sessions (filtered from %d total).", shownSessions, totalSessions)) + } results = append(results, "```") - results = append(results, out) + results = append(results, truncated) results = append(results, "```") return api.NewToolCallResult(strings.Join(results, "\n"), nil), nil } + +func configSectionType(line string) string { + for _, kw := range haproxySectionKeywords { + if strings.HasPrefix(line, kw) && (len(line) == len(kw) || line[len(kw)] == ' ' || line[len(kw)] == '\t') { + return kw + } + } + return "" +} + +func truncateConfigOutput(output string, tailLines int64, section, filter string) (string, int, int, bool) { + if output == "" { + return "", 0, 0, false + } + + allLines := strings.Split(output, "\n") + totalLines := len(allLines) + + if section == "" && filter == "" { + if int64(totalLines) <= tailLines { + return output, totalLines, totalLines, false + } + result := allLines[totalLines-int(tailLines):] + return strings.Join(result, "\n"), totalLines, len(result), true + } + + filterLower := strings.ToLower(filter) + var resultLines []string + include := false + for _, line := range allLines { + if st := configSectionType(line); st != "" { + include = (section == "" || st == section) && (filter == "" || strings.Contains(strings.ToLower(line), filterLower)) + } + if include { + resultLines = append(resultLines, line) + } + } + shownLines := len(resultLines) + + if int64(shownLines) <= tailLines { + return strings.Join(resultLines, "\n"), totalLines, shownLines, false + } + result := resultLines[shownLines-int(tailLines):] + return strings.Join(result, "\n"), totalLines, len(result), true +} + +func truncateSessionsOutput(output string, limit int64, filter string) (string, int, int, bool) { + if output == "" { + return "", 0, 0, false + } + + lines := strings.Split(output, "\n") + var blocks [][]string + var current []string + + for _, line := range lines { + if strings.HasPrefix(line, "0x") { + if len(current) > 0 { + blocks = append(blocks, current) + } + current = []string{line} + } else if len(current) > 0 { + current = append(current, line) + } + } + if len(current) > 0 { + blocks = append(blocks, current) + } + + totalSessions := len(blocks) + + if filter != "" { + filterLower := strings.ToLower(filter) + var filtered [][]string + for _, block := range blocks { + if blockContainsSubstring(block, filterLower) { + filtered = append(filtered, block) + } + } + blocks = filtered + } + + truncated := int64(len(blocks)) > limit + if truncated { + blocks = blocks[:limit] + } + + var resultLines []string + for _, block := range blocks { + resultLines = append(resultLines, block...) + } + return strings.Join(resultLines, "\n"), totalSessions, len(blocks), truncated +} + +func blockContainsSubstring(block []string, substr string) bool { + for _, line := range block { + if strings.Contains(strings.ToLower(line), substr) { + return true + } + } + return false +} + func getAnyRouterPod(params api.ToolHandlerParams, icName string) (string, error) { pods, err := params.DynamicClient().Resource(podGVR).Namespace(ingressNamespace).List(params.Context, metav1.ListOptions{ LabelSelector: "ingresscontroller.operator.openshift.io/deployment-ingresscontroller=" + icName, diff --git a/pkg/toolsets/netedge/router_test.go b/pkg/toolsets/netedge/router_test.go new file mode 100644 index 000000000..944a16cf8 --- /dev/null +++ b/pkg/toolsets/netedge/router_test.go @@ -0,0 +1,216 @@ +package netedge + +import ( + "fmt" + "strings" +) + +func (s *NetEdgeTestSuite) TestTruncateConfigOutput() { + smallConfig := strings.Join([]string{ + "global", + " maxconn 20000", + "defaults", + " timeout connect 5s", + "frontend public", + " bind *:80", + "frontend public_ssl", + " bind *:443", + "backend be_http:myapp:ns", + " server pod1 10.0.0.1:8080", + "backend be_http:otherapp:ns", + " server pod2 10.0.0.2:8080", + }, "\n") + + s.Run("no truncation needed", func() { + result, total, shown, truncated := truncateConfigOutput(smallConfig, 200, "", "") + s.Equal(smallConfig, result) + s.Equal(12, total) + s.Equal(12, shown) + s.False(truncated) + }) + + s.Run("tail_lines truncation", func() { + result, total, shown, truncated := truncateConfigOutput(smallConfig, 5, "", "") + lines := strings.Split(result, "\n") + s.Equal(5, len(lines)) + s.Equal(12, total) + s.Equal(5, shown) + s.True(truncated) + s.Contains(result, "backend be_http:otherapp:ns") + }) + + s.Run("section filter - global", func() { + result, total, shown, truncated := truncateConfigOutput(smallConfig, 200, "global", "") + s.Equal(12, total) + s.Equal(2, shown) + s.False(truncated) + s.Contains(result, "global") + s.Contains(result, "maxconn") + s.NotContains(result, "defaults") + s.NotContains(result, "frontend") + }) + + s.Run("section filter - backend returns all backends", func() { + result, total, shown, truncated := truncateConfigOutput(smallConfig, 200, "backend", "") + s.Equal(12, total) + s.Equal(4, shown) + s.False(truncated) + s.Contains(result, "be_http:myapp:ns") + s.Contains(result, "be_http:otherapp:ns") + s.NotContains(result, "frontend") + }) + + s.Run("section filter - frontend", func() { + result, _, shown, _ := truncateConfigOutput(smallConfig, 200, "frontend", "") + s.Equal(4, shown) + s.Contains(result, "frontend public") + s.Contains(result, "frontend public_ssl") + }) + + s.Run("substring filter on section header", func() { + result, total, shown, truncated := truncateConfigOutput(smallConfig, 200, "", "myapp") + s.Equal(12, total) + s.Equal(2, shown) + s.False(truncated) + s.Contains(result, "be_http:myapp:ns") + s.NotContains(result, "otherapp") + }) + + s.Run("section plus filter combined", func() { + result, _, shown, _ := truncateConfigOutput(smallConfig, 200, "backend", "myapp") + s.Equal(2, shown) + s.Contains(result, "be_http:myapp:ns") + s.NotContains(result, "otherapp") + }) + + s.Run("filter is case insensitive", func() { + result, _, shown, _ := truncateConfigOutput(smallConfig, 200, "", "MYAPP") + s.Equal(2, shown) + s.Contains(result, "be_http:myapp:ns") + }) + + s.Run("filtered result still truncated by tail_lines", func() { + _, _, shown, truncated := truncateConfigOutput(smallConfig, 1, "backend", "") + s.Equal(1, shown) + s.True(truncated) + }) + + s.Run("empty output", func() { + result, total, shown, truncated := truncateConfigOutput("", 200, "", "") + s.Equal("", result) + s.Equal(0, total) + s.Equal(0, shown) + s.False(truncated) + }) + + s.Run("no matching section", func() { + result, total, shown, truncated := truncateConfigOutput(smallConfig, 200, "frontend", "nonexistent") + s.Equal(12, total) + s.Equal(0, shown) + s.False(truncated) + s.Equal("", result) + }) + + s.Run("large config with many backends", func() { + var lines []string + lines = append(lines, "global", " maxconn 20000") + lines = append(lines, "defaults", " timeout connect 5s") + for i := 0; i < 100; i++ { + lines = append(lines, fmt.Sprintf("backend be_http:app%d:ns", i)) + lines = append(lines, fmt.Sprintf(" server pod%d 10.0.%d.1:8080", i, i)) + } + largeConfig := strings.Join(lines, "\n") + + result, total, shown, truncated := truncateConfigOutput(largeConfig, 50, "", "") + s.Equal(204, total) + s.Equal(50, shown) + s.True(truncated) + resultLines := strings.Split(result, "\n") + s.Equal(50, len(resultLines)) + }) +} + +func (s *NetEdgeTestSuite) TestTruncateSessionsOutput() { + makeSession := func(id int, backend string) string { + return fmt.Sprintf("0x%08x: proto=tcpv4 src=10.0.0.%d:12345\n backend=%s state=active\n flags=0x0", id, id%256, backend) + } + + smallOutput := strings.Join([]string{ + makeSession(1, "be_myapp"), + makeSession(2, "be_otherapp"), + makeSession(3, "be_myapp"), + }, "\n") + + s.Run("no truncation needed", func() { + result, total, shown, truncated := truncateSessionsOutput(smallOutput, 50, "") + s.Equal(3, total) + s.Equal(3, shown) + s.False(truncated) + s.Equal(smallOutput, result) + }) + + s.Run("limit truncation", func() { + result, total, shown, truncated := truncateSessionsOutput(smallOutput, 2, "") + s.Equal(3, total) + s.Equal(2, shown) + s.True(truncated) + s.Contains(result, "0x00000001:") + s.Contains(result, "0x00000002:") + s.NotContains(result, "0x00000003:") + }) + + s.Run("filter by backend", func() { + result, total, shown, truncated := truncateSessionsOutput(smallOutput, 50, "be_myapp") + s.Equal(3, total) + s.Equal(2, shown) + s.False(truncated) + s.Contains(result, "0x00000001:") + s.Contains(result, "0x00000003:") + s.NotContains(result, "be_otherapp") + }) + + s.Run("filter is case insensitive", func() { + _, _, shown, _ := truncateSessionsOutput(smallOutput, 50, "BE_MYAPP") + s.Equal(2, shown) + }) + + s.Run("filter plus limit", func() { + result, total, shown, truncated := truncateSessionsOutput(smallOutput, 1, "be_myapp") + s.Equal(3, total) + s.Equal(1, shown) + s.True(truncated) + s.Contains(result, "0x00000001:") + s.NotContains(result, "0x00000003:") + }) + + s.Run("empty output", func() { + result, total, shown, truncated := truncateSessionsOutput("", 50, "") + s.Equal("", result) + s.Equal(0, total) + s.Equal(0, shown) + s.False(truncated) + }) + + s.Run("no matching filter", func() { + result, total, shown, truncated := truncateSessionsOutput(smallOutput, 50, "nonexistent") + s.Equal(3, total) + s.Equal(0, shown) + s.False(truncated) + s.Equal("", result) + }) + + s.Run("large session output", func() { + var sessions []string + for i := 0; i < 100; i++ { + sessions = append(sessions, makeSession(i, fmt.Sprintf("be_app%d", i%10))) + } + largeOutput := strings.Join(sessions, "\n") + + result, total, shown, truncated := truncateSessionsOutput(largeOutput, 10, "") + s.Equal(100, total) + s.Equal(10, shown) + s.True(truncated) + s.Contains(result, "0x00000000:") + s.NotContains(result, "0x0000000a:") + }) +}