-
Notifications
You must be signed in to change notification settings - Fork 49
NE-2636: Add truncation and filtering to router tools #304
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,29 +18,47 @@ 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{ | ||
| "pod": { | ||
| 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,14 +95,24 @@ 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{ | ||
| "pod": { | ||
| 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') { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is a smart default for HAProxy where dynamic backends often appear at the end. |
||
| 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") { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This should help group into logical blocks so the pagination breaks things less awkwardly |
||
| 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, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think
getRouterInfoneeds this same help with WrapParams