Files
zzet--gortex/internal/mcp/tools_analyze_infra.go
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

261 lines
7.3 KiB
Go

package mcp
import (
"context"
"sort"
"strings"
"github.com/mark3labs/mcp-go/mcp"
"github.com/zzet/gortex/internal/graph"
)
// handleAnalyzeK8sResources surfaces the KindResource graph layer
// produced by the K8s manifest extractor. Returns one row per Resource
// node with kind, namespace, name, file, line, and the count of
// outgoing infra edges (depends_on / configures / mounts / exposes /
// uses_env). Useful for "which deployments live in prod" / "which
// ConfigMaps no workload references" / "what does this Resource
// reach?" queries.
//
// Filters:
// - k8s_kind: K8s kind filter (Deployment / Service / Ingress / …)
// - namespace: substring match on namespace
// - name: substring match on resource name
func (s *Server) handleAnalyzeK8sResources(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
kindFilter := strings.TrimSpace(stringArg(args, "k8s_kind"))
nsFilter := strings.ToLower(strings.TrimSpace(stringArg(args, "namespace")))
nameFilter := strings.ToLower(strings.TrimSpace(stringArg(args, "name")))
type resourceRow struct {
ID string `json:"id"`
K8sKind string `json:"k8s_kind"`
Namespace string `json:"namespace"`
Name string `json:"name"`
File string `json:"file"`
Line int `json:"line"`
DependsOn int `json:"depends_on"`
Configures int `json:"configures"`
Mounts int `json:"mounts"`
Exposes int `json:"exposes"`
UsesEnv int `json:"uses_env"`
}
// Tally per-resource edge fan-out in a single pass so we don't
// re-walk AllEdges() for every resource.
type counts struct {
dependsOn, configures, mounts, exposes, usesEnv int
}
tally := make(map[string]*counts)
bump := func(id string, edge graph.EdgeKind) {
c, ok := tally[id]
if !ok {
c = &counts{}
tally[id] = c
}
switch edge {
case graph.EdgeDependsOn:
c.dependsOn++
case graph.EdgeConfigures:
c.configures++
case graph.EdgeMounts:
c.mounts++
case graph.EdgeExposes:
c.exposes++
case graph.EdgeUsesEnv:
c.usesEnv++
}
}
for e := range edgesByKinds(s.graph,
graph.EdgeDependsOn,
graph.EdgeConfigures,
graph.EdgeMounts,
graph.EdgeExposes,
graph.EdgeUsesEnv,
) {
bump(e.From, e.Kind)
}
var rows []*resourceRow
for _, n := range s.scopedNodes(ctx) {
if n.Kind != graph.KindResource {
continue
}
k8sKind, _ := n.Meta["k8s_kind"].(string)
ns, _ := n.Meta["namespace"].(string)
if kindFilter != "" && !strings.EqualFold(k8sKind, kindFilter) {
continue
}
if nsFilter != "" && !strings.Contains(strings.ToLower(ns), nsFilter) {
continue
}
if nameFilter != "" && !strings.Contains(strings.ToLower(n.Name), nameFilter) {
continue
}
c := tally[n.ID]
if c == nil {
c = &counts{}
}
rows = append(rows, &resourceRow{
ID: n.ID, K8sKind: k8sKind, Namespace: ns, Name: n.Name,
File: n.FilePath, Line: n.StartLine,
DependsOn: c.dependsOn,
Configures: c.configures,
Mounts: c.mounts,
Exposes: c.exposes,
UsesEnv: c.usesEnv,
})
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].K8sKind != rows[j].K8sKind {
return rows[i].K8sKind < rows[j].K8sKind
}
if rows[i].Namespace != rows[j].Namespace {
return rows[i].Namespace < rows[j].Namespace
}
return rows[i].Name < rows[j].Name
})
return s.respondJSONOrTOON(ctx, req, map[string]any{
"resources": rows,
"total": len(rows),
})
}
// handleAnalyzeImages surfaces the KindImage graph layer. Returns
// one row per Image node with consumer counts (Dockerfile stages
// that build FROM it, K8s Resources that reference it). Useful for
// "which images are stale" / "which Resource pulls nginx:latest"
// queries.
//
// Filters:
// - role: base | stage (default: both)
// - ref: substring match on the image reference (registry/path)
// - tag: exact tag match (latest / 1.25 / sha256:…)
func (s *Server) handleAnalyzeImages(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
roleFilter := strings.ToLower(strings.TrimSpace(stringArg(args, "role")))
refFilter := strings.ToLower(strings.TrimSpace(stringArg(args, "ref")))
tagFilter := strings.TrimSpace(stringArg(args, "tag"))
type imageRow struct {
ID string `json:"id"`
Name string `json:"name"`
Role string `json:"role,omitempty"`
Ref string `json:"ref,omitempty"`
Tag string `json:"tag,omitempty"`
File string `json:"file"`
Line int `json:"line"`
Consumers int `json:"consumers"`
}
consumers := make(map[string]int)
for e := range edgesByKinds(s.graph, graph.EdgeDependsOn) {
consumers[e.To]++
}
var rows []*imageRow
for _, n := range s.scopedNodes(ctx) {
if n.Kind != graph.KindImage {
continue
}
role, _ := n.Meta["role"].(string)
ref, _ := n.Meta["ref"].(string)
tag, _ := n.Meta["tag"].(string)
if roleFilter != "" && role != roleFilter {
continue
}
if refFilter != "" && !strings.Contains(strings.ToLower(ref), refFilter) {
continue
}
if tagFilter != "" && tag != tagFilter {
continue
}
rows = append(rows, &imageRow{
ID: n.ID, Name: n.Name, Role: role, Ref: ref, Tag: tag,
File: n.FilePath, Line: n.StartLine,
Consumers: consumers[n.ID],
})
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].Role != rows[j].Role {
return rows[i].Role < rows[j].Role
}
if rows[i].Ref != rows[j].Ref {
return rows[i].Ref < rows[j].Ref
}
return rows[i].Tag < rows[j].Tag
})
return s.respondJSONOrTOON(ctx, req, map[string]any{
"images": rows,
"total": len(rows),
})
}
// handleAnalyzeKustomize surfaces the KindKustomization graph layer.
// Returns one row per overlay with its base count and resource count
// (computed from outgoing EdgeDependsOn / EdgeReferences fan-out).
//
// Filters:
// - dir: substring match on the overlay's directory.
func (s *Server) handleAnalyzeKustomize(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
dirFilter := strings.ToLower(strings.TrimSpace(stringArg(args, "dir")))
type overlayRow struct {
ID string `json:"id"`
Dir string `json:"dir"`
File string `json:"file"`
Line int `json:"line"`
DependsOn int `json:"depends_on"`
Resources int `json:"resources"`
}
type counts struct{ deps, res int }
tally := make(map[string]*counts)
bump := func(id string, edge graph.EdgeKind) {
c, ok := tally[id]
if !ok {
c = &counts{}
tally[id] = c
}
switch edge {
case graph.EdgeDependsOn:
c.deps++
case graph.EdgeReferences:
c.res++
}
}
for e := range edgesByKinds(s.graph, graph.EdgeDependsOn, graph.EdgeReferences) {
bump(e.From, e.Kind)
}
var rows []*overlayRow
for _, n := range s.scopedNodes(ctx) {
if n.Kind != graph.KindKustomization {
continue
}
dir, _ := n.Meta["dir"].(string)
if dir == "" {
dir = n.Name
}
if dirFilter != "" && !strings.Contains(strings.ToLower(dir), dirFilter) {
continue
}
c := tally[n.ID]
if c == nil {
c = &counts{}
}
rows = append(rows, &overlayRow{
ID: n.ID, Dir: dir,
File: n.FilePath, Line: n.StartLine,
DependsOn: c.deps, Resources: c.res,
})
}
sort.Slice(rows, func(i, j int) bool { return rows[i].Dir < rows[j].Dir })
return s.respondJSONOrTOON(ctx, req, map[string]any{
"kustomizations": rows,
"total": len(rows),
})
}