From 47a8f818e87b031fc76f1549068428663dc9b805 Mon Sep 17 00:00:00 2001 From: Ivan Porta Date: Fri, 5 Dec 2025 03:38:47 +0900 Subject: [PATCH 1/7] update caches to gitignore Signed-off-by: Ivan Porta --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 4b3d33a..62d65d1 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,5 @@ bin/ /helm/kagent-tools/Chart.yaml /reports/tools-cve.csv .dagger/ +/tools/.cache/ +.gocache/ \ No newline at end of file From e76aa5b55d013fbe482709fd649443770110ac36 Mon Sep 17 00:00:00 2001 From: Ivan Porta Date: Thu, 4 Dec 2025 20:39:43 +0900 Subject: [PATCH 2/7] Add linekrd agent Signed-off-by: Ivan Porta --- DEVELOPMENT.md | 4 +- Dockerfile | 9 +- Makefile | 4 +- README.md | 41 +- cmd/main.go | 2 + pkg/linkerd/linkerd.go | 1019 +++++++++++++++++++++++++++++++++++ pkg/linkerd/linkerd_test.go | 607 +++++++++++++++++++++ 7 files changed, 1676 insertions(+), 10 deletions(-) create mode 100644 pkg/linkerd/linkerd.go create mode 100644 pkg/linkerd/linkerd_test.go diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 4d9f556..d6649d9 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -18,6 +18,7 @@ These tools enhance functionality but aren't required for basic development: - `kubectl` - Kubernetes CLI for k8s tools - `helm` - Helm package manager for helm tools - `istioctl` - Istio service mesh CLI for istio tools +- `linkerd` - Linkerd service mesh CLI for linkerd tools - `cilium` - Cilium CLI for cilium tools ## Project Structure @@ -32,6 +33,7 @@ These tools enhance functionality but aren't required for basic development: │ ├── k8s/ # Kubernetes tools │ ├── helm/ # Helm package manager tools │ ├── istio/ # Istio service mesh tools +│ ├── linkerd/ # Linkerd service mesh tools │ ├── cilium/ # Cilium CNI tools │ ├── argo/ # Argo Rollouts tools │ ├── prometheus/ # Prometheus monitoring tools @@ -422,4 +424,4 @@ git commit -m "docs(readme): update installation instructions" - Check existing issues in the repository - Review the CLAUDE.md file for project-specific guidance - Consult Go documentation and best practices -- Ask questions in code reviews or team discussions \ No newline at end of file +- Ask questions in code reviews or team discussions diff --git a/Dockerfile b/Dockerfile index c8c4882..cb0fde0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -46,6 +46,12 @@ RUN curl -Lo cilium.tar.gz https://github.com/cilium/cilium-cli/releases/downloa && rm -rf cilium.tar.gz \ && /downloads/cilium version +# Install Linkerd CLI +ARG TOOLS_LINKERD_VERSION +RUN curl -Lo /downloads/linkerd https://github.com/linkerd/linkerd2/releases/download/${TOOLS_LINKERD_VERSION}/linkerd2-cli-${TOOLS_LINKERD_VERSION}-linux-${TARGETARCH} \ + && chmod +x /downloads/linkerd \ + && /downloads/linkerd version --client + ### STAGE 2: build-tools MCP ARG BASE_IMAGE_REGISTRY=cgr.dev ARG BUILDARCH=amd64 @@ -96,6 +102,7 @@ COPY --from=tools --chown=65532:65532 /downloads/istioctl /bin/isti COPY --from=tools --chown=65532:65532 /downloads/helm /bin/helm COPY --from=tools --chown=65532:65532 /downloads/kubectl-argo-rollouts /bin/kubectl-argo-rollouts COPY --from=tools --chown=65532:65532 /downloads/cilium /bin/cilium +COPY --from=tools --chown=65532:65532 /downloads/linkerd /bin/linkerd # Copy the tool-server binary COPY --from=builder --chown=65532:65532 /workspace/tool-server /tool-server @@ -106,4 +113,4 @@ LABEL org.opencontainers.image.description="Kagent MCP tools server" LABEL org.opencontainers.image.authors="Kagent Creators 🤖" LABEL org.opencontainers.image.version="$VERSION" -ENTRYPOINT ["/tool-server"] \ No newline at end of file +ENTRYPOINT ["/tool-server"] diff --git a/Makefile b/Makefile index 265604f..6e445ab 100644 --- a/Makefile +++ b/Makefile @@ -141,6 +141,7 @@ TOOLS_ARGO_ROLLOUTS_VERSION ?= 1.8.3 TOOLS_KUBECTL_VERSION ?= 1.34.2 TOOLS_HELM_VERSION ?= 3.19.0 TOOLS_CILIUM_VERSION ?= 0.18.9 +TOOLS_LINKERD_VERSION ?= edge-25.11.3 # build args TOOLS_IMAGE_BUILD_ARGS = --build-arg VERSION=$(VERSION) @@ -151,6 +152,7 @@ TOOLS_IMAGE_BUILD_ARGS += --build-arg TOOLS_ARGO_ROLLOUTS_VERSION=$(TOOLS_ARGO_R TOOLS_IMAGE_BUILD_ARGS += --build-arg TOOLS_KUBECTL_VERSION=$(TOOLS_KUBECTL_VERSION) TOOLS_IMAGE_BUILD_ARGS += --build-arg TOOLS_HELM_VERSION=$(TOOLS_HELM_VERSION) TOOLS_IMAGE_BUILD_ARGS += --build-arg TOOLS_CILIUM_VERSION=$(TOOLS_CILIUM_VERSION) +TOOLS_IMAGE_BUILD_ARGS += --build-arg TOOLS_LINKERD_VERSION=$(TOOLS_LINKERD_VERSION) .PHONY: buildx-create buildx-create: @@ -299,4 +301,4 @@ GOBIN=$(LOCALBIN) go install $${package} ;\ mv $(1) $(1)-$(3) ;\ } ;\ ln -sf $(1)-$(3) $(1) -endef \ No newline at end of file +endef diff --git a/README.md b/README.md index ae07bae..f8aae7c 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,33 @@ Provides Istio service mesh management: - **istio_waypoint_status**: Get waypoint proxy status - **istio_ztunnel_config**: Get ztunnel configuration -### 4. Argo Rollouts Tools (`argo.go`) +### 4. Linkerd Tools (`linkerd.go`) +Provides Linkerd service mesh management: + +- **linkerd_check**: Run pre-flight or proxy checks +- **linkerd_install**: Install Linkerd control plane manifests +- **linkerd_install_cni**: Install the Linkerd CNI components +- **linkerd_upgrade**: Upgrade Linkerd installation components +- **linkerd_uninstall**: Remove Linkerd components +- **linkerd_version**: Show Linkerd client and server versions +- **linkerd_authz**: Inspect authorizations for a workload +- **linkerd_stat**: Retrieve Linkerd metrics for resources +- **linkerd_top**: Inspect live traffic for workloads +- **linkerd_edges**: Display allowed edges between resources +- **linkerd_routes**: Inspect HTTP routes for a resource +- **linkerd_diagnostics_proxy_metrics**: Collect raw proxy metrics +- **linkerd_diagnostics_controller_metrics**: Fetch controller metrics +- **linkerd_diagnostics_endpoints**: Inspect service discovery endpoints +- **linkerd_diagnostics_policy**: Inspect policy state for an authority +- **linkerd_diagnostics_profile**: Inspect service discovery profile data +- **linkerd_viz_install**: Install the Linkerd viz extension +- **linkerd_viz_uninstall**: Remove the Linkerd viz extension +- **linkerd_viz_top**: Inspect live traffic using viz extension +- **linkerd_viz_stat**: Retrieve viz metrics for resources +- **linkerd_fips_audit**: Audit Linkerd proxies for FIPS compliance +- **linkerd_policy_generate**: Generate policy manifests for workloads + +### 5. Argo Rollouts Tools (`argo.go`) Provides Argo Rollouts progressive delivery functionality: - **verify_argo_rollouts_controller_install**: Verify controller installation @@ -115,7 +141,7 @@ Provides Argo Rollouts progressive delivery functionality: - **verify_gateway_plugin**: Verify Gateway API plugin - **check_plugin_logs**: Check plugin installation logs -### 5. Cilium Tools (`cilium.go`) +### 6. Cilium Tools (`cilium.go`) Provides Cilium CNI and networking functionality: - **cilium_status_and_version**: Get Cilium status and version @@ -131,7 +157,7 @@ Provides Cilium CNI and networking functionality: - **toggle_hubble**: Enable/disable Hubble - **toggle_cluster_mesh**: Enable/disable cluster mesh -### 6. Prometheus Tools (`prometheus.go`) +### 7. Prometheus Tools (`prometheus.go`) Provides Prometheus monitoring and alerting functionality: - **prometheus_query**: Execute PromQL queries @@ -139,7 +165,7 @@ Provides Prometheus monitoring and alerting functionality: - **prometheus_labels**: Get available labels - **prometheus_targets**: Get scraping targets and their status -### 7. Grafana Tools (`grafana.go`) +### 8. Grafana Tools (`grafana.go`) Provides Grafana dashboard and alerting management: - **grafana_org_management**: Manage Grafana organizations @@ -147,20 +173,20 @@ Provides Grafana dashboard and alerting management: - **grafana_alert_management**: Manage alerts and alert rules - **grafana_datasource_management**: Manage data sources -### 8. DateTime Tools (`datetime.go`) +### 9. DateTime Tools (`datetime.go`) Provides time and date utilities: - **current_date_time**: Get current date and time in ISO 8601 format - **format_time**: Format timestamps with optional timezone - **parse_time**: Parse time strings into RFC3339 format -### 9. Documentation Tools (`docs.go`) +### 10. Documentation Tools (`docs.go`) Provides documentation query functionality: - **query_documentation**: Query documentation for supported products (simplified implementation) - **list_supported_products**: List supported products for documentation queries -### 10. Common Tools (`common.go`) +### 11. Common Tools (`common.go`) Provides general utility functions: - **shell**: Execute shell commands @@ -174,6 +200,7 @@ Provides general utility functions: - `kubectl` (for Kubernetes tools) - `helm` (for Helm tools) - `istioctl` (for Istio tools) + - `linkerd` (for Linkerd tools) - `cilium` (for Cilium tools) ### Building diff --git a/cmd/main.go b/cmd/main.go index fa737dd..aad3d6a 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -22,6 +22,7 @@ import ( "github.com/kagent-dev/tools/pkg/helm" "github.com/kagent-dev/tools/pkg/istio" "github.com/kagent-dev/tools/pkg/k8s" + "github.com/kagent-dev/tools/pkg/linkerd" "github.com/kagent-dev/tools/pkg/prometheus" "github.com/kagent-dev/tools/pkg/utils" "github.com/spf13/cobra" @@ -291,6 +292,7 @@ func registerMCP(mcp *server.MCPServer, enabledToolProviders []string, kubeconfi "cilium": cilium.RegisterTools, "helm": helm.RegisterTools, "istio": istio.RegisterTools, + "linkerd": linkerd.RegisterTools, "k8s": func(s *server.MCPServer) { k8s.RegisterTools(s, nil, kubeconfig) }, "prometheus": prometheus.RegisterTools, "utils": utils.RegisterTools, diff --git a/pkg/linkerd/linkerd.go b/pkg/linkerd/linkerd.go new file mode 100644 index 0000000..fcf9a0f --- /dev/null +++ b/pkg/linkerd/linkerd.go @@ -0,0 +1,1019 @@ +package linkerd + +import ( + "context" + goerrors "errors" + "fmt" + "os" + "strings" + + "github.com/kagent-dev/tools/internal/commands" + toolerrors "github.com/kagent-dev/tools/internal/errors" + "github.com/kagent-dev/tools/internal/security" + "github.com/kagent-dev/tools/internal/telemetry" + "github.com/kagent-dev/tools/pkg/utils" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +const ( + linkerdInjectionAnnotationKey = "linkerd.io/inject" + linkerdInjectionAnnotationJSONPath = "/spec/template/metadata/annotations/linkerd.io~1inject" +) + +func runLinkerdCommand(ctx context.Context, args []string) (string, error) { + return runLinkerdCommandWithOptions(ctx, args, false) +} + +func runLinkerdManifestCommand(ctx context.Context, args []string) (string, error) { + return runLinkerdCommandWithOptions(ctx, args, true) +} + +func runLinkerdCommandWithOptions(ctx context.Context, args []string, stdoutOnly bool) (string, error) { + kubeconfigPath := utils.GetKubeconfig() + builder := commands.NewCommandBuilder("linkerd"). + WithArgs(args...). + WithKubeconfig(kubeconfigPath) + + if stdoutOnly { + builder = builder.WithStdoutOnly(true) + } + + return builder.Execute(ctx) +} + +func applyManifest(ctx context.Context, manifest string) (string, error) { + return runKubectlManifestCommand(ctx, "apply", manifest) +} + +func deleteManifest(ctx context.Context, manifest string) (string, error) { + return runKubectlManifestCommand(ctx, "delete", manifest) +} + +func runKubectlManifestCommand(ctx context.Context, action, manifest string) (string, error) { + manifestPath, err := writeManifestToTempFile(manifest) + if err != nil { + return "", fmt.Errorf("failed to prepare manifest for kubectl %s: %w", action, err) + } + defer os.Remove(manifestPath) + + return runKubectlCommand(ctx, []string{action, "-f", manifestPath}) +} + +func runKubectlCommand(ctx context.Context, args []string) (string, error) { + kubeconfigPath := utils.GetKubeconfig() + return commands.NewCommandBuilder("kubectl"). + WithArgs(args...). + WithKubeconfig(kubeconfigPath). + Execute(ctx) +} + +func writeManifestToTempFile(manifest string) (string, error) { + tempFile, err := os.CreateTemp("", "linkerd-manifest-*.yaml") + if err != nil { + return "", fmt.Errorf("failed to create temporary manifest file: %w", err) + } + + if _, err := tempFile.WriteString(manifest); err != nil { + tempFile.Close() + os.Remove(tempFile.Name()) + return "", fmt.Errorf("failed to write manifest to temporary file: %w", err) + } + + if err := tempFile.Close(); err != nil { + os.Remove(tempFile.Name()) + return "", fmt.Errorf("failed to close temporary manifest file: %w", err) + } + + return tempFile.Name(), nil +} + +func formatLinkerdCommandResult(operation string, output string, err error) (*mcp.CallToolResult, error) { + if err == nil { + return mcp.NewToolResultText(output), nil + } + + trimmedOutput := strings.TrimSpace(output) + failureMessage := fmt.Sprintf("%s failed: %v", operation, err) + if trimmedOutput != "" { + failureMessage = fmt.Sprintf("%s\n\n%s", failureMessage, trimmedOutput) + } + + var toolErr *toolerrors.ToolError + if goerrors.As(err, &toolErr) { + toolErr = toolErr.WithContext("kagent_operation", operation) + if trimmedOutput != "" { + toolErr = toolErr.WithContext("command_output", trimmedOutput) + } + return toolErr.ToMCPResult(), nil + } + + return mcp.NewToolResultError(failureMessage), nil +} + +// Linkerd check +func handleLinkerdCheck(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + namespace := mcp.ParseString(request, "namespace", "") + preCheck := mcp.ParseString(request, "pre_check", "") == "true" + proxyCheck := mcp.ParseString(request, "proxy_check", "") == "true" + waitDuration := mcp.ParseString(request, "wait", "") + output := mcp.ParseString(request, "output", "") + + args := []string{"check"} + + if preCheck { + args = append(args, "--pre") + } + + if proxyCheck { + args = append(args, "--proxy") + } + + if namespace != "" { + args = append(args, "-n", namespace) + } + + if waitDuration != "" { + args = append(args, "--wait", waitDuration) + } + + if output != "" { + args = append(args, "--output", output) + } + + result, err := runLinkerdCommand(ctx, args) + return formatLinkerdCommandResult("linkerd check", result, err) + +} + +// Linkerd install +func handleLinkerdInstall(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + ha := mcp.ParseString(request, "ha", "") == "true" + crdsOnly := mcp.ParseString(request, "crds_only", "") == "true" + skipChecks := mcp.ParseString(request, "skip_checks", "") == "true" + identityTrustAnchors := mcp.ParseString(request, "identity_trust_anchors_pem", "") + setOverrides := mcp.ParseString(request, "set_overrides", "") + + args := []string{"install"} + + if ha { + args = append(args, "--ha") + } + + if skipChecks { + args = append(args, "--skip-checks") + } + + if identityTrustAnchors != "" { + args = append(args, "--identity-trust-anchors-pem", identityTrustAnchors) + } + + args = appendSetOverrides(args, setOverrides) + + crdArgs := append([]string{}, args...) + crdArgs = append(crdArgs, "--crds") + + if crdsOnly { + manifest, err := runLinkerdManifestCommand(ctx, crdArgs) + if err != nil { + return formatLinkerdCommandResult("linkerd install --crds", manifest, err) + } + + applyResult, applyErr := applyManifest(ctx, manifest) + return formatLinkerdCommandResult("kubectl apply linkerd install CRDs manifest", applyResult, applyErr) + } + + var combinedOutput strings.Builder + + crdManifest, err := runLinkerdManifestCommand(ctx, crdArgs) + if err != nil { + return formatLinkerdCommandResult("linkerd install --crds", crdManifest, err) + } + + crdApplyResult, crdApplyErr := applyManifest(ctx, crdManifest) + if crdApplyErr != nil { + return formatLinkerdCommandResult("kubectl apply linkerd install CRDs manifest", crdApplyResult, crdApplyErr) + } + + if trimmed := strings.TrimSpace(crdApplyResult); trimmed != "" { + combinedOutput.WriteString("Linkerd CRDs applied:\n") + combinedOutput.WriteString(trimmed) + combinedOutput.WriteString("\n\n") + } + + manifest, err := runLinkerdManifestCommand(ctx, args) + if err != nil { + return formatLinkerdCommandResult("linkerd install", manifest, err) + } + + applyResult, applyErr := applyManifest(ctx, manifest) + + finalOutput := applyResult + if combinedOutput.Len() > 0 { + trimmedApply := strings.TrimSpace(applyResult) + if trimmedApply != "" { + combinedOutput.WriteString("Linkerd control plane applied:\n") + combinedOutput.WriteString(trimmedApply) + } else if applyErr != nil { + combinedOutput.WriteString("Linkerd control plane apply failed (no output captured)") + } else { + combinedOutput.WriteString("Linkerd control plane applied.") + } + finalOutput = combinedOutput.String() + } + + return formatLinkerdCommandResult("kubectl apply linkerd install manifest", finalOutput, applyErr) + +} + +// Linkerd workload injection patch +func handleLinkerdWorkloadInjection(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + workloadName := mcp.ParseString(request, "workload_name", "") + if workloadName == "" { + return mcp.NewToolResultError("workload_name parameter is required"), nil + } + + if err := security.ValidateK8sResourceName(workloadName); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("invalid workload name: %v", err)), nil + } + + namespace := mcp.ParseString(request, "namespace", "default") + if err := security.ValidateNamespace(namespace); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("invalid namespace: %v", err)), nil + } + + workloadType := strings.ToLower(mcp.ParseString(request, "workload_type", "deployment")) + switch workloadType { + case "deployment", "statefulset", "daemonset": + default: + return mcp.NewToolResultError("workload_type must be deployment, statefulset, or daemonset"), nil + } + + removeAnnotation := mcp.ParseString(request, "remove_annotation", "") == "true" + injectState := strings.ToLower(mcp.ParseString(request, "inject_state", "disabled")) + if !removeAnnotation { + switch injectState { + case "enabled", "disabled", "ingress": + default: + return mcp.NewToolResultError("inject_state must be enabled, disabled, or ingress"), nil + } + } + + args := []string{"patch", workloadType, workloadName, "-n", namespace} + var patch string + operation := fmt.Sprintf("kubectl patch %s %s for linkerd injection", workloadType, workloadName) + if removeAnnotation { + patch = fmt.Sprintf(`[{"op":"remove","path":"%s"}]`, linkerdInjectionAnnotationJSONPath) + args = append(args, "--type=json", "-p", patch) + operation = fmt.Sprintf("kubectl remove linkerd injection annotation from %s %s", workloadType, workloadName) + } else { + patch = fmt.Sprintf(`{"spec":{"template":{"metadata":{"annotations":{"%s":"%s"}}}}}`, linkerdInjectionAnnotationKey, injectState) + args = append(args, "-p", patch) + operation = fmt.Sprintf("kubectl set linkerd injection=%s on %s %s", injectState, workloadType, workloadName) + } + + result, err := runKubectlCommand(ctx, args) + return formatLinkerdCommandResult(operation, result, err) +} + +// Linkerd install CNI +func handleLinkerdInstallCNI(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + skipChecks := mcp.ParseString(request, "skip_checks", "") == "true" + setOverrides := mcp.ParseString(request, "set_overrides", "") + + args := []string{"install-cni"} + + if skipChecks { + args = append(args, "--skip-checks") + } + + args = appendSetOverrides(args, setOverrides) + + manifest, err := runLinkerdManifestCommand(ctx, args) + if err != nil { + return formatLinkerdCommandResult("linkerd install-cni", manifest, err) + } + + applyResult, applyErr := applyManifest(ctx, manifest) + return formatLinkerdCommandResult("kubectl apply linkerd install-cni manifest", applyResult, applyErr) + +} + +// Linkerd upgrade +func handleLinkerdUpgrade(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + ha := mcp.ParseString(request, "ha", "") == "true" + crdsOnly := mcp.ParseString(request, "crds_only", "") == "true" + skipChecks := mcp.ParseString(request, "skip_checks", "") == "true" + setOverrides := mcp.ParseString(request, "set_overrides", "") + + args := []string{"upgrade"} + + if crdsOnly { + args = append(args, "--crds") + } + + if ha { + args = append(args, "--ha") + } + + if skipChecks { + args = append(args, "--skip-checks") + } + + args = appendSetOverrides(args, setOverrides) + + manifest, err := runLinkerdManifestCommand(ctx, args) + if err != nil { + return formatLinkerdCommandResult("linkerd upgrade", manifest, err) + } + + applyResult, applyErr := applyManifest(ctx, manifest) + return formatLinkerdCommandResult("kubectl apply linkerd upgrade manifest", applyResult, applyErr) + +} + +// Linkerd uninstall +func handleLinkerdUninstall(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + force := mcp.ParseString(request, "force", "") == "true" + + args := []string{"uninstall"} + + if force { + args = append(args, "--force") + } + + manifest, err := runLinkerdManifestCommand(ctx, args) + if err != nil { + return formatLinkerdCommandResult("linkerd uninstall", manifest, err) + } + + deleteResult, deleteErr := deleteManifest(ctx, manifest) + return formatLinkerdCommandResult("kubectl delete linkerd uninstall manifest", deleteResult, deleteErr) + +} + +// Linkerd version +func handleLinkerdVersion(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + clientOnly := mcp.ParseString(request, "client_only", "") == "true" + short := mcp.ParseString(request, "short", "") == "true" + + args := []string{"version"} + + if clientOnly { + args = append(args, "--client") + } + + if short { + args = append(args, "--short") + } + + result, err := runLinkerdCommand(ctx, args) + return formatLinkerdCommandResult("linkerd version", result, err) + +} + +// Linkerd authz +func handleLinkerdAuthz(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + resource := mcp.ParseString(request, "resource", "") + if resource == "" { + return mcp.NewToolResultError("resource parameter is required"), nil + } + + namespace := mcp.ParseString(request, "namespace", "") + + args := []string{"authz"} + + if namespace != "" { + args = append(args, "-n", namespace) + } + + args = append(args, resource) + + result, err := runLinkerdCommand(ctx, args) + return formatLinkerdCommandResult("linkerd authz", result, err) + +} + +// Linkerd stat +func handleLinkerdStat(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + resource := mcp.ParseString(request, "resource", "") + if resource == "" { + return mcp.NewToolResultError("resource parameter is required"), nil + } + + namespace := mcp.ParseString(request, "namespace", "") + allNamespaces := mcp.ParseString(request, "all_namespaces", "") == "true" + from := mcp.ParseString(request, "from", "") + to := mcp.ParseString(request, "to", "") + timeWindow := mcp.ParseString(request, "time_window", "") + output := mcp.ParseString(request, "output", "") + + args := []string{"stat", resource} + + if allNamespaces { + args = append(args, "-A") + } else if namespace != "" { + args = append(args, "-n", namespace) + } + + if from != "" { + args = append(args, "--from", from) + } + + if to != "" { + args = append(args, "--to", to) + } + + if timeWindow != "" { + args = append(args, "--time-window", timeWindow) + } + + if output != "" { + args = append(args, "-o", output) + } + + result, err := runLinkerdCommand(ctx, args) + return formatLinkerdCommandResult("linkerd stat", result, err) + +} + +// Linkerd top +func handleLinkerdTop(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + resource := mcp.ParseString(request, "resource", "") + if resource == "" { + return mcp.NewToolResultError("resource parameter is required"), nil + } + + namespace := mcp.ParseString(request, "namespace", "") + from := mcp.ParseString(request, "from", "") + to := mcp.ParseString(request, "to", "") + maxRows := mcp.ParseString(request, "max_results", "") + timeWindow := mcp.ParseString(request, "time_window", "") + + args := []string{"top", resource} + + if namespace != "" { + args = append(args, "-n", namespace) + } + + if from != "" { + args = append(args, "--from", from) + } + + if to != "" { + args = append(args, "--to", to) + } + + if maxRows != "" { + args = append(args, "--max", maxRows) + } + + if timeWindow != "" { + args = append(args, "--time-window", timeWindow) + } + + result, err := runLinkerdCommand(ctx, args) + return formatLinkerdCommandResult("linkerd top", result, err) + +} + +// Linkerd edges +func handleLinkerdEdges(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + resource := mcp.ParseString(request, "resource", "") + if resource == "" { + return mcp.NewToolResultError("resource parameter is required"), nil + } + + namespace := mcp.ParseString(request, "namespace", "") + allNamespaces := mcp.ParseString(request, "all_namespaces", "") == "true" + output := mcp.ParseString(request, "output", "") + + args := []string{"edges", resource} + + if allNamespaces { + args = append(args, "-A") + } else if namespace != "" { + args = append(args, "-n", namespace) + } + + if output != "" { + args = append(args, "-o", output) + } + + result, err := runLinkerdCommand(ctx, args) + return formatLinkerdCommandResult("linkerd edges", result, err) + +} + +// Linkerd routes +func handleLinkerdRoutes(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + resource := mcp.ParseString(request, "resource", "") + if resource == "" { + return mcp.NewToolResultError("resource parameter is required"), nil + } + + namespace := mcp.ParseString(request, "namespace", "") + output := mcp.ParseString(request, "output", "") + from := mcp.ParseString(request, "from", "") + to := mcp.ParseString(request, "to", "") + + args := []string{"routes", resource} + + if namespace != "" { + args = append(args, "-n", namespace) + } + + if from != "" { + args = append(args, "--from", from) + } + + if to != "" { + args = append(args, "--to", to) + } + + if output != "" { + args = append(args, "-o", output) + } + + result, err := runLinkerdCommand(ctx, args) + return formatLinkerdCommandResult("linkerd routes", result, err) + +} + +// Linkerd diagnostics proxy metrics +func handleLinkerdDiagnosticsProxyMetrics(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + namespace := mcp.ParseString(request, "namespace", "") + allNamespaces := mcp.ParseString(request, "all_namespaces", "") == "true" + selector := mcp.ParseString(request, "selector", "") + resource := mcp.ParseString(request, "resource", "") + + args := []string{"diagnostics", "proxy-metrics"} + + if allNamespaces { + args = append(args, "-A") + } else if namespace != "" { + args = append(args, "-n", namespace) + } + + if selector != "" { + args = append(args, "--selector", selector) + } + + if resource != "" { + args = append(args, resource) + } + + result, err := runLinkerdCommand(ctx, args) + return formatLinkerdCommandResult("linkerd diagnostics proxy-metrics", result, err) + +} + +// Linkerd diagnostics controller metrics +func handleLinkerdDiagnosticsControllerMetrics(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + namespace := mcp.ParseString(request, "namespace", "") + component := mcp.ParseString(request, "component", "") + + args := []string{"diagnostics", "controller-metrics"} + + if namespace != "" { + args = append(args, "-n", namespace) + } + + if component != "" { + args = append(args, "--component", component) + } + + result, err := runLinkerdCommand(ctx, args) + return formatLinkerdCommandResult("linkerd diagnostics controller-metrics", result, err) + +} + +// Linkerd diagnostics endpoints +func handleLinkerdDiagnosticsEndpoints(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + authority := mcp.ParseString(request, "authority", "") + if authority == "" { + return mcp.NewToolResultError("authority parameter is required"), nil + } + + namespace := mcp.ParseString(request, "namespace", "") + + args := []string{"diagnostics", "endpoints"} + + if namespace != "" { + args = append(args, "-n", namespace) + } + + args = append(args, authority) + + result, err := runLinkerdCommand(ctx, args) + return formatLinkerdCommandResult("linkerd diagnostics endpoints", result, err) + +} + +// Linkerd diagnostics policy +func handleLinkerdDiagnosticsPolicy(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + authority := mcp.ParseString(request, "authority", "") + if authority == "" { + return mcp.NewToolResultError("authority parameter is required"), nil + } + + namespace := mcp.ParseString(request, "namespace", "") + + args := []string{"diagnostics", "policy"} + + if namespace != "" { + args = append(args, "-n", namespace) + } + + args = append(args, authority) + + result, err := runLinkerdCommand(ctx, args) + return formatLinkerdCommandResult("linkerd diagnostics policy", result, err) + +} + +// Linkerd diagnostics profile +func handleLinkerdDiagnosticsProfile(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + authority := mcp.ParseString(request, "authority", "") + if authority == "" { + return mcp.NewToolResultError("authority parameter is required"), nil + } + + namespace := mcp.ParseString(request, "namespace", "") + + args := []string{"diagnostics", "profile"} + + if namespace != "" { + args = append(args, "-n", namespace) + } + + args = append(args, authority) + + result, err := runLinkerdCommand(ctx, args) + return formatLinkerdCommandResult("linkerd diagnostics profile", result, err) + +} + +// Linkerd viz install +func handleLinkerdVizInstall(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + ha := mcp.ParseString(request, "ha", "") == "true" + skipChecks := mcp.ParseString(request, "skip_checks", "") == "true" + setOverrides := mcp.ParseString(request, "set_overrides", "") + + args := []string{"viz", "install"} + + if ha { + args = append(args, "--ha") + } + + if skipChecks { + args = append(args, "--skip-checks") + } + + args = appendSetOverrides(args, setOverrides) + + manifest, err := runLinkerdManifestCommand(ctx, args) + if err != nil { + return formatLinkerdCommandResult("linkerd viz install", manifest, err) + } + + applyResult, applyErr := applyManifest(ctx, manifest) + return formatLinkerdCommandResult("kubectl apply linkerd viz install manifest", applyResult, applyErr) + +} + +// Linkerd viz uninstall +func handleLinkerdVizUninstall(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + force := mcp.ParseString(request, "force", "") == "true" + + args := []string{"viz", "uninstall"} + + if force { + args = append(args, "--force") + } + + manifest, err := runLinkerdManifestCommand(ctx, args) + if err != nil { + return formatLinkerdCommandResult("linkerd viz uninstall", manifest, err) + } + + deleteResult, deleteErr := deleteManifest(ctx, manifest) + return formatLinkerdCommandResult("kubectl delete linkerd viz uninstall manifest", deleteResult, deleteErr) + +} + +// Linkerd viz top +func handleLinkerdVizTop(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + resource := mcp.ParseString(request, "resource", "") + if resource == "" { + return mcp.NewToolResultError("resource parameter is required"), nil + } + + namespace := mcp.ParseString(request, "namespace", "") + from := mcp.ParseString(request, "from", "") + to := mcp.ParseString(request, "to", "") + maxRows := mcp.ParseString(request, "max_results", "") + timeWindow := mcp.ParseString(request, "time_window", "") + + args := []string{"viz", "top", resource} + + if namespace != "" { + args = append(args, "-n", namespace) + } + + if from != "" { + args = append(args, "--from", from) + } + + if to != "" { + args = append(args, "--to", to) + } + + if maxRows != "" { + args = append(args, "--max", maxRows) + } + + if timeWindow != "" { + args = append(args, "--time-window", timeWindow) + } + + result, err := runLinkerdCommand(ctx, args) + return formatLinkerdCommandResult("linkerd viz top", result, err) + +} + +// Linkerd viz stat +func handleLinkerdVizStat(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + resource := mcp.ParseString(request, "resource", "") + if resource == "" { + return mcp.NewToolResultError("resource parameter is required"), nil + } + + namespace := mcp.ParseString(request, "namespace", "") + allNamespaces := mcp.ParseString(request, "all_namespaces", "") == "true" + from := mcp.ParseString(request, "from", "") + to := mcp.ParseString(request, "to", "") + timeWindow := mcp.ParseString(request, "time_window", "") + output := mcp.ParseString(request, "output", "") + + args := []string{"viz", "stat", resource} + + if allNamespaces { + args = append(args, "-A") + } else if namespace != "" { + args = append(args, "-n", namespace) + } + + if from != "" { + args = append(args, "--from", from) + } + + if to != "" { + args = append(args, "--to", to) + } + + if timeWindow != "" { + args = append(args, "--time-window", timeWindow) + } + + if output != "" { + args = append(args, "-o", output) + } + + result, err := runLinkerdCommand(ctx, args) + return formatLinkerdCommandResult("linkerd viz stat", result, err) + +} + +// Linkerd FIPS audit +func handleLinkerdFipsAudit(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + namespace := mcp.ParseString(request, "namespace", "") + + args := []string{"fips", "audit"} + + if namespace != "" { + args = append(args, "-n", namespace) + } + + result, err := runLinkerdCommand(ctx, args) + return formatLinkerdCommandResult("linkerd fips audit", result, err) + +} + +// Linkerd policy generate +func handleLinkerdPolicyGenerate(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + namespace := mcp.ParseString(request, "namespace", "") + output := mcp.ParseString(request, "output", "") + timeout := mcp.ParseString(request, "timeout", "") + + args := []string{"policy", "generate"} + + if namespace != "" { + args = append(args, "-n", namespace) + } + + if output != "" { + args = append(args, "-o", output) + } + + if timeout != "" { + args = append(args, "--timeout", timeout) + } + + result, err := runLinkerdCommand(ctx, args) + return formatLinkerdCommandResult("linkerd policy generate", result, err) + +} + +func appendSetOverrides(args []string, overrides string) []string { + if overrides == "" { + return args + } + + pairs := strings.Split(overrides, ",") + for _, pair := range pairs { + trimmed := strings.TrimSpace(pair) + if trimmed == "" { + continue + } + args = append(args, "--set", trimmed) + } + + return args +} + +// Register Linkerd tools +func RegisterTools(s *server.MCPServer) { + s.AddTool(mcp.NewTool("linkerd_check", + mcp.WithDescription("Run pre-flight or data plane checks for the Linkerd control plane"), + mcp.WithString("namespace", mcp.Description("Namespace that contains Linkerd components")), + mcp.WithString("pre_check", mcp.Description("Set to true to run pre-installation checks")), + mcp.WithString("proxy_check", mcp.Description("Set to true to run proxy diagnostics")), + mcp.WithString("wait", mcp.Description("Duration to wait for checks to complete, e.g. 30s")), + mcp.WithString("output", mcp.Description("Output format, e.g. table, short, json")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("linkerd_check", handleLinkerdCheck))) + + s.AddTool(mcp.NewTool("linkerd_install", + mcp.WithDescription("Install the Linkerd control plane components"), + mcp.WithString("ha", mcp.Description("Set to true to deploy high availability components")), + mcp.WithString("crds_only", mcp.Description("Set to true to output only CRDs")), + mcp.WithString("skip_checks", mcp.Description("Skip Kubernetes and environment checks")), + mcp.WithString("identity_trust_anchors_pem", mcp.Description("PEM encoded trust anchors to use")), + mcp.WithString("set_overrides", mcp.Description("Comma-separated Helm style key=value overrides")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("linkerd_install", handleLinkerdInstall))) + + s.AddTool(mcp.NewTool("linkerd_patch_workload_injection", + mcp.WithDescription("Enable, disable, or remove Linkerd proxy injection annotations on a workload's pod template"), + mcp.WithString("workload_name", mcp.Description("Name of the workload (e.g. simple-app)"), mcp.Required()), + mcp.WithString("namespace", mcp.Description("Namespace containing the workload (default: default)")), + mcp.WithString("workload_type", mcp.Description("Workload type: deployment, statefulset, or daemonset (default: deployment)")), + mcp.WithString("inject_state", mcp.Description("Annotation value to set (enabled, disabled, ingress); ignored if remove_annotation is true")), + mcp.WithString("remove_annotation", mcp.Description("Set to true to remove the annotation instead of setting it")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("linkerd_patch_workload_injection", handleLinkerdWorkloadInjection))) + + s.AddTool(mcp.NewTool("linkerd_install_cni", + mcp.WithDescription("Install the Linkerd CNI components"), + mcp.WithString("skip_checks", mcp.Description("Skip Kubernetes and environment checks")), + mcp.WithString("set_overrides", mcp.Description("Comma-separated Helm style key=value overrides")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("linkerd_install_cni", handleLinkerdInstallCNI))) + + s.AddTool(mcp.NewTool("linkerd_upgrade", + mcp.WithDescription("Upgrade the Linkerd control plane components"), + mcp.WithString("ha", mcp.Description("Set to true to use high availability values")), + mcp.WithString("crds_only", mcp.Description("Set to true to upgrade only CRDs")), + mcp.WithString("skip_checks", mcp.Description("Skip Kubernetes and environment checks")), + mcp.WithString("set_overrides", mcp.Description("Comma-separated Helm style key=value overrides")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("linkerd_upgrade", handleLinkerdUpgrade))) + + s.AddTool(mcp.NewTool("linkerd_uninstall", + mcp.WithDescription("Uninstall the Linkerd control plane components"), + mcp.WithString("force", mcp.Description("Set to true to skip confirmation prompts")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("linkerd_uninstall", handleLinkerdUninstall))) + + s.AddTool(mcp.NewTool("linkerd_version", + mcp.WithDescription("Get Linkerd client and server versions"), + mcp.WithString("client_only", mcp.Description("Set to true to print only client version")), + mcp.WithString("short", mcp.Description("Set to true for short version output")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("linkerd_version", handleLinkerdVersion))) + + s.AddTool(mcp.NewTool("linkerd_authz", + mcp.WithDescription("List Linkerd authorizations for a resource"), + mcp.WithString("resource", mcp.Description("Resource to inspect, e.g. deploy/web"), mcp.Required()), + mcp.WithString("namespace", mcp.Description("Namespace containing the resource")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("linkerd_authz", handleLinkerdAuthz))) + + s.AddTool(mcp.NewTool("linkerd_stat", + mcp.WithDescription("Get resource metrics using linkerd stat"), + mcp.WithString("resource", mcp.Description("Kubernetes resource to inspect (e.g. deploy/web)"), mcp.Required()), + mcp.WithString("namespace", mcp.Description("Namespace for the resource")), + mcp.WithString("all_namespaces", mcp.Description("Set to true to inspect every namespace")), + mcp.WithString("from", mcp.Description("Restrict metrics to traffic from the specified resource")), + mcp.WithString("to", mcp.Description("Restrict metrics to traffic to the specified resource")), + mcp.WithString("time_window", mcp.Description("Time window for metrics, e.g. 1m")), + mcp.WithString("output", mcp.Description("Output format (table, json)")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("linkerd_stat", handleLinkerdStat))) + + s.AddTool(mcp.NewTool("linkerd_top", + mcp.WithDescription("Inspect live traffic using linkerd top"), + mcp.WithString("resource", mcp.Description("Resource to observe (e.g. deploy/web)"), mcp.Required()), + mcp.WithString("namespace", mcp.Description("Namespace for the resource")), + mcp.WithString("from", mcp.Description("Limit traffic to requests originating from this resource")), + mcp.WithString("to", mcp.Description("Limit traffic to requests destined to this resource")), + mcp.WithString("max_results", mcp.Description("Maximum number of rows to display")), + mcp.WithString("time_window", mcp.Description("Time window for sampling, e.g. 30s")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("linkerd_top", handleLinkerdTop))) + + s.AddTool(mcp.NewTool("linkerd_edges", + mcp.WithDescription("Describe allowed and denied edges between resources"), + mcp.WithString("resource", mcp.Description("Resource to inspect (e.g. deploy/web)"), mcp.Required()), + mcp.WithString("namespace", mcp.Description("Namespace for the resource")), + mcp.WithString("all_namespaces", mcp.Description("Set to true to inspect every namespace")), + mcp.WithString("output", mcp.Description("Output format (table, wide, json)")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("linkerd_edges", handleLinkerdEdges))) + + s.AddTool(mcp.NewTool("linkerd_routes", + mcp.WithDescription("Describe HTTP routes for resources"), + mcp.WithString("resource", mcp.Description("Resource to inspect (e.g. deploy/web)"), mcp.Required()), + mcp.WithString("namespace", mcp.Description("Namespace for the resource")), + mcp.WithString("from", mcp.Description("Filter by traffic originating from this resource")), + mcp.WithString("to", mcp.Description("Filter by traffic destined to this resource")), + mcp.WithString("output", mcp.Description("Output format (table, json)")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("linkerd_routes", handleLinkerdRoutes))) + + s.AddTool(mcp.NewTool("linkerd_diagnostics_proxy_metrics", + mcp.WithDescription("Collect raw proxy metrics for Linkerd workloads"), + mcp.WithString("namespace", mcp.Description("Namespace to inspect")), + mcp.WithString("all_namespaces", mcp.Description("Set to true to inspect every namespace")), + mcp.WithString("selector", mcp.Description("Label selector to target specific pods")), + mcp.WithString("resource", mcp.Description("Specific resource to query, e.g. deploy/web")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("linkerd_diagnostics_proxy_metrics", handleLinkerdDiagnosticsProxyMetrics))) + + s.AddTool(mcp.NewTool("linkerd_diagnostics_controller_metrics", + mcp.WithDescription("Fetch metrics directly from Linkerd control-plane components"), + mcp.WithString("namespace", mcp.Description("Namespace containing the control-plane pods")), + mcp.WithString("component", mcp.Description("Specific control-plane component name")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("linkerd_diagnostics_controller_metrics", handleLinkerdDiagnosticsControllerMetrics))) + + s.AddTool(mcp.NewTool("linkerd_diagnostics_endpoints", + mcp.WithDescription("Inspect Linkerd's service discovery endpoints for an authority"), + mcp.WithString("authority", mcp.Description("Authority host:port to inspect"), mcp.Required()), + mcp.WithString("namespace", mcp.Description("Namespace context for the query")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("linkerd_diagnostics_endpoints", handleLinkerdDiagnosticsEndpoints))) + + s.AddTool(mcp.NewTool("linkerd_diagnostics_policy", + mcp.WithDescription("Inspect Linkerd's policy state for an authority"), + mcp.WithString("authority", mcp.Description("Authority host:port to inspect"), mcp.Required()), + mcp.WithString("namespace", mcp.Description("Namespace context for the query")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("linkerd_diagnostics_policy", handleLinkerdDiagnosticsPolicy))) + + s.AddTool(mcp.NewTool("linkerd_diagnostics_profile", + mcp.WithDescription("Inspect Linkerd's service discovery profile for an authority"), + mcp.WithString("authority", mcp.Description("Authority host:port to inspect"), mcp.Required()), + mcp.WithString("namespace", mcp.Description("Namespace context for the query")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("linkerd_diagnostics_profile", handleLinkerdDiagnosticsProfile))) + + s.AddTool(mcp.NewTool("linkerd_viz_install", + mcp.WithDescription("Install the Linkerd viz extension components"), + mcp.WithString("ha", mcp.Description("Set to true to deploy high availability viz components")), + mcp.WithString("skip_checks", mcp.Description("Skip Kubernetes and environment checks")), + mcp.WithString("set_overrides", mcp.Description("Comma-separated Helm style key=value overrides")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("linkerd_viz_install", handleLinkerdVizInstall))) + + s.AddTool(mcp.NewTool("linkerd_viz_uninstall", + mcp.WithDescription("Remove the Linkerd viz extension from the cluster"), + mcp.WithString("force", mcp.Description("Set to true to skip confirmation prompts")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("linkerd_viz_uninstall", handleLinkerdVizUninstall))) + + s.AddTool(mcp.NewTool("linkerd_viz_top", + mcp.WithDescription("Inspect live traffic for viz-injected workloads"), + mcp.WithString("resource", mcp.Description("Resource to observe"), mcp.Required()), + mcp.WithString("namespace", mcp.Description("Namespace for the resource")), + mcp.WithString("from", mcp.Description("Limit traffic to requests originating from this resource")), + mcp.WithString("to", mcp.Description("Limit traffic to requests destined to this resource")), + mcp.WithString("max_results", mcp.Description("Maximum number of rows to display")), + mcp.WithString("time_window", mcp.Description("Time window for sampling, e.g. 30s")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("linkerd_viz_top", handleLinkerdVizTop))) + + s.AddTool(mcp.NewTool("linkerd_viz_stat", + mcp.WithDescription("Get viz metrics using linkerd viz stat"), + mcp.WithString("resource", mcp.Description("Resource to inspect (e.g. deploy/web)"), mcp.Required()), + mcp.WithString("namespace", mcp.Description("Namespace for the resource")), + mcp.WithString("all_namespaces", mcp.Description("Set to true to inspect every namespace")), + mcp.WithString("from", mcp.Description("Restrict metrics to traffic from the specified resource")), + mcp.WithString("to", mcp.Description("Restrict metrics to traffic to the specified resource")), + mcp.WithString("time_window", mcp.Description("Time window for metrics, e.g. 1m")), + mcp.WithString("output", mcp.Description("Output format (table, json)")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("linkerd_viz_stat", handleLinkerdVizStat))) + + s.AddTool(mcp.NewTool("linkerd_fips_audit", + mcp.WithDescription("Audit Linkerd proxies for FIPS compliance"), + mcp.WithString("namespace", mcp.Description("Namespace scope for the audit")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("linkerd_fips_audit", handleLinkerdFipsAudit))) + + s.AddTool(mcp.NewTool("linkerd_policy_generate", + mcp.WithDescription("Generate Linkerd policy manifests for existing workloads"), + mcp.WithString("namespace", mcp.Description("Namespace containing workload manifests")), + mcp.WithString("output", mcp.Description("Output format, e.g. yaml or json")), + mcp.WithString("timeout", mcp.Description("Command timeout, e.g. 30s")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("linkerd_policy_generate", handleLinkerdPolicyGenerate))) +} diff --git a/pkg/linkerd/linkerd_test.go b/pkg/linkerd/linkerd_test.go new file mode 100644 index 0000000..1913989 --- /dev/null +++ b/pkg/linkerd/linkerd_test.go @@ -0,0 +1,607 @@ +package linkerd + +import ( + "context" + "testing" + + "github.com/kagent-dev/tools/internal/cmd" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRegisterTools(t *testing.T) { + s := server.NewMCPServer("test-server", "v0.0.1") + RegisterTools(s) +} + +func TestHandleLinkerdCheck(t *testing.T) { + ctx := context.Background() + + t.Run("basic check", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("linkerd", []string{"check"}, "ok", nil) + + ctx = cmd.WithShellExecutor(ctx, mock) + + result, err := handleLinkerdCheck(ctx, mcp.CallToolRequest{}) + require.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsError) + }) + + t.Run("pre proxy check", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("linkerd", []string{"check", "--pre", "--proxy", "-n", "linkerd", "--wait", "60s", "--output", "short"}, "ok", nil) + + ctx = cmd.WithShellExecutor(ctx, mock) + + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]interface{}{ + "pre_check": "true", + "proxy_check": "true", + "namespace": "linkerd", + "wait": "60s", + "output": "short", + } + + result, err := handleLinkerdCheck(ctx, request) + require.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsError) + }) +} + +func TestHandleLinkerdInstall(t *testing.T) { + ctx := context.Background() + + t.Run("default install", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("linkerd", []string{"install", "--crds"}, "crd-manifest", nil) + mock.AddCommandString("linkerd", []string{"install"}, "manifest", nil) + mock.AddPartialMatcherString("kubectl", []string{"apply", "-f"}, "applied", nil) + ctx = cmd.WithShellExecutor(ctx, mock) + + result, err := handleLinkerdInstall(ctx, mcp.CallToolRequest{}) + require.NoError(t, err) + assert.False(t, result.IsError) + }) + + t.Run("ha install with overrides", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("linkerd", []string{"install", "--ha", "--skip-checks", "--identity-trust-anchors-pem", "anchors", "--set", "global.proxy.logLevel=debug", "--crds"}, "manifest", nil) + mock.AddPartialMatcherString("kubectl", []string{"apply", "-f"}, "applied", nil) + ctx = cmd.WithShellExecutor(ctx, mock) + + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]interface{}{ + "ha": "true", + "crds_only": "true", + "skip_checks": "true", + "identity_trust_anchors_pem": "anchors", + "set_overrides": "global.proxy.logLevel=debug", + } + + result, err := handleLinkerdInstall(ctx, request) + require.NoError(t, err) + assert.False(t, result.IsError) + }) +} + +func TestHandleLinkerdWorkloadInjection(t *testing.T) { + t.Run("disable deployment", func(t *testing.T) { + ctx := context.Background() + mock := cmd.NewMockShellExecutor() + patch := `{"spec":{"template":{"metadata":{"annotations":{"linkerd.io/inject":"disabled"}}}}}` + mock.AddCommandString("kubectl", []string{"patch", "deployment", "simple-app-v5", "-n", "simple-app", "-p", patch}, "patched", nil) + ctx = cmd.WithShellExecutor(ctx, mock) + + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]interface{}{ + "workload_name": "simple-app-v5", + "namespace": "simple-app", + "workload_type": "deployment", + "inject_state": "disabled", + } + + result, err := handleLinkerdWorkloadInjection(ctx, req) + require.NoError(t, err) + assert.False(t, result.IsError) + }) + + t.Run("remove annotation", func(t *testing.T) { + ctx := context.Background() + mock := cmd.NewMockShellExecutor() + patch := `[{"op":"remove","path":"/spec/template/metadata/annotations/linkerd.io~1inject"}]` + mock.AddCommandString("kubectl", []string{"patch", "statefulset", "inventory", "-n", "default", "--type=json", "-p", patch}, "patched", nil) + ctx = cmd.WithShellExecutor(ctx, mock) + + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]interface{}{ + "workload_name": "inventory", + "workload_type": "statefulset", + "remove_annotation": "true", + } + + result, err := handleLinkerdWorkloadInjection(ctx, req) + require.NoError(t, err) + assert.False(t, result.IsError) + }) + + t.Run("missing name", func(t *testing.T) { + ctx := context.Background() + result, err := handleLinkerdWorkloadInjection(ctx, mcp.CallToolRequest{}) + require.NoError(t, err) + assert.True(t, result.IsError) + }) +} + +func TestHandleLinkerdInstallCNI(t *testing.T) { + ctx := context.Background() + + t.Run("default install-cni", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("linkerd", []string{"install-cni"}, "manifest", nil) + mock.AddPartialMatcherString("kubectl", []string{"apply", "-f"}, "applied", nil) + ctx = cmd.WithShellExecutor(ctx, mock) + + result, err := handleLinkerdInstallCNI(ctx, mcp.CallToolRequest{}) + require.NoError(t, err) + assert.False(t, result.IsError) + }) + + t.Run("install-cni with overrides", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("linkerd", []string{"install-cni", "--skip-checks", "--set", "cniResourceReadyTimeout=10m"}, "manifest", nil) + mock.AddPartialMatcherString("kubectl", []string{"apply", "-f"}, "applied", nil) + ctx = cmd.WithShellExecutor(ctx, mock) + + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]interface{}{ + "skip_checks": "true", + "set_overrides": "cniResourceReadyTimeout=10m", + } + + result, err := handleLinkerdInstallCNI(ctx, request) + require.NoError(t, err) + assert.False(t, result.IsError) + }) +} + +func TestHandleLinkerdUpgrade(t *testing.T) { + ctx := context.Background() + + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("linkerd", []string{"upgrade", "--crds", "--ha", "--skip-checks", "--set", "global.proxy.logLevel=debug"}, "manifest", nil) + mock.AddPartialMatcherString("kubectl", []string{"apply", "-f"}, "applied", nil) + ctx = cmd.WithShellExecutor(ctx, mock) + + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]interface{}{ + "ha": "true", + "crds_only": "true", + "skip_checks": "true", + "set_overrides": "global.proxy.logLevel=debug", + } + + result, err := handleLinkerdUpgrade(ctx, request) + require.NoError(t, err) + assert.False(t, result.IsError) +} + +func TestHandleLinkerdUninstall(t *testing.T) { + ctx := context.Background() + + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("linkerd", []string{"uninstall", "--force"}, "removed", nil) + mock.AddPartialMatcherString("kubectl", []string{"delete", "-f"}, "deleted", nil) + ctx = cmd.WithShellExecutor(ctx, mock) + + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]interface{}{ + "force": "true", + } + + result, err := handleLinkerdUninstall(ctx, request) + require.NoError(t, err) + assert.False(t, result.IsError) +} + +func TestHandleLinkerdVersion(t *testing.T) { + ctx := context.Background() + + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("linkerd", []string{"version", "--client", "--short"}, "version", nil) + ctx = cmd.WithShellExecutor(ctx, mock) + + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]interface{}{ + "client_only": "true", + "short": "true", + } + + result, err := handleLinkerdVersion(ctx, request) + require.NoError(t, err) + assert.False(t, result.IsError) +} + +func TestHandleLinkerdAuthz(t *testing.T) { + ctx := context.Background() + + t.Run("missing resource", func(t *testing.T) { + result, err := handleLinkerdAuthz(ctx, mcp.CallToolRequest{}) + require.NoError(t, err) + assert.True(t, result.IsError) + }) + + t.Run("authz resource", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("linkerd", []string{"authz", "-n", "default", "deploy/web"}, "authz", nil) + ctx = cmd.WithShellExecutor(ctx, mock) + + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]interface{}{ + "resource": "deploy/web", + "namespace": "default", + } + + result, err := handleLinkerdAuthz(ctx, request) + require.NoError(t, err) + assert.False(t, result.IsError) + }) +} + +func TestHandleLinkerdStat(t *testing.T) { + ctx := context.Background() + + t.Run("missing resource", func(t *testing.T) { + result, err := handleLinkerdStat(ctx, mcp.CallToolRequest{}) + require.NoError(t, err) + assert.True(t, result.IsError) + }) + + t.Run("stat specific namespace", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("linkerd", []string{"stat", "deploy/web", "-n", "default", "--from", "deploy/api", "--time-window", "1m", "-o", "json"}, "stats", nil) + ctx = cmd.WithShellExecutor(ctx, mock) + + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]interface{}{ + "resource": "deploy/web", + "namespace": "default", + "from": "deploy/api", + "time_window": "1m", + "output": "json", + } + + result, err := handleLinkerdStat(ctx, request) + require.NoError(t, err) + assert.False(t, result.IsError) + }) +} + +func TestHandleLinkerdTop(t *testing.T) { + ctx := context.Background() + + t.Run("missing resource", func(t *testing.T) { + result, err := handleLinkerdTop(ctx, mcp.CallToolRequest{}) + require.NoError(t, err) + assert.True(t, result.IsError) + }) + + t.Run("top specific namespace", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("linkerd", []string{"top", "deploy/web", "-n", "default", "--max", "10", "--time-window", "30s"}, "top", nil) + ctx = cmd.WithShellExecutor(ctx, mock) + + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]interface{}{ + "resource": "deploy/web", + "namespace": "default", + "max_results": "10", + "time_window": "30s", + } + + result, err := handleLinkerdTop(ctx, request) + require.NoError(t, err) + assert.False(t, result.IsError) + }) +} + +func TestHandleLinkerdEdges(t *testing.T) { + ctx := context.Background() + + t.Run("missing resource", func(t *testing.T) { + result, err := handleLinkerdEdges(ctx, mcp.CallToolRequest{}) + require.NoError(t, err) + assert.True(t, result.IsError) + }) + + t.Run("edges all namespaces", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("linkerd", []string{"edges", "deploy/web", "-A", "-o", "wide"}, "edges", nil) + ctx = cmd.WithShellExecutor(ctx, mock) + + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]interface{}{ + "resource": "deploy/web", + "all_namespaces": "true", + "output": "wide", + } + + result, err := handleLinkerdEdges(ctx, request) + require.NoError(t, err) + assert.False(t, result.IsError) + }) +} + +func TestHandleLinkerdRoutes(t *testing.T) { + ctx := context.Background() + + t.Run("missing resource", func(t *testing.T) { + result, err := handleLinkerdRoutes(ctx, mcp.CallToolRequest{}) + require.NoError(t, err) + assert.True(t, result.IsError) + }) + + t.Run("routes with filters", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("linkerd", []string{"routes", "deploy/web", "-n", "default", "--from", "deploy/api", "--to", "svc/backend"}, "routes", nil) + ctx = cmd.WithShellExecutor(ctx, mock) + + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]interface{}{ + "resource": "deploy/web", + "namespace": "default", + "from": "deploy/api", + "to": "svc/backend", + } + + result, err := handleLinkerdRoutes(ctx, request) + require.NoError(t, err) + assert.False(t, result.IsError) + }) +} + +func TestHandleLinkerdDiagnosticsProxyMetrics(t *testing.T) { + ctx := context.Background() + + t.Run("basic selector", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("linkerd", []string{"diagnostics", "proxy-metrics", "-A", "--selector", "app=web"}, "metrics", nil) + ctx = cmd.WithShellExecutor(ctx, mock) + + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]interface{}{ + "all_namespaces": "true", + "selector": "app=web", + } + + result, err := handleLinkerdDiagnosticsProxyMetrics(ctx, request) + require.NoError(t, err) + assert.False(t, result.IsError) + }) + + t.Run("with namespace and resource", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("linkerd", []string{"diagnostics", "proxy-metrics", "-n", "emojivoto", "deploy/web"}, "metrics", nil) + ctx = cmd.WithShellExecutor(ctx, mock) + + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]interface{}{ + "namespace": "emojivoto", + "resource": "deploy/web", + } + + result, err := handleLinkerdDiagnosticsProxyMetrics(ctx, request) + require.NoError(t, err) + assert.False(t, result.IsError) + }) +} + +func TestHandleLinkerdDiagnosticsControllerMetrics(t *testing.T) { + ctx := context.Background() + + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("linkerd", []string{"diagnostics", "controller-metrics", "-n", "linkerd", "--component", "controller"}, "metrics", nil) + ctx = cmd.WithShellExecutor(ctx, mock) + + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]interface{}{ + "namespace": "linkerd", + "component": "controller", + } + + result, err := handleLinkerdDiagnosticsControllerMetrics(ctx, request) + require.NoError(t, err) + assert.False(t, result.IsError) +} + +func TestHandleLinkerdDiagnosticsEndpoints(t *testing.T) { + ctx := context.Background() + + t.Run("missing authority", func(t *testing.T) { + result, err := handleLinkerdDiagnosticsEndpoints(ctx, mcp.CallToolRequest{}) + require.NoError(t, err) + assert.True(t, result.IsError) + }) + + t.Run("with authority", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("linkerd", []string{"diagnostics", "endpoints", "web.linkerd-viz.svc.cluster.local:8084"}, "endpoints", nil) + ctx = cmd.WithShellExecutor(ctx, mock) + + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]interface{}{ + "authority": "web.linkerd-viz.svc.cluster.local:8084", + } + + result, err := handleLinkerdDiagnosticsEndpoints(ctx, request) + require.NoError(t, err) + assert.False(t, result.IsError) + }) +} + +func TestHandleLinkerdDiagnosticsPolicy(t *testing.T) { + ctx := context.Background() + + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("linkerd", []string{"diagnostics", "policy", "-n", "default", "web.linkerd-viz.svc.cluster.local:8084"}, "policy", nil) + ctx = cmd.WithShellExecutor(ctx, mock) + + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]interface{}{ + "authority": "web.linkerd-viz.svc.cluster.local:8084", + "namespace": "default", + } + + result, err := handleLinkerdDiagnosticsPolicy(ctx, request) + require.NoError(t, err) + assert.False(t, result.IsError) +} + +func TestHandleLinkerdDiagnosticsProfile(t *testing.T) { + ctx := context.Background() + + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("linkerd", []string{"diagnostics", "profile", "web.linkerd-viz.svc.cluster.local:8084"}, "profile", nil) + ctx = cmd.WithShellExecutor(ctx, mock) + + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]interface{}{ + "authority": "web.linkerd-viz.svc.cluster.local:8084", + } + + result, err := handleLinkerdDiagnosticsProfile(ctx, request) + require.NoError(t, err) + assert.False(t, result.IsError) +} + +func TestHandleLinkerdVizInstall(t *testing.T) { + ctx := context.Background() + + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("linkerd", []string{"viz", "install", "--ha", "--skip-checks", "--set", "tap.resources.limits.cpu=200m"}, "manifest", nil) + mock.AddPartialMatcherString("kubectl", []string{"apply", "-f"}, "applied", nil) + ctx = cmd.WithShellExecutor(ctx, mock) + + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]interface{}{ + "ha": "true", + "skip_checks": "true", + "set_overrides": "tap.resources.limits.cpu=200m", + } + + result, err := handleLinkerdVizInstall(ctx, request) + require.NoError(t, err) + assert.False(t, result.IsError) +} + +func TestHandleLinkerdVizUninstall(t *testing.T) { + ctx := context.Background() + + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("linkerd", []string{"viz", "uninstall", "--force"}, "removed", nil) + mock.AddPartialMatcherString("kubectl", []string{"delete", "-f"}, "deleted", nil) + ctx = cmd.WithShellExecutor(ctx, mock) + + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]interface{}{ + "force": "true", + } + + result, err := handleLinkerdVizUninstall(ctx, request) + require.NoError(t, err) + assert.False(t, result.IsError) +} + +func TestHandleLinkerdVizTop(t *testing.T) { + ctx := context.Background() + + t.Run("missing resource", func(t *testing.T) { + result, err := handleLinkerdVizTop(ctx, mcp.CallToolRequest{}) + require.NoError(t, err) + assert.True(t, result.IsError) + }) + + t.Run("viz top resource", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("linkerd", []string{"viz", "top", "deploy/web", "-n", "default", "--max", "5"}, "top", nil) + ctx = cmd.WithShellExecutor(ctx, mock) + + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]interface{}{ + "resource": "deploy/web", + "namespace": "default", + "max_results": "5", + } + + result, err := handleLinkerdVizTop(ctx, request) + require.NoError(t, err) + assert.False(t, result.IsError) + }) +} + +func TestHandleLinkerdVizStat(t *testing.T) { + ctx := context.Background() + + t.Run("missing resource", func(t *testing.T) { + result, err := handleLinkerdVizStat(ctx, mcp.CallToolRequest{}) + require.NoError(t, err) + assert.True(t, result.IsError) + }) + + t.Run("viz stat all namespaces", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("linkerd", []string{"viz", "stat", "deploy/web", "-A", "--time-window", "30s"}, "stats", nil) + ctx = cmd.WithShellExecutor(ctx, mock) + + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]interface{}{ + "resource": "deploy/web", + "all_namespaces": "true", + "time_window": "30s", + } + + result, err := handleLinkerdVizStat(ctx, request) + require.NoError(t, err) + assert.False(t, result.IsError) + }) +} + +func TestHandleLinkerdFipsAudit(t *testing.T) { + ctx := context.Background() + + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("linkerd", []string{"fips", "audit", "-n", "default"}, "audit", nil) + ctx = cmd.WithShellExecutor(ctx, mock) + + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]interface{}{ + "namespace": "default", + } + + result, err := handleLinkerdFipsAudit(ctx, request) + require.NoError(t, err) + assert.False(t, result.IsError) +} + +func TestHandleLinkerdPolicyGenerate(t *testing.T) { + ctx := context.Background() + + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("linkerd", []string{"policy", "generate", "-n", "default", "-o", "yaml", "--timeout", "30s"}, "policy", nil) + ctx = cmd.WithShellExecutor(ctx, mock) + + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]interface{}{ + "namespace": "default", + "output": "yaml", + "timeout": "30s", + } + + result, err := handleLinkerdPolicyGenerate(ctx, request) + require.NoError(t, err) + assert.False(t, result.IsError) +} From 4d12ff3baf4f228552c8489ebb9ccce55f1ee0ab Mon Sep 17 00:00:00 2001 From: Ivan Porta Date: Fri, 5 Dec 2025 03:52:37 +0900 Subject: [PATCH 3/7] remove linkerd viz as deprecated Signed-off-by: Ivan Porta --- pkg/linkerd/linkerd.go | 1243 ++++++++++++++++++++++------------- pkg/linkerd/linkerd_test.go | 311 ++++----- 2 files changed, 892 insertions(+), 662 deletions(-) diff --git a/pkg/linkerd/linkerd.go b/pkg/linkerd/linkerd.go index fcf9a0f..9cca93d 100644 --- a/pkg/linkerd/linkerd.go +++ b/pkg/linkerd/linkerd.go @@ -2,9 +2,11 @@ package linkerd import ( "context" + "encoding/json" goerrors "errors" "fmt" "os" + "sort" "strings" "github.com/kagent-dev/tools/internal/commands" @@ -16,29 +18,95 @@ import ( "github.com/mark3labs/mcp-go/server" ) +// ================================= +// Constants and variables +// ================================= + const ( - linkerdInjectionAnnotationKey = "linkerd.io/inject" - linkerdInjectionAnnotationJSONPath = "/spec/template/metadata/annotations/linkerd.io~1inject" + linkerdInjectionAnnotationKey = "linkerd.io/inject" ) +type linkerdWorkloadTypeConfig struct { + annotationsPath []string + namespaced bool +} + +var linkerdWorkloadTypes = map[string]linkerdWorkloadTypeConfig{ + "namespace": {annotationsPath: []string{"metadata", "annotations"}, namespaced: false}, + "deployment": {annotationsPath: []string{"spec", "template", "metadata", "annotations"}, namespaced: true}, + "statefulset": {annotationsPath: []string{"spec", "template", "metadata", "annotations"}, namespaced: true}, + "daemonset": {annotationsPath: []string{"spec", "template", "metadata", "annotations"}, namespaced: true}, + "replicaset": {annotationsPath: []string{"spec", "template", "metadata", "annotations"}, namespaced: true}, + "replicationcontroller": {annotationsPath: []string{"spec", "template", "metadata", "annotations"}, namespaced: true}, + "job": {annotationsPath: []string{"spec", "template", "metadata", "annotations"}, namespaced: true}, + "cronjob": {annotationsPath: []string{"spec", "jobTemplate", "spec", "template", "metadata", "annotations"}, namespaced: true}, + "pod": {annotationsPath: []string{"metadata", "annotations"}, namespaced: true}, +} + +// ================================= +// Helpers functions +// ================================= + +func supportedLinkerdWorkloadTypes() []string { + types := make([]string, 0, len(linkerdWorkloadTypes)) + for t := range linkerdWorkloadTypes { + types = append(types, t) + } + sort.Strings(types) + return types +} + +func buildAnnotationMergePatch(path []string, key, value string) (string, error) { + if len(path) == 0 { + return "", fmt.Errorf("annotation path is empty") + } + patch := map[string]interface{}{} + current := patch + for i, segment := range path { + if i == len(path)-1 { + current[segment] = map[string]interface{}{key: value} + continue + } + next := map[string]interface{}{} + current[segment] = next + current = next + } + data, err := json.Marshal(patch) + if err != nil { + return "", err + } + return string(data), nil +} + +func buildAnnotationRemovePatch(path []string, key string) string { + segments := append([]string{}, path...) + segments = append(segments, key) + for i, segment := range segments { + segments[i] = escapeJSONPointerSegment(segment) + } + return fmt.Sprintf(`[{"op":"remove","path":"/%s"}]`, strings.Join(segments, "/")) +} + +func escapeJSONPointerSegment(segment string) string { + segment = strings.ReplaceAll(segment, "~", "~0") + segment = strings.ReplaceAll(segment, "/", "~1") + return segment +} + func runLinkerdCommand(ctx context.Context, args []string) (string, error) { - return runLinkerdCommandWithOptions(ctx, args, false) + return executeLinkerdCommand(ctx, args) } func runLinkerdManifestCommand(ctx context.Context, args []string) (string, error) { - return runLinkerdCommandWithOptions(ctx, args, true) + return executeLinkerdCommand(ctx, args) } -func runLinkerdCommandWithOptions(ctx context.Context, args []string, stdoutOnly bool) (string, error) { +func executeLinkerdCommand(ctx context.Context, args []string) (string, error) { kubeconfigPath := utils.GetKubeconfig() builder := commands.NewCommandBuilder("linkerd"). WithArgs(args...). WithKubeconfig(kubeconfigPath) - if stdoutOnly { - builder = builder.WithStdoutOnly(true) - } - return builder.Execute(ctx) } @@ -111,7 +179,75 @@ func formatLinkerdCommandResult(operation string, output string, err error) (*mc return mcp.NewToolResultError(failureMessage), nil } -// Linkerd check +func appendSetOverrides(args []string, overrides string) []string { + return appendCSVArgs(args, "--set", overrides) +} + +func appendCSVArgs(args []string, flag, csv string) []string { + for _, value := range parseCommaSeparated(csv) { + args = append(args, flag, value) + } + return args +} + +func appendFlagArg(args []string, flag, value string) []string { + if value == "" { + return args + } + return append(args, flag, value) +} + +func appendBoolFlag(args []string, flag, value string) ([]string, error) { + trimmed := strings.ToLower(strings.TrimSpace(value)) + if trimmed == "" { + return args, nil + } + switch trimmed { + case "true": + return append(args, flag), nil + case "false": + return append(args, fmt.Sprintf("%s=false", flag)), nil + default: + return args, fmt.Errorf("invalid boolean value %q for %s", value, flag) + } +} + +func parseCommaSeparated(csv string) []string { + if csv == "" { + return nil + } + parts := strings.Split(csv, ",") + values := make([]string, 0, len(parts)) + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed == "" { + continue + } + values = append(values, trimmed) + } + return values +} + +// ================================= +// Linkerd +// ================================= + +// The check command will perform a series of checks to validate that the linkerd +// CLI and control plane are configured correctly. If the command encounters a +// failure it will print additional information about the failure and exit with a +// non-zero exit code. +// Usage: +// +// linkerd check [flags] +// +// Examples: +// +// # Check that the Linkerd control plane is up and running +// linkerd check +// # Check that the Linkerd control plane can be installed in the "test" namespace +// linkerd check --pre --linkerd-namespace test +// # Check that the Linkerd data plane proxies in the "app" namespace are up and running +// linkerd check --proxy --namespace app func handleLinkerdCheck(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { namespace := mcp.ParseString(request, "namespace", "") preCheck := mcp.ParseString(request, "pre_check", "") == "true" @@ -146,13 +282,73 @@ func handleLinkerdCheck(ctx context.Context, request mcp.CallToolRequest) (*mcp. } -// Linkerd install +// Output Kubernetes configs to install Linkerd. +// +// This command provides all Kubernetes configs necessary to install the Linkerd +// control plane. +// +// Usage: +// +// linkerd install [flags] +// +// Examples: +// +// # Install CRDs first. +// linkerd install --crds | kubectl apply -f - +// +// # Install the core control plane. +// linkerd install | kubectl apply -f - +// +// The installation can be configured by using the --set, --values, --set-string and --set-file flags. +// A full list of configurable values can be found at https://artifacthub.io/packages/helm/linkerd2/linkerd-control-plane#values func handleLinkerdInstall(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { ha := mcp.ParseString(request, "ha", "") == "true" crdsOnly := mcp.ParseString(request, "crds_only", "") == "true" skipChecks := mcp.ParseString(request, "skip_checks", "") == "true" identityTrustAnchors := mcp.ParseString(request, "identity_trust_anchors_pem", "") + identityTrustAnchorsFile := mcp.ParseString(request, "identity_trust_anchors_file", "") + identityClockSkewAllowance := mcp.ParseString(request, "identity_clock_skew_allowance", "") + identityIssuanceLifetime := mcp.ParseString(request, "identity_issuance_lifetime", "") + identityIssuerCertificateFile := mcp.ParseString(request, "identity_issuer_certificate_file", "") + identityIssuerKeyFile := mcp.ParseString(request, "identity_issuer_key_file", "") + identityTrustDomain := mcp.ParseString(request, "identity_trust_domain", "") setOverrides := mcp.ParseString(request, "set_overrides", "") + setStringOverrides := mcp.ParseString(request, "set_string_overrides", "") + setFileOverrides := mcp.ParseString(request, "set_file_overrides", "") + valuesFiles := mcp.ParseString(request, "values", "") + adminPort := mcp.ParseString(request, "admin_port", "") + clusterDomain := mcp.ParseString(request, "cluster_domain", "") + controlPort := mcp.ParseString(request, "control_port", "") + controllerGID := mcp.ParseString(request, "controller_gid", "") + controllerLogLevel := mcp.ParseString(request, "controller_log_level", "") + controllerReplicas := mcp.ParseString(request, "controller_replicas", "") + controllerUID := mcp.ParseString(request, "controller_uid", "") + defaultInboundPolicy := mcp.ParseString(request, "default_inbound_policy", "") + imagePullPolicy := mcp.ParseString(request, "image_pull_policy", "") + inboundPort := mcp.ParseString(request, "inbound_port", "") + initImage := mcp.ParseString(request, "init_image", "") + initImageVersion := mcp.ParseString(request, "init_image_version", "") + outboundPort := mcp.ParseString(request, "outbound_port", "") + outputFormat := mcp.ParseString(request, "output", "") + proxyCPULimit := mcp.ParseString(request, "proxy_cpu_limit", "") + proxyCPURequest := mcp.ParseString(request, "proxy_cpu_request", "") + proxyGID := mcp.ParseString(request, "proxy_gid", "") + proxyImage := mcp.ParseString(request, "proxy_image", "") + proxyLogLevel := mcp.ParseString(request, "proxy_log_level", "") + proxyMemoryLimit := mcp.ParseString(request, "proxy_memory_limit", "") + proxyMemoryRequest := mcp.ParseString(request, "proxy_memory_request", "") + proxyUID := mcp.ParseString(request, "proxy_uid", "") + registry := mcp.ParseString(request, "registry", "") + skipInboundPorts := mcp.ParseString(request, "skip_inbound_ports", "") + skipOutboundPorts := mcp.ParseString(request, "skip_outbound_ports", "") + disableH2Upgrade := mcp.ParseString(request, "disable_h2_upgrade", "") + disableHeartbeat := mcp.ParseString(request, "disable_heartbeat", "") + enableEndpointSlices := mcp.ParseString(request, "enable_endpoint_slices", "") + enableExternalProfiles := mcp.ParseString(request, "enable_external_profiles", "") + identityExternalCA := mcp.ParseString(request, "identity_external_ca", "") + identityExternalIssuer := mcp.ParseString(request, "identity_external_issuer", "") + ignoreCluster := mcp.ParseString(request, "ignore_cluster", "") + linkerdCNIEnabled := mcp.ParseString(request, "linkerd_cni_enabled", "") args := []string{"install"} @@ -164,11 +360,67 @@ func handleLinkerdInstall(ctx context.Context, request mcp.CallToolRequest) (*mc args = append(args, "--skip-checks") } + boolFlags := []struct { + flag string + value string + }{ + {"--disable-h2-upgrade", disableH2Upgrade}, + {"--disable-heartbeat", disableHeartbeat}, + {"--enable-endpoint-slices", enableEndpointSlices}, + {"--enable-external-profiles", enableExternalProfiles}, + {"--identity-external-ca", identityExternalCA}, + {"--identity-external-issuer", identityExternalIssuer}, + {"--ignore-cluster", ignoreCluster}, + {"--linkerd-cni-enabled", linkerdCNIEnabled}, + } + + for _, flag := range boolFlags { + var err error + args, err = appendBoolFlag(args, flag.flag, flag.value) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + } + + args = appendFlagArg(args, "--admin-port", adminPort) + args = appendFlagArg(args, "--cluster-domain", clusterDomain) + args = appendFlagArg(args, "--control-port", controlPort) + args = appendFlagArg(args, "--controller-gid", controllerGID) + args = appendFlagArg(args, "--controller-log-level", controllerLogLevel) + args = appendFlagArg(args, "--controller-replicas", controllerReplicas) + args = appendFlagArg(args, "--controller-uid", controllerUID) + args = appendFlagArg(args, "--default-inbound-policy", defaultInboundPolicy) + args = appendFlagArg(args, "--identity-clock-skew-allowance", identityClockSkewAllowance) + args = appendFlagArg(args, "--identity-issuance-lifetime", identityIssuanceLifetime) + args = appendFlagArg(args, "--identity-issuer-certificate-file", identityIssuerCertificateFile) + args = appendFlagArg(args, "--identity-issuer-key-file", identityIssuerKeyFile) + args = appendFlagArg(args, "--identity-trust-anchors-file", identityTrustAnchorsFile) if identityTrustAnchors != "" { args = append(args, "--identity-trust-anchors-pem", identityTrustAnchors) } + args = appendFlagArg(args, "--identity-trust-domain", identityTrustDomain) + args = appendFlagArg(args, "--image-pull-policy", imagePullPolicy) + args = appendFlagArg(args, "--inbound-port", inboundPort) + args = appendFlagArg(args, "--init-image", initImage) + args = appendFlagArg(args, "--init-image-version", initImageVersion) + args = appendFlagArg(args, "--outbound-port", outboundPort) + args = appendFlagArg(args, "--proxy-cpu-limit", proxyCPULimit) + args = appendFlagArg(args, "--proxy-cpu-request", proxyCPURequest) + args = appendFlagArg(args, "--proxy-gid", proxyGID) + args = appendFlagArg(args, "--proxy-image", proxyImage) + args = appendFlagArg(args, "--proxy-log-level", proxyLogLevel) + args = appendFlagArg(args, "--proxy-memory-limit", proxyMemoryLimit) + args = appendFlagArg(args, "--proxy-memory-request", proxyMemoryRequest) + args = appendFlagArg(args, "--proxy-uid", proxyUID) + args = appendFlagArg(args, "--registry", registry) + args = appendFlagArg(args, "--skip-inbound-ports", skipInboundPorts) + args = appendFlagArg(args, "--skip-outbound-ports", skipOutboundPorts) + args = appendFlagArg(args, "-o", outputFormat) args = appendSetOverrides(args, setOverrides) + args = appendCSVArgs(args, "--set-string", setStringOverrides) + args = appendCSVArgs(args, "--set-file", setFileOverrides) + args = appendCSVArgs(args, "-f", valuesFiles) crdArgs := append([]string{}, args...) crdArgs = append(crdArgs, "--crds") @@ -226,60 +478,38 @@ func handleLinkerdInstall(ctx context.Context, request mcp.CallToolRequest) (*mc } -// Linkerd workload injection patch -func handleLinkerdWorkloadInjection(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - workloadName := mcp.ParseString(request, "workload_name", "") - if workloadName == "" { - return mcp.NewToolResultError("workload_name parameter is required"), nil - } - - if err := security.ValidateK8sResourceName(workloadName); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("invalid workload name: %v", err)), nil - } - - namespace := mcp.ParseString(request, "namespace", "default") - if err := security.ValidateNamespace(namespace); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("invalid namespace: %v", err)), nil - } - - workloadType := strings.ToLower(mcp.ParseString(request, "workload_type", "deployment")) - switch workloadType { - case "deployment", "statefulset", "daemonset": - default: - return mcp.NewToolResultError("workload_type must be deployment, statefulset, or daemonset"), nil - } - - removeAnnotation := mcp.ParseString(request, "remove_annotation", "") == "true" - injectState := strings.ToLower(mcp.ParseString(request, "inject_state", "disabled")) - if !removeAnnotation { - switch injectState { - case "enabled", "disabled", "ingress": - default: - return mcp.NewToolResultError("inject_state must be enabled, disabled, or ingress"), nil - } - } - - args := []string{"patch", workloadType, workloadName, "-n", namespace} - var patch string - operation := fmt.Sprintf("kubectl patch %s %s for linkerd injection", workloadType, workloadName) - if removeAnnotation { - patch = fmt.Sprintf(`[{"op":"remove","path":"%s"}]`, linkerdInjectionAnnotationJSONPath) - args = append(args, "--type=json", "-p", patch) - operation = fmt.Sprintf("kubectl remove linkerd injection annotation from %s %s", workloadType, workloadName) - } else { - patch = fmt.Sprintf(`{"spec":{"template":{"metadata":{"annotations":{"%s":"%s"}}}}}`, linkerdInjectionAnnotationKey, injectState) - args = append(args, "-p", patch) - operation = fmt.Sprintf("kubectl set linkerd injection=%s on %s %s", injectState, workloadType, workloadName) - } - - result, err := runKubectlCommand(ctx, args) - return formatLinkerdCommandResult(operation, result, err) -} - -// Linkerd install CNI +// Output Kubernetes configs to install Linkerd CNI. +// +// This command provides all Kubernetes configs necessary to install the Linkerd +// CNI. +// +// Usage: +// +// linkerd install-cni [flags] func handleLinkerdInstallCNI(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { skipChecks := mcp.ParseString(request, "skip_checks", "") == "true" setOverrides := mcp.ParseString(request, "set_overrides", "") + setStringOverrides := mcp.ParseString(request, "set_string_overrides", "") + setFileOverrides := mcp.ParseString(request, "set_file_overrides", "") + valuesFiles := mcp.ParseString(request, "values", "") + adminPort := mcp.ParseString(request, "admin_port", "") + cniImage := mcp.ParseString(request, "cni_image", "") + cniImageVersion := mcp.ParseString(request, "cni_image_version", "") + cniLogLevel := mcp.ParseString(request, "cni_log_level", "") + controlPort := mcp.ParseString(request, "control_port", "") + destCNIBinDir := mcp.ParseString(request, "dest_cni_bin_dir", "") + destCNINetDir := mcp.ParseString(request, "dest_cni_net_dir", "") + inboundPort := mcp.ParseString(request, "inbound_port", "") + linkerdVersion := mcp.ParseString(request, "linkerd_version", "") + outboundPort := mcp.ParseString(request, "outbound_port", "") + priorityClassName := mcp.ParseString(request, "priority_class_name", "") + proxyGID := mcp.ParseString(request, "proxy_gid", "") + proxyUID := mcp.ParseString(request, "proxy_uid", "") + redirectPorts := mcp.ParseString(request, "redirect_ports", "") + registry := mcp.ParseString(request, "registry", "") + skipInboundPorts := mcp.ParseString(request, "skip_inbound_ports", "") + skipOutboundPorts := mcp.ParseString(request, "skip_outbound_ports", "") + useWaitFlag := mcp.ParseString(request, "use_wait_flag", "") == "true" args := []string{"install-cni"} @@ -287,7 +517,32 @@ func handleLinkerdInstallCNI(ctx context.Context, request mcp.CallToolRequest) ( args = append(args, "--skip-checks") } + if useWaitFlag { + args = append(args, "--use-wait-flag") + } + + args = appendFlagArg(args, "--admin-port", adminPort) + args = appendFlagArg(args, "--cni-image", cniImage) + args = appendFlagArg(args, "--cni-image-version", cniImageVersion) + args = appendFlagArg(args, "--cni-log-level", cniLogLevel) + args = appendFlagArg(args, "--control-port", controlPort) + args = appendFlagArg(args, "--dest-cni-bin-dir", destCNIBinDir) + args = appendFlagArg(args, "--dest-cni-net-dir", destCNINetDir) + args = appendFlagArg(args, "--inbound-port", inboundPort) + args = appendFlagArg(args, "--linkerd-version", linkerdVersion) + args = appendFlagArg(args, "--outbound-port", outboundPort) + args = appendFlagArg(args, "--priority-class-name", priorityClassName) + args = appendFlagArg(args, "--proxy-gid", proxyGID) + args = appendFlagArg(args, "--proxy-uid", proxyUID) + args = appendFlagArg(args, "--redirect-ports", redirectPorts) + args = appendFlagArg(args, "--registry", registry) + args = appendFlagArg(args, "--skip-inbound-ports", skipInboundPorts) + args = appendFlagArg(args, "--skip-outbound-ports", skipOutboundPorts) + args = appendSetOverrides(args, setOverrides) + args = appendCSVArgs(args, "--set-string", setStringOverrides) + args = appendCSVArgs(args, "--set-file", setFileOverrides) + args = appendCSVArgs(args, "-f", valuesFiles) manifest, err := runLinkerdManifestCommand(ctx, args) if err != nil { @@ -299,7 +554,29 @@ func handleLinkerdInstallCNI(ctx context.Context, request mcp.CallToolRequest) ( } -// Linkerd upgrade +// Output Kubernetes configs to upgrade an existing Linkerd control plane. +// +// Note that the default flag values for this command come from the Linkerd control +// plane. The default values displayed in the Flags section below only apply to the +// install command. +// +// The upgrade can be configured by using the --set, --values, --set-string and --set-file flags. +// A full list of configurable values can be found at https://www.github.com/linkerd/linkerd2/tree/main/charts/linkerd2/README.md +// +// Usage: +// +// linkerd upgrade [flags] +// +// Examples: +// +// # Upgrade CRDs first +// linkerd upgrade --crds | kubectl apply -f - +// +// # Then upgrade the control plane +// linkerd upgrade | kubectl apply -f - +// +// # And lastly, remove linkerd resources that no longer exist in the current version +// linkerd prune | kubectl delete -f - func handleLinkerdUpgrade(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { ha := mcp.ParseString(request, "ha", "") == "true" crdsOnly := mcp.ParseString(request, "crds_only", "") == "true" @@ -353,9 +630,21 @@ func handleLinkerdUninstall(ctx context.Context, request mcp.CallToolRequest) (* } // Linkerd version +// Print the client and server version information +// +// Usage: +// +// linkerd version [flags] func handleLinkerdVersion(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { clientOnly := mcp.ParseString(request, "client_only", "") == "true" short := mcp.ParseString(request, "short", "") == "true" + proxyVersions := mcp.ParseString(request, "proxy", "") == "true" + namespace := strings.TrimSpace(mcp.ParseString(request, "namespace", "")) + if namespace != "" { + if err := security.ValidateNamespace(namespace); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("invalid namespace: %v", err)), nil + } + } args := []string{"version"} @@ -363,6 +652,14 @@ func handleLinkerdVersion(ctx context.Context, request mcp.CallToolRequest) (*mc args = append(args, "--client") } + if proxyVersions { + args = append(args, "--proxy") + } + + if namespace != "" { + args = append(args, "-n", namespace) + } + if short { args = append(args, "--short") } @@ -372,7 +669,11 @@ func handleLinkerdVersion(ctx context.Context, request mcp.CallToolRequest) (*mc } -// Linkerd authz +// List authorizations for a resource. +// +// Usage: +// +// linkerd authz [flags] resource func handleLinkerdAuthz(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { resource := mcp.ParseString(request, "resource", "") if resource == "" { @@ -394,184 +695,240 @@ func handleLinkerdAuthz(ctx context.Context, request mcp.CallToolRequest) (*mcp. } -// Linkerd stat -func handleLinkerdStat(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - resource := mcp.ParseString(request, "resource", "") - if resource == "" { - return mcp.NewToolResultError("resource parameter is required"), nil +// Add the Linkerd proxy to a Kubernetes config. +// +// You can inject resources contained in a single file, inside a folder and its +// sub-folders, or coming from stdin. +// +// Usage: +// +// linkerd inject [flags] CONFIG-FILE +// +// Examples: +// +// # Inject all the deployments in the default namespace. +// kubectl get deploy -o yaml | linkerd inject - | kubectl apply -f - +// +// # Injecting a file from a remote URL +// linkerd inject https://url.to/yml | kubectl apply -f - +// +// # Inject all the resources inside a folder and its sub-folders. +// linkerd inject | kubectl apply -f - +func handleLinkerdWorkloadInjection(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + workloadName := mcp.ParseString(request, "workload_name", "") + if workloadName == "" { + return mcp.NewToolResultError("workload_name parameter is required"), nil } - namespace := mcp.ParseString(request, "namespace", "") - allNamespaces := mcp.ParseString(request, "all_namespaces", "") == "true" - from := mcp.ParseString(request, "from", "") - to := mcp.ParseString(request, "to", "") - timeWindow := mcp.ParseString(request, "time_window", "") - output := mcp.ParseString(request, "output", "") + if err := security.ValidateK8sResourceName(workloadName); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("invalid workload name: %v", err)), nil + } - args := []string{"stat", resource} + namespaceInput := mcp.ParseString(request, "namespace", "default") - if allNamespaces { - args = append(args, "-A") - } else if namespace != "" { - args = append(args, "-n", namespace) + workloadType := strings.ToLower(mcp.ParseString(request, "workload_type", "deployment")) + config, ok := linkerdWorkloadTypes[workloadType] + if !ok { + return mcp.NewToolResultError(fmt.Sprintf("workload_type must be one of: %s", strings.Join(supportedLinkerdWorkloadTypes(), ", "))), nil } - if from != "" { - args = append(args, "--from", from) + var namespace string + if config.namespaced { + if namespaceInput == "" { + namespaceInput = "default" + } + if err := security.ValidateNamespace(namespaceInput); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("invalid namespace: %v", err)), nil + } + namespace = namespaceInput } - if to != "" { - args = append(args, "--to", to) + removeAnnotation := mcp.ParseString(request, "remove_annotation", "") == "true" + injectState := strings.ToLower(mcp.ParseString(request, "inject_state", "disabled")) + if !removeAnnotation { + switch injectState { + case "enabled", "disabled", "ingress": + default: + return mcp.NewToolResultError("inject_state must be enabled, disabled, or ingress"), nil + } } - if timeWindow != "" { - args = append(args, "--time-window", timeWindow) + args := []string{"patch", workloadType, workloadName} + if config.namespaced { + args = append(args, "-n", namespace) } - - if output != "" { - args = append(args, "-o", output) + var patch string + operation := fmt.Sprintf("kubectl patch %s %s for linkerd injection", workloadType, workloadName) + if removeAnnotation { + patch = buildAnnotationRemovePatch(config.annotationsPath, linkerdInjectionAnnotationKey) + args = append(args, "--type=json", "-p", patch) + operation = fmt.Sprintf("kubectl remove linkerd injection annotation from %s %s", workloadType, workloadName) + } else { + var err error + patch, err = buildAnnotationMergePatch(config.annotationsPath, linkerdInjectionAnnotationKey, injectState) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to build patch: %v", err)), nil + } + args = append(args, "-p", patch) + operation = fmt.Sprintf("kubectl set linkerd injection=%s on %s %s", injectState, workloadType, workloadName) } - result, err := runLinkerdCommand(ctx, args) - return formatLinkerdCommandResult("linkerd stat", result, err) - + result, err := runKubectlCommand(ctx, args) + return formatLinkerdCommandResult(operation, result, err) } -// Linkerd top -func handleLinkerdTop(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - resource := mcp.ParseString(request, "resource", "") - if resource == "" { - return mcp.NewToolResultError("resource parameter is required"), nil +// Commands used to manage Linkerd policy. +// +// This command provides subcommands to manage Linkerd policy. +// +// Usage: +// +// linkerd policy [command] +// +// Examples: +// +// # Generate policy for existing meshed workloads +// linkerd policy generate +// +// Available Commands: +// +// generate Generate policy based on current traffic (beta) +func handleLinkerdPolicy(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + command := strings.TrimSpace(strings.ToLower(mcp.ParseString(request, "command", ""))) + if command == "" { + command = "generate" + } + + switch command { + case "generate": + result, err := runLinkerdCommand(ctx, nil) + return formatLinkerdCommandResult("linkerd policy generate", result, err) + default: + return mcp.NewToolResultError(fmt.Sprintf("unsupported linkerd policy command: %s", command)), nil } +} - namespace := mcp.ParseString(request, "namespace", "") - from := mcp.ParseString(request, "from", "") - to := mcp.ParseString(request, "to", "") - maxRows := mcp.ParseString(request, "max_results", "") - timeWindow := mcp.ParseString(request, "time_window", "") - - args := []string{"top", resource} - +// Output service profile config for Kubernetes. +// +// Usage: +// +// linkerd profile [flags] (--template | --open-api file | --proto file) (SERVICE) +// +// Examples: +// +// # Output a basic template to apply after modification. +// linkerd profile -n emojivoto --template web-svc +// +// # Generate a profile from an OpenAPI specification. +// linkerd profile -n emojivoto --open-api web-svc.swagger web-svc +// +// # Generate a profile from a protobuf definition. +// linkerd profile -n emojivoto --proto Voting.proto vote-svc +func handleLinkerdProfile(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + serviceName := strings.TrimSpace(mcp.ParseString(request, "service_name", "")) + if serviceName == "" { + return mcp.NewToolResultError("service_name parameter is required"), nil + } + + if err := security.ValidateK8sResourceName(serviceName); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("invalid service name: %v", err)), nil + } + + namespace := strings.TrimSpace(mcp.ParseString(request, "namespace", "")) if namespace != "" { - args = append(args, "-n", namespace) - } - - if from != "" { - args = append(args, "--from", from) - } - - if to != "" { - args = append(args, "--to", to) + if err := security.ValidateNamespace(namespace); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("invalid namespace: %v", err)), nil + } } - if maxRows != "" { - args = append(args, "--max", maxRows) - } + ignoreCluster := mcp.ParseString(request, "ignore_cluster", "") == "true" + templateOutput := mcp.ParseString(request, "template", "") == "true" + openAPIFile := strings.TrimSpace(mcp.ParseString(request, "open_api", "")) + protoFile := strings.TrimSpace(mcp.ParseString(request, "proto", "")) + outputFormat := strings.TrimSpace(mcp.ParseString(request, "output", "")) - if timeWindow != "" { - args = append(args, "--time-window", timeWindow) + modeCount := 0 + if templateOutput { + modeCount++ } - - result, err := runLinkerdCommand(ctx, args) - return formatLinkerdCommandResult("linkerd top", result, err) - -} - -// Linkerd edges -func handleLinkerdEdges(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - resource := mcp.ParseString(request, "resource", "") - if resource == "" { - return mcp.NewToolResultError("resource parameter is required"), nil + if openAPIFile != "" { + modeCount++ } - - namespace := mcp.ParseString(request, "namespace", "") - allNamespaces := mcp.ParseString(request, "all_namespaces", "") == "true" - output := mcp.ParseString(request, "output", "") - - args := []string{"edges", resource} - - if allNamespaces { - args = append(args, "-A") - } else if namespace != "" { - args = append(args, "-n", namespace) + if protoFile != "" { + modeCount++ } - - if output != "" { - args = append(args, "-o", output) + if modeCount == 0 { + return mcp.NewToolResultError("one of template, open_api, or proto must be provided"), nil } - - result, err := runLinkerdCommand(ctx, args) - return formatLinkerdCommandResult("linkerd edges", result, err) - -} - -// Linkerd routes -func handleLinkerdRoutes(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - resource := mcp.ParseString(request, "resource", "") - if resource == "" { - return mcp.NewToolResultError("resource parameter is required"), nil + if modeCount > 1 { + return mcp.NewToolResultError("template, open_api, and proto options are mutually exclusive"), nil } - namespace := mcp.ParseString(request, "namespace", "") - output := mcp.ParseString(request, "output", "") - from := mcp.ParseString(request, "from", "") - to := mcp.ParseString(request, "to", "") - - args := []string{"routes", resource} - + args := []string{"profile"} if namespace != "" { args = append(args, "-n", namespace) } - - if from != "" { - args = append(args, "--from", from) + if ignoreCluster { + args = append(args, "--ignore-cluster") } - - if to != "" { - args = append(args, "--to", to) + if templateOutput { + args = append(args, "--template") + } else if openAPIFile != "" { + args = append(args, "--open-api", openAPIFile) + } else if protoFile != "" { + args = append(args, "--proto", protoFile) } - - if output != "" { - args = append(args, "-o", output) + if outputFormat != "" { + args = append(args, "-o", outputFormat) } + args = append(args, serviceName) result, err := runLinkerdCommand(ctx, args) - return formatLinkerdCommandResult("linkerd routes", result, err) - + return formatLinkerdCommandResult("linkerd profile", result, err) } -// Linkerd diagnostics proxy metrics -func handleLinkerdDiagnosticsProxyMetrics(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +// Commands used to manage FIPS-enabled installations of Linkerd. +// +// This command provides subcommands to manage FIPS-enabled installations of +// Linkerd. +// +// Usage: +// +// linkerd fips [command] +// +// Examples: +// +// # Audit all Linkerd proxies for FIPS modules +// linkerd fips audit +func handleLinkerdFips(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { namespace := mcp.ParseString(request, "namespace", "") - allNamespaces := mcp.ParseString(request, "all_namespaces", "") == "true" - selector := mcp.ParseString(request, "selector", "") - resource := mcp.ParseString(request, "resource", "") - args := []string{"diagnostics", "proxy-metrics"} + args := []string{"fips", "audit"} - if allNamespaces { - args = append(args, "-A") - } else if namespace != "" { + if namespace != "" { args = append(args, "-n", namespace) } - if selector != "" { - args = append(args, "--selector", selector) - } - - if resource != "" { - args = append(args, resource) - } - result, err := runLinkerdCommand(ctx, args) - return formatLinkerdCommandResult("linkerd diagnostics proxy-metrics", result, err) + return formatLinkerdCommandResult("linkerd fips audit", result, err) } -// Linkerd diagnostics controller metrics +// ================================= +// Linkerd Diagnostics +// ================================= + +// Fetch metrics directly from Linkerd control plane containers. +// +// This command initiates port-forward to each control plane process, and +// queries the /metrics endpoint on them. +// +// Usage: +// +// linkerd diagnostics controller-metrics [flags] func handleLinkerdDiagnosticsControllerMetrics(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { namespace := mcp.ParseString(request, "namespace", "") - component := mcp.ParseString(request, "component", "") + waitDuration := mcp.ParseString(request, "wait", "") args := []string{"diagnostics", "controller-metrics"} @@ -579,270 +936,218 @@ func handleLinkerdDiagnosticsControllerMetrics(ctx context.Context, request mcp. args = append(args, "-n", namespace) } - if component != "" { - args = append(args, "--component", component) + if waitDuration != "" { + args = append(args, "--wait", waitDuration) } result, err := runLinkerdCommand(ctx, args) return formatLinkerdCommandResult("linkerd diagnostics controller-metrics", result, err) - } -// Linkerd diagnostics endpoints -func handleLinkerdDiagnosticsEndpoints(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +// Introspect Linkerd's service discovery state. +// +// This command provides debug information about the internal state of the +// control-plane's destination controller. It queries the same Destination service +// endpoint as the linkerd-proxy's, and returns the profile associated with that +// destination. +// +// Usage: +// +// linkerd diagnostics profile [flags] address +// +// Examples: +// +// # Get the service profile for the service or endpoint at 10.20.2.4:8080 +// linkerd diagnostics profile 10.20.2.4:8080 +func handleLinkerdDiagnosticsProfile(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { authority := mcp.ParseString(request, "authority", "") if authority == "" { return mcp.NewToolResultError("authority parameter is required"), nil } namespace := mcp.ParseString(request, "namespace", "") + destinationPod := mcp.ParseString(request, "destination_pod", "") + token := mcp.ParseString(request, "token", "") - args := []string{"diagnostics", "endpoints"} + args := []string{"diagnostics", "profile"} if namespace != "" { args = append(args, "-n", namespace) } - args = append(args, authority) - - result, err := runLinkerdCommand(ctx, args) - return formatLinkerdCommandResult("linkerd diagnostics endpoints", result, err) - -} - -// Linkerd diagnostics policy -func handleLinkerdDiagnosticsPolicy(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - authority := mcp.ParseString(request, "authority", "") - if authority == "" { - return mcp.NewToolResultError("authority parameter is required"), nil + if destinationPod != "" { + args = append(args, "--destination-pod", destinationPod) } - namespace := mcp.ParseString(request, "namespace", "") - - args := []string{"diagnostics", "policy"} - - if namespace != "" { - args = append(args, "-n", namespace) + if token != "" { + args = append(args, "--token", token) } args = append(args, authority) result, err := runLinkerdCommand(ctx, args) - return formatLinkerdCommandResult("linkerd diagnostics policy", result, err) + return formatLinkerdCommandResult("linkerd diagnostics profile", result, err) } -// Linkerd diagnostics profile -func handleLinkerdDiagnosticsProfile(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - authority := mcp.ParseString(request, "authority", "") - if authority == "" { - return mcp.NewToolResultError("authority parameter is required"), nil +// Fetch metrics directly from Linkerd proxies. +// +// This command initiates a port-forward to a given pod or set of pods, and +// queries the /metrics endpoint on the Linkerd proxies. +// +// Examples: +// +// # Get metrics from pod-foo-bar in the default namespace. +// linkerd diagnostics proxy-metrics po/pod-foo-bar +// +// # Get metrics from the web deployment in the emojivoto namespace. +// linkerd diagnostics proxy-metrics -n emojivoto deploy/web +func handleLinkerdDiagnosticsProxyMetrics(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + resource := strings.TrimSpace(mcp.ParseString(request, "resource", "")) + if resource == "" { + return mcp.NewToolResultError("resource parameter is required"), nil } namespace := mcp.ParseString(request, "namespace", "") + obfuscate := mcp.ParseString(request, "obfuscate", "") == "true" - args := []string{"diagnostics", "profile"} + args := []string{"diagnostics", "proxy-metrics"} if namespace != "" { args = append(args, "-n", namespace) } - args = append(args, authority) - - result, err := runLinkerdCommand(ctx, args) - return formatLinkerdCommandResult("linkerd diagnostics profile", result, err) - -} - -// Linkerd viz install -func handleLinkerdVizInstall(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - ha := mcp.ParseString(request, "ha", "") == "true" - skipChecks := mcp.ParseString(request, "skip_checks", "") == "true" - setOverrides := mcp.ParseString(request, "set_overrides", "") - - args := []string{"viz", "install"} - - if ha { - args = append(args, "--ha") - } - - if skipChecks { - args = append(args, "--skip-checks") - } - - args = appendSetOverrides(args, setOverrides) - - manifest, err := runLinkerdManifestCommand(ctx, args) - if err != nil { - return formatLinkerdCommandResult("linkerd viz install", manifest, err) - } - - applyResult, applyErr := applyManifest(ctx, manifest) - return formatLinkerdCommandResult("kubectl apply linkerd viz install manifest", applyResult, applyErr) - -} - -// Linkerd viz uninstall -func handleLinkerdVizUninstall(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - force := mcp.ParseString(request, "force", "") == "true" - - args := []string{"viz", "uninstall"} - - if force { - args = append(args, "--force") - } - - manifest, err := runLinkerdManifestCommand(ctx, args) - if err != nil { - return formatLinkerdCommandResult("linkerd viz uninstall", manifest, err) + if obfuscate { + args = append(args, "--obfuscate") } - deleteResult, deleteErr := deleteManifest(ctx, manifest) - return formatLinkerdCommandResult("kubectl delete linkerd viz uninstall manifest", deleteResult, deleteErr) + args = append(args, resource) + result, err := runLinkerdCommand(ctx, args) + return formatLinkerdCommandResult("linkerd diagnostics proxy-metrics", result, err) } -// Linkerd viz top -func handleLinkerdVizTop(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - resource := mcp.ParseString(request, "resource", "") - if resource == "" { - return mcp.NewToolResultError("resource parameter is required"), nil +// Introspect Linkerd's service discovery state. +// +// This command provides debug information about the internal state of the +// control-plane's destination container. It queries the same Destination service +// endpoint as the linkerd-proxy's, and returns the addresses associated with that +// destination. +// +// Usage: +// +// linkerd diagnostics endpoints [flags] authorities +// +// Examples: +// +// # get all endpoints for the authorities emoji-svc.emojivoto.svc.cluster.local:8080 and web-svc.emojivoto.svc.cluster.local:80 +// linkerd diagnostics endpoints emoji-svc.emojivoto.svc.cluster.local:8080 web-svc.emojivoto.svc.cluster.local:80 +// +// # get that same information in json format +// linkerd diagnostics endpoints -o json emoji-svc.emojivoto.svc.cluster.local:8080 web-svc.emojivoto.svc.cluster.local:80 +// +// # get the endpoints for authorities in Linkerd's control-plane itself +// linkerd diagnostics endpoints web.linkerd-viz.svc.cluster.local:8084 +func handleLinkerdDiagnosticsEndpoints(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + authority := mcp.ParseString(request, "authority", "") + if authority == "" { + return mcp.NewToolResultError("authority parameter is required"), nil } namespace := mcp.ParseString(request, "namespace", "") - from := mcp.ParseString(request, "from", "") - to := mcp.ParseString(request, "to", "") - maxRows := mcp.ParseString(request, "max_results", "") - timeWindow := mcp.ParseString(request, "time_window", "") + destinationPod := mcp.ParseString(request, "destination_pod", "") + outputFormat := mcp.ParseString(request, "output", "") + token := mcp.ParseString(request, "token", "") - args := []string{"viz", "top", resource} + args := []string{"diagnostics", "endpoints"} if namespace != "" { args = append(args, "-n", namespace) } - if from != "" { - args = append(args, "--from", from) + if destinationPod != "" { + args = append(args, "--destination-pod", destinationPod) } - if to != "" { - args = append(args, "--to", to) + if outputFormat != "" { + args = append(args, "-o", outputFormat) } - if maxRows != "" { - args = append(args, "--max", maxRows) + if token != "" { + args = append(args, "--token", token) } - if timeWindow != "" { - args = append(args, "--time-window", timeWindow) - } + args = append(args, authority) result, err := runLinkerdCommand(ctx, args) - return formatLinkerdCommandResult("linkerd viz top", result, err) + return formatLinkerdCommandResult("linkerd diagnostics endpoints", result, err) } -// Linkerd viz stat -func handleLinkerdVizStat(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - resource := mcp.ParseString(request, "resource", "") +// Introspect Linkerd's policy state. +// +// This command provides debug information about the internal state of the +// control-plane's policy controller. It queries the same control-plane +// endpoint as the linkerd-proxy's, and returns the policies associated with the +// given resource. If the resource is a Pod, inbound policy for that Pod is +// displayed. If the resource is a Service, outbound policy for that Service is +// displayed. +// +// Usage: +// +// linkerd diagnostics policy [flags] resource port +// +// Examples: +// +// # get the inbound policy for pod emoji-6d66d87995-bvrnn on port 8080 +// linkerd diagnostics policy -n emojivoto po/emoji-6d66d87995-bvrnn 8080 +// +// # get the outbound policy for Service emoji-svc on port 8080 +// linkerd diagnostics policy -n emojivoto svc/emoji-svc 8080 +func handleLinkerdDiagnosticsPolicy(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + resource := strings.TrimSpace(mcp.ParseString(request, "resource", "")) if resource == "" { return mcp.NewToolResultError("resource parameter is required"), nil } - namespace := mcp.ParseString(request, "namespace", "") - allNamespaces := mcp.ParseString(request, "all_namespaces", "") == "true" - from := mcp.ParseString(request, "from", "") - to := mcp.ParseString(request, "to", "") - timeWindow := mcp.ParseString(request, "time_window", "") - output := mcp.ParseString(request, "output", "") - - args := []string{"viz", "stat", resource} - - if allNamespaces { - args = append(args, "-A") - } else if namespace != "" { - args = append(args, "-n", namespace) - } - - if from != "" { - args = append(args, "--from", from) - } - - if to != "" { - args = append(args, "--to", to) - } - - if timeWindow != "" { - args = append(args, "--time-window", timeWindow) + port := strings.TrimSpace(mcp.ParseString(request, "port", "")) + if port == "" { + return mcp.NewToolResultError("port parameter is required"), nil } - if output != "" { - args = append(args, "-o", output) - } - - result, err := runLinkerdCommand(ctx, args) - return formatLinkerdCommandResult("linkerd viz stat", result, err) - -} - -// Linkerd FIPS audit -func handleLinkerdFipsAudit(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { namespace := mcp.ParseString(request, "namespace", "") + destinationPod := mcp.ParseString(request, "destination_pod", "") + outputFormat := mcp.ParseString(request, "output", "") + token := mcp.ParseString(request, "token", "") - args := []string{"fips", "audit"} + args := []string{"diagnostics", "policy"} if namespace != "" { args = append(args, "-n", namespace) } - result, err := runLinkerdCommand(ctx, args) - return formatLinkerdCommandResult("linkerd fips audit", result, err) - -} - -// Linkerd policy generate -func handleLinkerdPolicyGenerate(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - namespace := mcp.ParseString(request, "namespace", "") - output := mcp.ParseString(request, "output", "") - timeout := mcp.ParseString(request, "timeout", "") - - args := []string{"policy", "generate"} - - if namespace != "" { - args = append(args, "-n", namespace) + if destinationPod != "" { + args = append(args, "--destination-pod", destinationPod) } - if output != "" { - args = append(args, "-o", output) + if outputFormat != "" { + args = append(args, "-o", outputFormat) } - if timeout != "" { - args = append(args, "--timeout", timeout) + if token != "" { + args = append(args, "--token", token) } - result, err := runLinkerdCommand(ctx, args) - return formatLinkerdCommandResult("linkerd policy generate", result, err) - -} - -func appendSetOverrides(args []string, overrides string) []string { - if overrides == "" { - return args - } + args = append(args, resource, port) - pairs := strings.Split(overrides, ",") - for _, pair := range pairs { - trimmed := strings.TrimSpace(pair) - if trimmed == "" { - continue - } - args = append(args, "--set", trimmed) - } + result, err := runLinkerdCommand(ctx, args) + return formatLinkerdCommandResult("linkerd diagnostics policy", result, err) - return args } +// ================================= // Register Linkerd tools +// ================================= func RegisterTools(s *server.MCPServer) { s.AddTool(mcp.NewTool("linkerd_check", mcp.WithDescription("Run pre-flight or data plane checks for the Linkerd control plane"), @@ -858,15 +1163,57 @@ func RegisterTools(s *server.MCPServer) { mcp.WithString("ha", mcp.Description("Set to true to deploy high availability components")), mcp.WithString("crds_only", mcp.Description("Set to true to output only CRDs")), mcp.WithString("skip_checks", mcp.Description("Skip Kubernetes and environment checks")), - mcp.WithString("identity_trust_anchors_pem", mcp.Description("PEM encoded trust anchors to use")), - mcp.WithString("set_overrides", mcp.Description("Comma-separated Helm style key=value overrides")), + mcp.WithString("identity_trust_anchors_pem", mcp.Description("PEM encoded trust anchors for --identity-trust-anchors-pem")), + mcp.WithString("identity_trust_anchors_file", mcp.Description("Path to trust anchors file (--identity-trust-anchors-file)")), + mcp.WithString("identity_clock_skew_allowance", mcp.Description("Duration for --identity-clock-skew-allowance, e.g. 20s")), + mcp.WithString("identity_issuance_lifetime", mcp.Description("Certificate lifetime for --identity-issuance-lifetime")), + mcp.WithString("identity_issuer_certificate_file", mcp.Description("Path for --identity-issuer-certificate-file")), + mcp.WithString("identity_issuer_key_file", mcp.Description("Path for --identity-issuer-key-file")), + mcp.WithString("identity_trust_domain", mcp.Description("Value for --identity-trust-domain")), + mcp.WithString("identity_external_ca", mcp.Description("true/false to toggle --identity-external-ca")), + mcp.WithString("identity_external_issuer", mcp.Description("true/false to toggle --identity-external-issuer")), + mcp.WithString("ignore_cluster", mcp.Description("true/false to toggle --ignore-cluster")), + mcp.WithString("admin_port", mcp.Description("Proxy metrics port (--admin-port)")), + mcp.WithString("cluster_domain", mcp.Description("Custom cluster domain (--cluster-domain)")), + mcp.WithString("control_port", mcp.Description("Proxy control port (--control-port)")), + mcp.WithString("controller_gid", mcp.Description("Run control plane under this GID (--controller-gid)")), + mcp.WithString("controller_log_level", mcp.Description("Log level for controller/web (--controller-log-level)")), + mcp.WithString("controller_replicas", mcp.Description("Number of controller replicas (--controller-replicas)")), + mcp.WithString("controller_uid", mcp.Description("Run control plane under this UID (--controller-uid)")), + mcp.WithString("default_inbound_policy", mcp.Description("Default inbound policy (--default-inbound-policy)")), + mcp.WithString("disable_h2_upgrade", mcp.Description("true/false for --disable-h2-upgrade")), + mcp.WithString("disable_heartbeat", mcp.Description("true/false for --disable-heartbeat")), + mcp.WithString("enable_endpoint_slices", mcp.Description("true/false for --enable-endpoint-slices")), + mcp.WithString("enable_external_profiles", mcp.Description("true/false for --enable-external-profiles")), + mcp.WithString("image_pull_policy", mcp.Description("Docker image pull policy (--image-pull-policy)")), + mcp.WithString("inbound_port", mcp.Description("Proxy inbound port (--inbound-port)")), + mcp.WithString("init_image", mcp.Description("Init container image (--init-image)")), + mcp.WithString("init_image_version", mcp.Description("Init container image version (--init-image-version)")), + mcp.WithString("linkerd_cni_enabled", mcp.Description("true/false to toggle --linkerd-cni-enabled")), + mcp.WithString("outbound_port", mcp.Description("Proxy outbound port (--outbound-port)")), + mcp.WithString("output", mcp.Description("Output format for manifests (-o/--output)")), + mcp.WithString("proxy_cpu_limit", mcp.Description("Proxy CPU limit (--proxy-cpu-limit)")), + mcp.WithString("proxy_cpu_request", mcp.Description("Proxy CPU request (--proxy-cpu-request)")), + mcp.WithString("proxy_gid", mcp.Description("Proxy GID (--proxy-gid)")), + mcp.WithString("proxy_image", mcp.Description("Proxy image (--proxy-image)")), + mcp.WithString("proxy_log_level", mcp.Description("Proxy log level (--proxy-log-level)")), + mcp.WithString("proxy_memory_limit", mcp.Description("Proxy memory limit (--proxy-memory-limit)")), + mcp.WithString("proxy_memory_request", mcp.Description("Proxy memory request (--proxy-memory-request)")), + mcp.WithString("proxy_uid", mcp.Description("Proxy UID (--proxy-uid)")), + mcp.WithString("registry", mcp.Description("Image registry (--registry)")), + mcp.WithString("skip_inbound_ports", mcp.Description("Ports to skip inbound proxying (--skip-inbound-ports)")), + mcp.WithString("skip_outbound_ports", mcp.Description("Ports to skip outbound proxying (--skip-outbound-ports)")), + mcp.WithString("set_overrides", mcp.Description("Comma-separated Helm style key=value overrides for --set")), + mcp.WithString("set_string_overrides", mcp.Description("Comma-separated key=value overrides for --set-string")), + mcp.WithString("set_file_overrides", mcp.Description("Comma-separated key=path overrides for --set-file")), + mcp.WithString("values", mcp.Description("Comma-separated list of values files/URLs for -f/--values")), ), telemetry.AdaptToolHandler(telemetry.WithTracing("linkerd_install", handleLinkerdInstall))) s.AddTool(mcp.NewTool("linkerd_patch_workload_injection", mcp.WithDescription("Enable, disable, or remove Linkerd proxy injection annotations on a workload's pod template"), mcp.WithString("workload_name", mcp.Description("Name of the workload (e.g. simple-app)"), mcp.Required()), mcp.WithString("namespace", mcp.Description("Namespace containing the workload (default: default)")), - mcp.WithString("workload_type", mcp.Description("Workload type: deployment, statefulset, or daemonset (default: deployment)")), + mcp.WithString("workload_type", mcp.Description("Workload type: namespace, deployment, statefulset, daemonset, job, cronjob, pod, replicaset, or replicationcontroller (default: deployment)")), mcp.WithString("inject_state", mcp.Description("Annotation value to set (enabled, disabled, ingress); ignored if remove_annotation is true")), mcp.WithString("remove_annotation", mcp.Description("Set to true to remove the annotation instead of setting it")), ), telemetry.AdaptToolHandler(telemetry.WithTracing("linkerd_patch_workload_injection", handleLinkerdWorkloadInjection))) @@ -874,7 +1221,28 @@ func RegisterTools(s *server.MCPServer) { s.AddTool(mcp.NewTool("linkerd_install_cni", mcp.WithDescription("Install the Linkerd CNI components"), mcp.WithString("skip_checks", mcp.Description("Skip Kubernetes and environment checks")), - mcp.WithString("set_overrides", mcp.Description("Comma-separated Helm style key=value overrides")), + mcp.WithString("admin_port", mcp.Description("Proxy metrics port for the CNI proxy (maps to --admin-port)")), + mcp.WithString("cni_image", mcp.Description("Override the CNI plugin image (--cni-image)")), + mcp.WithString("cni_image_version", mcp.Description("Override the CNI plugin image tag (--cni-image-version)")), + mcp.WithString("cni_log_level", mcp.Description("Set the CNI plugin log level (--cni-log-level)")), + mcp.WithString("control_port", mcp.Description("Proxy control port (--control-port)")), + mcp.WithString("dest_cni_bin_dir", mcp.Description("Host directory to place the CNI binary (--dest-cni-bin-dir)")), + mcp.WithString("dest_cni_net_dir", mcp.Description("Host directory to place the CNI config (--dest-cni-net-dir)")), + mcp.WithString("inbound_port", mcp.Description("Proxy inbound port (--inbound-port)")), + mcp.WithString("linkerd_version", mcp.Description("Linkerd image tag to use (--linkerd-version)")), + mcp.WithString("outbound_port", mcp.Description("Proxy outbound port (--outbound-port)")), + mcp.WithString("priority_class_name", mcp.Description("PriorityClass name for the DaemonSet (--priority-class-name)")), + mcp.WithString("proxy_gid", mcp.Description("Run the proxy under this GID (--proxy-gid)")), + mcp.WithString("proxy_uid", mcp.Description("Run the proxy under this UID (--proxy-uid)")), + mcp.WithString("redirect_ports", mcp.Description("Ports to redirect to the proxy, comma-separated (--redirect-ports)")), + mcp.WithString("registry", mcp.Description("Image registry for the plugin (--registry)")), + mcp.WithString("set_overrides", mcp.Description("Comma-separated Helm style key=value overrides for --set")), + mcp.WithString("set_string_overrides", mcp.Description("Comma-separated key=value pairs for --set-string")), + mcp.WithString("set_file_overrides", mcp.Description("Comma-separated key=path pairs for --set-file")), + mcp.WithString("skip_inbound_ports", mcp.Description("Ports that should bypass the proxy (--skip-inbound-ports)")), + mcp.WithString("skip_outbound_ports", mcp.Description("Outbound ports that should bypass the proxy (--skip-outbound-ports)")), + mcp.WithString("use_wait_flag", mcp.Description("Set to true to enable --use-wait-flag")), + mcp.WithString("values", mcp.Description("Comma-separated list of values files or URLs for -f/--values")), ), telemetry.AdaptToolHandler(telemetry.WithTracing("linkerd_install_cni", handleLinkerdInstallCNI))) s.AddTool(mcp.NewTool("linkerd_upgrade", @@ -893,6 +1261,8 @@ func RegisterTools(s *server.MCPServer) { s.AddTool(mcp.NewTool("linkerd_version", mcp.WithDescription("Get Linkerd client and server versions"), mcp.WithString("client_only", mcp.Description("Set to true to print only client version")), + mcp.WithString("proxy", mcp.Description("Set to true to print data-plane proxy versions (--proxy)")), + mcp.WithString("namespace", mcp.Description("Namespace scope for proxy versions (-n/--namespace)")), mcp.WithString("short", mcp.Description("Set to true for short version output")), ), telemetry.AdaptToolHandler(telemetry.WithTracing("linkerd_version", handleLinkerdVersion))) @@ -902,118 +1272,65 @@ func RegisterTools(s *server.MCPServer) { mcp.WithString("namespace", mcp.Description("Namespace containing the resource")), ), telemetry.AdaptToolHandler(telemetry.WithTracing("linkerd_authz", handleLinkerdAuthz))) - s.AddTool(mcp.NewTool("linkerd_stat", - mcp.WithDescription("Get resource metrics using linkerd stat"), - mcp.WithString("resource", mcp.Description("Kubernetes resource to inspect (e.g. deploy/web)"), mcp.Required()), - mcp.WithString("namespace", mcp.Description("Namespace for the resource")), - mcp.WithString("all_namespaces", mcp.Description("Set to true to inspect every namespace")), - mcp.WithString("from", mcp.Description("Restrict metrics to traffic from the specified resource")), - mcp.WithString("to", mcp.Description("Restrict metrics to traffic to the specified resource")), - mcp.WithString("time_window", mcp.Description("Time window for metrics, e.g. 1m")), - mcp.WithString("output", mcp.Description("Output format (table, json)")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("linkerd_stat", handleLinkerdStat))) - - s.AddTool(mcp.NewTool("linkerd_top", - mcp.WithDescription("Inspect live traffic using linkerd top"), - mcp.WithString("resource", mcp.Description("Resource to observe (e.g. deploy/web)"), mcp.Required()), - mcp.WithString("namespace", mcp.Description("Namespace for the resource")), - mcp.WithString("from", mcp.Description("Limit traffic to requests originating from this resource")), - mcp.WithString("to", mcp.Description("Limit traffic to requests destined to this resource")), - mcp.WithString("max_results", mcp.Description("Maximum number of rows to display")), - mcp.WithString("time_window", mcp.Description("Time window for sampling, e.g. 30s")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("linkerd_top", handleLinkerdTop))) - - s.AddTool(mcp.NewTool("linkerd_edges", - mcp.WithDescription("Describe allowed and denied edges between resources"), - mcp.WithString("resource", mcp.Description("Resource to inspect (e.g. deploy/web)"), mcp.Required()), - mcp.WithString("namespace", mcp.Description("Namespace for the resource")), - mcp.WithString("all_namespaces", mcp.Description("Set to true to inspect every namespace")), - mcp.WithString("output", mcp.Description("Output format (table, wide, json)")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("linkerd_edges", handleLinkerdEdges))) - - s.AddTool(mcp.NewTool("linkerd_routes", - mcp.WithDescription("Describe HTTP routes for resources"), - mcp.WithString("resource", mcp.Description("Resource to inspect (e.g. deploy/web)"), mcp.Required()), - mcp.WithString("namespace", mcp.Description("Namespace for the resource")), - mcp.WithString("from", mcp.Description("Filter by traffic originating from this resource")), - mcp.WithString("to", mcp.Description("Filter by traffic destined to this resource")), - mcp.WithString("output", mcp.Description("Output format (table, json)")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("linkerd_routes", handleLinkerdRoutes))) + s.AddTool(mcp.NewTool("linkerd_policy", + mcp.WithDescription("Manage Linkerd policy operations like generate"), + mcp.WithString("command", mcp.Description("Policy subcommand to execute (default: generate)")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("linkerd_policy", handleLinkerdPolicy))) + + s.AddTool(mcp.NewTool("linkerd_profile", + mcp.WithDescription("Generate a service profile template or manifest"), + mcp.WithString("service_name", mcp.Description("Service to profile (e.g. web-svc)"), mcp.Required()), + mcp.WithString("namespace", mcp.Description("Namespace containing the service")), + mcp.WithString("template", mcp.Description("Set to true to output a template (--template)")), + mcp.WithString("open_api", mcp.Description("Path to an OpenAPI spec file (--open-api)")), + mcp.WithString("proto", mcp.Description("Path to a protobuf definition file (--proto)")), + mcp.WithString("ignore_cluster", mcp.Description("Set to true to enable --ignore-cluster")), + mcp.WithString("output", mcp.Description("Output format (yaml or json)")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("linkerd_profile", handleLinkerdProfile))) + + s.AddTool(mcp.NewTool("linkerd_fips_audit", + mcp.WithDescription("Audit Linkerd proxies for FIPS compliance"), + mcp.WithString("namespace", mcp.Description("Namespace containing Linkerd components to audit")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("linkerd_fips_audit", handleLinkerdFips))) s.AddTool(mcp.NewTool("linkerd_diagnostics_proxy_metrics", mcp.WithDescription("Collect raw proxy metrics for Linkerd workloads"), - mcp.WithString("namespace", mcp.Description("Namespace to inspect")), - mcp.WithString("all_namespaces", mcp.Description("Set to true to inspect every namespace")), - mcp.WithString("selector", mcp.Description("Label selector to target specific pods")), - mcp.WithString("resource", mcp.Description("Specific resource to query, e.g. deploy/web")), + mcp.WithString("resource", mcp.Description("Specific resource to query, e.g. deploy/web"), mcp.Required()), + mcp.WithString("namespace", mcp.Description("Namespace containing the resource (defaults to current)")), + mcp.WithString("obfuscate", mcp.Description("Set to true to obfuscate sensitive metric labels")), ), telemetry.AdaptToolHandler(telemetry.WithTracing("linkerd_diagnostics_proxy_metrics", handleLinkerdDiagnosticsProxyMetrics))) s.AddTool(mcp.NewTool("linkerd_diagnostics_controller_metrics", mcp.WithDescription("Fetch metrics directly from Linkerd control-plane components"), mcp.WithString("namespace", mcp.Description("Namespace containing the control-plane pods")), mcp.WithString("component", mcp.Description("Specific control-plane component name")), + mcp.WithString("wait", mcp.Description("Time allowed to fetch diagnostics, e.g. 30s")), ), telemetry.AdaptToolHandler(telemetry.WithTracing("linkerd_diagnostics_controller_metrics", handleLinkerdDiagnosticsControllerMetrics))) s.AddTool(mcp.NewTool("linkerd_diagnostics_endpoints", mcp.WithDescription("Inspect Linkerd's service discovery endpoints for an authority"), mcp.WithString("authority", mcp.Description("Authority host:port to inspect"), mcp.Required()), mcp.WithString("namespace", mcp.Description("Namespace context for the query")), + mcp.WithString("destination_pod", mcp.Description("Specific destination pod to query")), + mcp.WithString("output", mcp.Description(`Output format ("table" or "json")`)), + mcp.WithString("token", mcp.Description("Context token for destination API requests")), ), telemetry.AdaptToolHandler(telemetry.WithTracing("linkerd_diagnostics_endpoints", handleLinkerdDiagnosticsEndpoints))) s.AddTool(mcp.NewTool("linkerd_diagnostics_policy", - mcp.WithDescription("Inspect Linkerd's policy state for an authority"), - mcp.WithString("authority", mcp.Description("Authority host:port to inspect"), mcp.Required()), + mcp.WithDescription("Inspect Linkerd's policy state for a resource"), + mcp.WithString("resource", mcp.Description("Target resource (e.g. po/emoji-123, svc/web)"), mcp.Required()), + mcp.WithString("port", mcp.Description("Port number to inspect"), mcp.Required()), mcp.WithString("namespace", mcp.Description("Namespace context for the query")), + mcp.WithString("destination_pod", mcp.Description("Specific destination pod to query")), + mcp.WithString("output", mcp.Description(`Output format ("yaml" or "json")`)), + mcp.WithString("token", mcp.Description("Token used when querying the policy service")), ), telemetry.AdaptToolHandler(telemetry.WithTracing("linkerd_diagnostics_policy", handleLinkerdDiagnosticsPolicy))) s.AddTool(mcp.NewTool("linkerd_diagnostics_profile", mcp.WithDescription("Inspect Linkerd's service discovery profile for an authority"), mcp.WithString("authority", mcp.Description("Authority host:port to inspect"), mcp.Required()), mcp.WithString("namespace", mcp.Description("Namespace context for the query")), + mcp.WithString("destination_pod", mcp.Description("Specific destination pod to query")), + mcp.WithString("token", mcp.Description("Context token for destination API requests")), ), telemetry.AdaptToolHandler(telemetry.WithTracing("linkerd_diagnostics_profile", handleLinkerdDiagnosticsProfile))) - - s.AddTool(mcp.NewTool("linkerd_viz_install", - mcp.WithDescription("Install the Linkerd viz extension components"), - mcp.WithString("ha", mcp.Description("Set to true to deploy high availability viz components")), - mcp.WithString("skip_checks", mcp.Description("Skip Kubernetes and environment checks")), - mcp.WithString("set_overrides", mcp.Description("Comma-separated Helm style key=value overrides")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("linkerd_viz_install", handleLinkerdVizInstall))) - - s.AddTool(mcp.NewTool("linkerd_viz_uninstall", - mcp.WithDescription("Remove the Linkerd viz extension from the cluster"), - mcp.WithString("force", mcp.Description("Set to true to skip confirmation prompts")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("linkerd_viz_uninstall", handleLinkerdVizUninstall))) - - s.AddTool(mcp.NewTool("linkerd_viz_top", - mcp.WithDescription("Inspect live traffic for viz-injected workloads"), - mcp.WithString("resource", mcp.Description("Resource to observe"), mcp.Required()), - mcp.WithString("namespace", mcp.Description("Namespace for the resource")), - mcp.WithString("from", mcp.Description("Limit traffic to requests originating from this resource")), - mcp.WithString("to", mcp.Description("Limit traffic to requests destined to this resource")), - mcp.WithString("max_results", mcp.Description("Maximum number of rows to display")), - mcp.WithString("time_window", mcp.Description("Time window for sampling, e.g. 30s")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("linkerd_viz_top", handleLinkerdVizTop))) - - s.AddTool(mcp.NewTool("linkerd_viz_stat", - mcp.WithDescription("Get viz metrics using linkerd viz stat"), - mcp.WithString("resource", mcp.Description("Resource to inspect (e.g. deploy/web)"), mcp.Required()), - mcp.WithString("namespace", mcp.Description("Namespace for the resource")), - mcp.WithString("all_namespaces", mcp.Description("Set to true to inspect every namespace")), - mcp.WithString("from", mcp.Description("Restrict metrics to traffic from the specified resource")), - mcp.WithString("to", mcp.Description("Restrict metrics to traffic to the specified resource")), - mcp.WithString("time_window", mcp.Description("Time window for metrics, e.g. 1m")), - mcp.WithString("output", mcp.Description("Output format (table, json)")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("linkerd_viz_stat", handleLinkerdVizStat))) - - s.AddTool(mcp.NewTool("linkerd_fips_audit", - mcp.WithDescription("Audit Linkerd proxies for FIPS compliance"), - mcp.WithString("namespace", mcp.Description("Namespace scope for the audit")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("linkerd_fips_audit", handleLinkerdFipsAudit))) - - s.AddTool(mcp.NewTool("linkerd_policy_generate", - mcp.WithDescription("Generate Linkerd policy manifests for existing workloads"), - mcp.WithString("namespace", mcp.Description("Namespace containing workload manifests")), - mcp.WithString("output", mcp.Description("Output format, e.g. yaml or json")), - mcp.WithString("timeout", mcp.Description("Command timeout, e.g. 30s")), - ), telemetry.AdaptToolHandler(telemetry.WithTracing("linkerd_policy_generate", handleLinkerdPolicyGenerate))) } diff --git a/pkg/linkerd/linkerd_test.go b/pkg/linkerd/linkerd_test.go index 1913989..73cd2cb 100644 --- a/pkg/linkerd/linkerd_test.go +++ b/pkg/linkerd/linkerd_test.go @@ -87,6 +87,58 @@ func TestHandleLinkerdInstall(t *testing.T) { require.NoError(t, err) assert.False(t, result.IsError) }) + + t.Run("install with advanced flags", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + expectedArgs := []string{"install", + "--disable-h2-upgrade", + "--enable-endpoint-slices=false", + "--admin-port", "5555", + "--controller-log-level", "debug", + "--default-inbound-policy", "cluster-authenticated", + "--identity-trust-anchors-file", "/tmp/anchors", + "--proxy-cpu-limit", "500m", + "--registry", "registry.example.com/linkerd", + "-o", "json", + "--set-string", "global.proxy.logLevel=debug", + "-f", "overrides1.yaml", + "-f", "overrides2.yaml", + "--crds", + } + mock.AddCommandString("linkerd", expectedArgs, "manifest", nil) + mock.AddPartialMatcherString("kubectl", []string{"apply", "-f"}, "applied", nil) + ctx = cmd.WithShellExecutor(ctx, mock) + + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]interface{}{ + "crds_only": "true", + "disable_h2_upgrade": "true", + "enable_endpoint_slices": "false", + "admin_port": "5555", + "controller_log_level": "debug", + "default_inbound_policy": "cluster-authenticated", + "identity_trust_anchors_file": "/tmp/anchors", + "proxy_cpu_limit": "500m", + "registry": "registry.example.com/linkerd", + "output": "json", + "set_string_overrides": "global.proxy.logLevel=debug", + "values": "overrides1.yaml,overrides2.yaml", + } + + result, err := handleLinkerdInstall(ctx, request) + require.NoError(t, err) + assert.False(t, result.IsError) + }) + + t.Run("invalid boolean flag", func(t *testing.T) { + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]interface{}{ + "disable_h2_upgrade": "maybe", + } + result, err := handleLinkerdInstall(ctx, request) + require.NoError(t, err) + assert.True(t, result.IsError) + }) } func TestHandleLinkerdWorkloadInjection(t *testing.T) { @@ -211,154 +263,86 @@ func TestHandleLinkerdUninstall(t *testing.T) { func TestHandleLinkerdVersion(t *testing.T) { ctx := context.Background() - mock := cmd.NewMockShellExecutor() - mock.AddCommandString("linkerd", []string{"version", "--client", "--short"}, "version", nil) - ctx = cmd.WithShellExecutor(ctx, mock) - - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "client_only": "true", - "short": "true", - } - - result, err := handleLinkerdVersion(ctx, request) - require.NoError(t, err) - assert.False(t, result.IsError) -} - -func TestHandleLinkerdAuthz(t *testing.T) { - ctx := context.Background() - - t.Run("missing resource", func(t *testing.T) { - result, err := handleLinkerdAuthz(ctx, mcp.CallToolRequest{}) - require.NoError(t, err) - assert.True(t, result.IsError) - }) - - t.Run("authz resource", func(t *testing.T) { + t.Run("client short", func(t *testing.T) { mock := cmd.NewMockShellExecutor() - mock.AddCommandString("linkerd", []string{"authz", "-n", "default", "deploy/web"}, "authz", nil) + mock.AddCommandString("linkerd", []string{"version", "--client", "--short"}, "version", nil) ctx = cmd.WithShellExecutor(ctx, mock) request := mcp.CallToolRequest{} request.Params.Arguments = map[string]interface{}{ - "resource": "deploy/web", - "namespace": "default", + "client_only": "true", + "short": "true", } - result, err := handleLinkerdAuthz(ctx, request) + result, err := handleLinkerdVersion(ctx, request) require.NoError(t, err) assert.False(t, result.IsError) }) -} - -func TestHandleLinkerdStat(t *testing.T) { - ctx := context.Background() - - t.Run("missing resource", func(t *testing.T) { - result, err := handleLinkerdStat(ctx, mcp.CallToolRequest{}) - require.NoError(t, err) - assert.True(t, result.IsError) - }) - t.Run("stat specific namespace", func(t *testing.T) { + t.Run("proxy namespace", func(t *testing.T) { mock := cmd.NewMockShellExecutor() - mock.AddCommandString("linkerd", []string{"stat", "deploy/web", "-n", "default", "--from", "deploy/api", "--time-window", "1m", "-o", "json"}, "stats", nil) + mock.AddCommandString("linkerd", []string{"version", "--proxy", "-n", "linkerd"}, "version", nil) ctx = cmd.WithShellExecutor(ctx, mock) request := mcp.CallToolRequest{} request.Params.Arguments = map[string]interface{}{ - "resource": "deploy/web", - "namespace": "default", - "from": "deploy/api", - "time_window": "1m", - "output": "json", + "proxy": "true", + "namespace": "linkerd", } - result, err := handleLinkerdStat(ctx, request) + result, err := handleLinkerdVersion(ctx, request) require.NoError(t, err) assert.False(t, result.IsError) }) -} - -func TestHandleLinkerdTop(t *testing.T) { - ctx := context.Background() - - t.Run("missing resource", func(t *testing.T) { - result, err := handleLinkerdTop(ctx, mcp.CallToolRequest{}) - require.NoError(t, err) - assert.True(t, result.IsError) - }) - t.Run("top specific namespace", func(t *testing.T) { + t.Run("explicit false client flag", func(t *testing.T) { mock := cmd.NewMockShellExecutor() - mock.AddCommandString("linkerd", []string{"top", "deploy/web", "-n", "default", "--max", "10", "--time-window", "30s"}, "top", nil) + mock.AddCommandString("linkerd", []string{"version", "--client=false"}, "version", nil) ctx = cmd.WithShellExecutor(ctx, mock) request := mcp.CallToolRequest{} request.Params.Arguments = map[string]interface{}{ - "resource": "deploy/web", - "namespace": "default", - "max_results": "10", - "time_window": "30s", + "client_only": "false", } - result, err := handleLinkerdTop(ctx, request) + result, err := handleLinkerdVersion(ctx, request) require.NoError(t, err) assert.False(t, result.IsError) }) -} - -func TestHandleLinkerdEdges(t *testing.T) { - ctx := context.Background() - - t.Run("missing resource", func(t *testing.T) { - result, err := handleLinkerdEdges(ctx, mcp.CallToolRequest{}) - require.NoError(t, err) - assert.True(t, result.IsError) - }) - - t.Run("edges all namespaces", func(t *testing.T) { - mock := cmd.NewMockShellExecutor() - mock.AddCommandString("linkerd", []string{"edges", "deploy/web", "-A", "-o", "wide"}, "edges", nil) - ctx = cmd.WithShellExecutor(ctx, mock) + t.Run("invalid bool flag", func(t *testing.T) { request := mcp.CallToolRequest{} request.Params.Arguments = map[string]interface{}{ - "resource": "deploy/web", - "all_namespaces": "true", - "output": "wide", + "short": "maybe", } - result, err := handleLinkerdEdges(ctx, request) + result, err := handleLinkerdVersion(ctx, request) require.NoError(t, err) - assert.False(t, result.IsError) + assert.True(t, result.IsError) }) } -func TestHandleLinkerdRoutes(t *testing.T) { +func TestHandleLinkerdAuthz(t *testing.T) { ctx := context.Background() t.Run("missing resource", func(t *testing.T) { - result, err := handleLinkerdRoutes(ctx, mcp.CallToolRequest{}) + result, err := handleLinkerdAuthz(ctx, mcp.CallToolRequest{}) require.NoError(t, err) assert.True(t, result.IsError) }) - t.Run("routes with filters", func(t *testing.T) { + t.Run("authz resource", func(t *testing.T) { mock := cmd.NewMockShellExecutor() - mock.AddCommandString("linkerd", []string{"routes", "deploy/web", "-n", "default", "--from", "deploy/api", "--to", "svc/backend"}, "routes", nil) + mock.AddCommandString("linkerd", []string{"authz", "-n", "default", "deploy/web"}, "authz", nil) ctx = cmd.WithShellExecutor(ctx, mock) request := mcp.CallToolRequest{} request.Params.Arguments = map[string]interface{}{ "resource": "deploy/web", "namespace": "default", - "from": "deploy/api", - "to": "svc/backend", } - result, err := handleLinkerdRoutes(ctx, request) + result, err := handleLinkerdAuthz(ctx, request) require.NoError(t, err) assert.False(t, result.IsError) }) @@ -367,34 +351,42 @@ func TestHandleLinkerdRoutes(t *testing.T) { func TestHandleLinkerdDiagnosticsProxyMetrics(t *testing.T) { ctx := context.Background() - t.Run("basic selector", func(t *testing.T) { + t.Run("missing resource", func(t *testing.T) { + result, err := handleLinkerdDiagnosticsProxyMetrics(ctx, mcp.CallToolRequest{}) + require.NoError(t, err) + assert.True(t, result.IsError) + }) + + t.Run("with namespace and obfuscate", func(t *testing.T) { + mockCtx := context.Background() mock := cmd.NewMockShellExecutor() - mock.AddCommandString("linkerd", []string{"diagnostics", "proxy-metrics", "-A", "--selector", "app=web"}, "metrics", nil) - ctx = cmd.WithShellExecutor(ctx, mock) + mock.AddCommandString("linkerd", []string{"diagnostics", "proxy-metrics", "-n", "emojivoto", "--obfuscate", "deploy/web"}, "metrics", nil) + mockCtx = cmd.WithShellExecutor(mockCtx, mock) request := mcp.CallToolRequest{} request.Params.Arguments = map[string]interface{}{ - "all_namespaces": "true", - "selector": "app=web", + "resource": "deploy/web", + "namespace": "emojivoto", + "obfuscate": "true", } - result, err := handleLinkerdDiagnosticsProxyMetrics(ctx, request) + result, err := handleLinkerdDiagnosticsProxyMetrics(mockCtx, request) require.NoError(t, err) assert.False(t, result.IsError) }) - t.Run("with namespace and resource", func(t *testing.T) { + t.Run("without namespace", func(t *testing.T) { + mockCtx := context.Background() mock := cmd.NewMockShellExecutor() - mock.AddCommandString("linkerd", []string{"diagnostics", "proxy-metrics", "-n", "emojivoto", "deploy/web"}, "metrics", nil) - ctx = cmd.WithShellExecutor(ctx, mock) + mock.AddCommandString("linkerd", []string{"diagnostics", "proxy-metrics", "po/pod-foo"}, "metrics", nil) + mockCtx = cmd.WithShellExecutor(mockCtx, mock) request := mcp.CallToolRequest{} request.Params.Arguments = map[string]interface{}{ - "namespace": "emojivoto", - "resource": "deploy/web", + "resource": "po/pod-foo", } - result, err := handleLinkerdDiagnosticsProxyMetrics(ctx, request) + result, err := handleLinkerdDiagnosticsProxyMetrics(mockCtx, request) require.NoError(t, err) assert.False(t, result.IsError) }) @@ -404,13 +396,14 @@ func TestHandleLinkerdDiagnosticsControllerMetrics(t *testing.T) { ctx := context.Background() mock := cmd.NewMockShellExecutor() - mock.AddCommandString("linkerd", []string{"diagnostics", "controller-metrics", "-n", "linkerd", "--component", "controller"}, "metrics", nil) + mock.AddCommandString("linkerd", []string{"diagnostics", "controller-metrics", "-n", "linkerd", "--component", "controller", "--wait", "45s"}, "metrics", nil) ctx = cmd.WithShellExecutor(ctx, mock) request := mcp.CallToolRequest{} request.Params.Arguments = map[string]interface{}{ "namespace": "linkerd", "component": "controller", + "wait": "45s", } result, err := handleLinkerdDiagnosticsControllerMetrics(ctx, request) @@ -478,130 +471,50 @@ func TestHandleLinkerdDiagnosticsProfile(t *testing.T) { assert.False(t, result.IsError) } -func TestHandleLinkerdVizInstall(t *testing.T) { +func TestHandleLinkerdFips(t *testing.T) { ctx := context.Background() mock := cmd.NewMockShellExecutor() - mock.AddCommandString("linkerd", []string{"viz", "install", "--ha", "--skip-checks", "--set", "tap.resources.limits.cpu=200m"}, "manifest", nil) - mock.AddPartialMatcherString("kubectl", []string{"apply", "-f"}, "applied", nil) - ctx = cmd.WithShellExecutor(ctx, mock) - - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "ha": "true", - "skip_checks": "true", - "set_overrides": "tap.resources.limits.cpu=200m", - } - - result, err := handleLinkerdVizInstall(ctx, request) - require.NoError(t, err) - assert.False(t, result.IsError) -} - -func TestHandleLinkerdVizUninstall(t *testing.T) { - ctx := context.Background() - - mock := cmd.NewMockShellExecutor() - mock.AddCommandString("linkerd", []string{"viz", "uninstall", "--force"}, "removed", nil) - mock.AddPartialMatcherString("kubectl", []string{"delete", "-f"}, "deleted", nil) + mock.AddCommandString("linkerd", []string{"fips", "audit", "-n", "default"}, "audit", nil) ctx = cmd.WithShellExecutor(ctx, mock) request := mcp.CallToolRequest{} request.Params.Arguments = map[string]interface{}{ - "force": "true", + "namespace": "default", } - result, err := handleLinkerdVizUninstall(ctx, request) + result, err := handleLinkerdFips(ctx, request) require.NoError(t, err) assert.False(t, result.IsError) } -func TestHandleLinkerdVizTop(t *testing.T) { - ctx := context.Background() - - t.Run("missing resource", func(t *testing.T) { - result, err := handleLinkerdVizTop(ctx, mcp.CallToolRequest{}) - require.NoError(t, err) - assert.True(t, result.IsError) - }) +func TestHandleLinkerdPolicy(t *testing.T) { + t.Run("defaults to generate", func(t *testing.T) { + ctx := context.Background() - t.Run("viz top resource", func(t *testing.T) { mock := cmd.NewMockShellExecutor() - mock.AddCommandString("linkerd", []string{"viz", "top", "deploy/web", "-n", "default", "--max", "5"}, "top", nil) + mock.AddCommandString("linkerd", []string{"policy", "generate", "-n", "ns"}, "policy", nil) ctx = cmd.WithShellExecutor(ctx, mock) request := mcp.CallToolRequest{} request.Params.Arguments = map[string]interface{}{ - "resource": "deploy/web", - "namespace": "default", - "max_results": "5", + "namespace": "ns", } - result, err := handleLinkerdVizTop(ctx, request) + result, err := handleLinkerdPolicy(ctx, request) require.NoError(t, err) assert.False(t, result.IsError) }) -} - -func TestHandleLinkerdVizStat(t *testing.T) { - ctx := context.Background() - - t.Run("missing resource", func(t *testing.T) { - result, err := handleLinkerdVizStat(ctx, mcp.CallToolRequest{}) - require.NoError(t, err) - assert.True(t, result.IsError) - }) - - t.Run("viz stat all namespaces", func(t *testing.T) { - mock := cmd.NewMockShellExecutor() - mock.AddCommandString("linkerd", []string{"viz", "stat", "deploy/web", "-A", "--time-window", "30s"}, "stats", nil) - ctx = cmd.WithShellExecutor(ctx, mock) + t.Run("unsupported command", func(t *testing.T) { + ctx := context.Background() request := mcp.CallToolRequest{} request.Params.Arguments = map[string]interface{}{ - "resource": "deploy/web", - "all_namespaces": "true", - "time_window": "30s", + "command": "unknown", } - result, err := handleLinkerdVizStat(ctx, request) + result, err := handleLinkerdPolicy(ctx, request) require.NoError(t, err) - assert.False(t, result.IsError) + assert.True(t, result.IsError) }) } - -func TestHandleLinkerdFipsAudit(t *testing.T) { - ctx := context.Background() - - mock := cmd.NewMockShellExecutor() - mock.AddCommandString("linkerd", []string{"fips", "audit", "-n", "default"}, "audit", nil) - ctx = cmd.WithShellExecutor(ctx, mock) - - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "namespace": "default", - } - - result, err := handleLinkerdFipsAudit(ctx, request) - require.NoError(t, err) - assert.False(t, result.IsError) -} - -func TestHandleLinkerdPolicyGenerate(t *testing.T) { - ctx := context.Background() - - mock := cmd.NewMockShellExecutor() - mock.AddCommandString("linkerd", []string{"policy", "generate", "-n", "default", "-o", "yaml", "--timeout", "30s"}, "policy", nil) - ctx = cmd.WithShellExecutor(ctx, mock) - - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "namespace": "default", - "output": "yaml", - "timeout": "30s", - } - - result, err := handleLinkerdPolicyGenerate(ctx, request) - require.NoError(t, err) - assert.False(t, result.IsError) -} From 9f5ee5d258dcdfa54fc51037bbf193c56059e2f9 Mon Sep 17 00:00:00 2001 From: Ivan Porta Date: Thu, 4 Dec 2025 23:19:28 +0900 Subject: [PATCH 4/7] Update linkerd Signed-off-by: Ivan Porta --- pkg/linkerd/linkerd.go | 73 +++++++++++++++++++++- pkg/linkerd/linkerd_test.go | 117 +++++++++++++++++++++++++++++++++--- 2 files changed, 180 insertions(+), 10 deletions(-) diff --git a/pkg/linkerd/linkerd.go b/pkg/linkerd/linkerd.go index 9cca93d..10821c2 100644 --- a/pkg/linkerd/linkerd.go +++ b/pkg/linkerd/linkerd.go @@ -1,16 +1,20 @@ package linkerd import ( + "bytes" "context" "encoding/json" goerrors "errors" "fmt" "os" + "os/exec" "sort" "strings" + "time" "github.com/kagent-dev/tools/internal/commands" toolerrors "github.com/kagent-dev/tools/internal/errors" + "github.com/kagent-dev/tools/internal/logger" "github.com/kagent-dev/tools/internal/security" "github.com/kagent-dev/tools/internal/telemetry" "github.com/kagent-dev/tools/pkg/utils" @@ -43,6 +47,45 @@ var linkerdWorkloadTypes = map[string]linkerdWorkloadTypeConfig{ "pod": {annotationsPath: []string{"metadata", "annotations"}, namespaced: true}, } +type manifestCommandExecutor interface { + Run(ctx context.Context, command string, args []string) (stdout string, stderr string, err error) +} + +var linkerdManifestExecutor manifestCommandExecutor = &execManifestCommandExecutor{} + +type execManifestCommandExecutor struct{} + +func (e *execManifestCommandExecutor) Run(ctx context.Context, command string, args []string) (string, string, error) { + log := logger.WithContext(ctx) + start := time.Now() + cmd := exec.CommandContext(ctx, command, args...) + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + duration := time.Since(start) + if err != nil { + log.Error("linkerd manifest command failed", + "command", command, + "args", args, + "error", err, + "stderr", strings.TrimSpace(stderr.String()), + "duration", duration.String(), + ) + } else { + log.Info("linkerd manifest command executed", + "command", command, + "args", args, + "stderr_length", stderr.Len(), + "duration", duration.String(), + ) + } + + return stdout.String(), stderr.String(), err +} + // ================================= // Helpers functions // ================================= @@ -98,7 +141,35 @@ func runLinkerdCommand(ctx context.Context, args []string) (string, error) { } func runLinkerdManifestCommand(ctx context.Context, args []string) (string, error) { - return executeLinkerdCommand(ctx, args) + kubeconfigPath := utils.GetKubeconfig() + builder := commands.NewCommandBuilder("linkerd"). + WithArgs(args...). + WithKubeconfig(kubeconfigPath) + + command, builtArgs, err := builder.Build() + if err != nil { + return "", err + } + + stdout, stderr, execErr := linkerdManifestExecutor.Run(ctx, command, builtArgs) + if execErr != nil { + trimmed := strings.TrimSpace(stderr) + combinedErr := execErr + if trimmed != "" { + combinedErr = fmt.Errorf("%w: %s", execErr, trimmed) + } + return stdout, toolerrors.NewCommandError(command, combinedErr) + } + + if trimmed := strings.TrimSpace(stderr); trimmed != "" { + logger.WithContext(ctx).Warn("linkerd manifest command produced stderr output", + "command", command, + "args", builtArgs, + "stderr", trimmed, + ) + } + + return stdout, nil } func executeLinkerdCommand(ctx context.Context, args []string) (string, error) { diff --git a/pkg/linkerd/linkerd_test.go b/pkg/linkerd/linkerd_test.go index 73cd2cb..32b387a 100644 --- a/pkg/linkerd/linkerd_test.go +++ b/pkg/linkerd/linkerd_test.go @@ -11,6 +11,42 @@ import ( "github.com/stretchr/testify/require" ) +type manifestExecCall struct { + args []string + stdout string + stderr string + err error +} + +type mockManifestExecutor struct { + t *testing.T + calls []manifestExecCall + idx int +} + +func newMockManifestExecutor(t *testing.T, calls []manifestExecCall) *mockManifestExecutor { + return &mockManifestExecutor{t: t, calls: calls} +} + +func (m *mockManifestExecutor) Run(ctx context.Context, command string, args []string) (string, string, error) { + m.t.Helper() + if m.idx >= len(m.calls) { + m.t.Fatalf("unexpected manifest command: %s %v", command, args) + } + call := m.calls[m.idx] + m.idx++ + require.Equal(m.t, "linkerd", command) + require.Equal(m.t, call.args, args) + return call.stdout, call.stderr, call.err +} + +func (m *mockManifestExecutor) assertDone() { + m.t.Helper() + if m.idx != len(m.calls) { + m.t.Fatalf("expected %d manifest commands, got %d", len(m.calls), m.idx) + } +} + func TestRegisterTools(t *testing.T) { s := server.NewMCPServer("test-server", "v0.0.1") RegisterTools(s) @@ -58,11 +94,20 @@ func TestHandleLinkerdInstall(t *testing.T) { t.Run("default install", func(t *testing.T) { mock := cmd.NewMockShellExecutor() - mock.AddCommandString("linkerd", []string{"install", "--crds"}, "crd-manifest", nil) - mock.AddCommandString("linkerd", []string{"install"}, "manifest", nil) mock.AddPartialMatcherString("kubectl", []string{"apply", "-f"}, "applied", nil) ctx = cmd.WithShellExecutor(ctx, mock) + manifestMock := newMockManifestExecutor(t, []manifestExecCall{ + {args: []string{"install", "--crds"}, stdout: "crd-manifest"}, + {args: []string{"install"}, stdout: "manifest"}, + }) + prev := linkerdManifestExecutor + linkerdManifestExecutor = manifestMock + t.Cleanup(func() { + manifestMock.assertDone() + linkerdManifestExecutor = prev + }) + result, err := handleLinkerdInstall(ctx, mcp.CallToolRequest{}) require.NoError(t, err) assert.False(t, result.IsError) @@ -70,10 +115,19 @@ func TestHandleLinkerdInstall(t *testing.T) { t.Run("ha install with overrides", func(t *testing.T) { mock := cmd.NewMockShellExecutor() - mock.AddCommandString("linkerd", []string{"install", "--ha", "--skip-checks", "--identity-trust-anchors-pem", "anchors", "--set", "global.proxy.logLevel=debug", "--crds"}, "manifest", nil) mock.AddPartialMatcherString("kubectl", []string{"apply", "-f"}, "applied", nil) ctx = cmd.WithShellExecutor(ctx, mock) + manifestMock := newMockManifestExecutor(t, []manifestExecCall{ + {args: []string{"install", "--ha", "--skip-checks", "--identity-trust-anchors-pem", "anchors", "--set", "global.proxy.logLevel=debug", "--crds"}, stdout: "manifest"}, + }) + prev := linkerdManifestExecutor + linkerdManifestExecutor = manifestMock + t.Cleanup(func() { + manifestMock.assertDone() + linkerdManifestExecutor = prev + }) + request := mcp.CallToolRequest{} request.Params.Arguments = map[string]interface{}{ "ha": "true", @@ -89,7 +143,6 @@ func TestHandleLinkerdInstall(t *testing.T) { }) t.Run("install with advanced flags", func(t *testing.T) { - mock := cmd.NewMockShellExecutor() expectedArgs := []string{"install", "--disable-h2-upgrade", "--enable-endpoint-slices=false", @@ -105,10 +158,20 @@ func TestHandleLinkerdInstall(t *testing.T) { "-f", "overrides2.yaml", "--crds", } - mock.AddCommandString("linkerd", expectedArgs, "manifest", nil) + mock := cmd.NewMockShellExecutor() mock.AddPartialMatcherString("kubectl", []string{"apply", "-f"}, "applied", nil) ctx = cmd.WithShellExecutor(ctx, mock) + manifestMock := newMockManifestExecutor(t, []manifestExecCall{ + {args: expectedArgs, stdout: "manifest"}, + }) + prev := linkerdManifestExecutor + linkerdManifestExecutor = manifestMock + t.Cleanup(func() { + manifestMock.assertDone() + linkerdManifestExecutor = prev + }) + request := mcp.CallToolRequest{} request.Params.Arguments = map[string]interface{}{ "crds_only": "true", @@ -194,10 +257,19 @@ func TestHandleLinkerdInstallCNI(t *testing.T) { t.Run("default install-cni", func(t *testing.T) { mock := cmd.NewMockShellExecutor() - mock.AddCommandString("linkerd", []string{"install-cni"}, "manifest", nil) mock.AddPartialMatcherString("kubectl", []string{"apply", "-f"}, "applied", nil) ctx = cmd.WithShellExecutor(ctx, mock) + manifestMock := newMockManifestExecutor(t, []manifestExecCall{ + {args: []string{"install-cni"}, stdout: "manifest"}, + }) + prev := linkerdManifestExecutor + linkerdManifestExecutor = manifestMock + t.Cleanup(func() { + manifestMock.assertDone() + linkerdManifestExecutor = prev + }) + result, err := handleLinkerdInstallCNI(ctx, mcp.CallToolRequest{}) require.NoError(t, err) assert.False(t, result.IsError) @@ -205,10 +277,19 @@ func TestHandleLinkerdInstallCNI(t *testing.T) { t.Run("install-cni with overrides", func(t *testing.T) { mock := cmd.NewMockShellExecutor() - mock.AddCommandString("linkerd", []string{"install-cni", "--skip-checks", "--set", "cniResourceReadyTimeout=10m"}, "manifest", nil) mock.AddPartialMatcherString("kubectl", []string{"apply", "-f"}, "applied", nil) ctx = cmd.WithShellExecutor(ctx, mock) + manifestMock := newMockManifestExecutor(t, []manifestExecCall{ + {args: []string{"install-cni", "--skip-checks", "--set", "cniResourceReadyTimeout=10m"}, stdout: "manifest"}, + }) + prev := linkerdManifestExecutor + linkerdManifestExecutor = manifestMock + t.Cleanup(func() { + manifestMock.assertDone() + linkerdManifestExecutor = prev + }) + request := mcp.CallToolRequest{} request.Params.Arguments = map[string]interface{}{ "skip_checks": "true", @@ -225,10 +306,19 @@ func TestHandleLinkerdUpgrade(t *testing.T) { ctx := context.Background() mock := cmd.NewMockShellExecutor() - mock.AddCommandString("linkerd", []string{"upgrade", "--crds", "--ha", "--skip-checks", "--set", "global.proxy.logLevel=debug"}, "manifest", nil) mock.AddPartialMatcherString("kubectl", []string{"apply", "-f"}, "applied", nil) ctx = cmd.WithShellExecutor(ctx, mock) + manifestMock := newMockManifestExecutor(t, []manifestExecCall{ + {args: []string{"upgrade", "--crds", "--ha", "--skip-checks", "--set", "global.proxy.logLevel=debug"}, stdout: "manifest"}, + }) + prev := linkerdManifestExecutor + linkerdManifestExecutor = manifestMock + t.Cleanup(func() { + manifestMock.assertDone() + linkerdManifestExecutor = prev + }) + request := mcp.CallToolRequest{} request.Params.Arguments = map[string]interface{}{ "ha": "true", @@ -246,10 +336,19 @@ func TestHandleLinkerdUninstall(t *testing.T) { ctx := context.Background() mock := cmd.NewMockShellExecutor() - mock.AddCommandString("linkerd", []string{"uninstall", "--force"}, "removed", nil) mock.AddPartialMatcherString("kubectl", []string{"delete", "-f"}, "deleted", nil) ctx = cmd.WithShellExecutor(ctx, mock) + manifestMock := newMockManifestExecutor(t, []manifestExecCall{ + {args: []string{"uninstall", "--force"}, stdout: "removed"}, + }) + prev := linkerdManifestExecutor + linkerdManifestExecutor = manifestMock + t.Cleanup(func() { + manifestMock.assertDone() + linkerdManifestExecutor = prev + }) + request := mcp.CallToolRequest{} request.Params.Arguments = map[string]interface{}{ "force": "true", From 6958ea2ed445d5d60d3c37b5f8f926f21eaa794f Mon Sep 17 00:00:00 2001 From: Ivan Porta Date: Fri, 5 Dec 2025 01:37:46 +0900 Subject: [PATCH 5/7] linkerd Signed-off-by: Ivan Porta --- pkg/linkerd/linkerd.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/linkerd/linkerd.go b/pkg/linkerd/linkerd.go index 10821c2..75c6b02 100644 --- a/pkg/linkerd/linkerd.go +++ b/pkg/linkerd/linkerd.go @@ -829,7 +829,7 @@ func handleLinkerdWorkloadInjection(ctx context.Context, request mcp.CallToolReq args = append(args, "-n", namespace) } var patch string - operation := fmt.Sprintf("kubectl patch %s %s for linkerd injection", workloadType, workloadName) + var operation string if removeAnnotation { patch = buildAnnotationRemovePatch(config.annotationsPath, linkerdInjectionAnnotationKey) args = append(args, "--type=json", "-p", patch) From 88059c1ab9ebf9d78dbb2f3ca586ae769af3c12e Mon Sep 17 00:00:00 2001 From: Ivan Porta Date: Fri, 5 Dec 2025 02:12:00 +0900 Subject: [PATCH 6/7] format Signed-off-by: Ivan Porta --- pkg/linkerd/linkerd.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/linkerd/linkerd.go b/pkg/linkerd/linkerd.go index 75c6b02..13d8c45 100644 --- a/pkg/linkerd/linkerd.go +++ b/pkg/linkerd/linkerd.go @@ -828,6 +828,7 @@ func handleLinkerdWorkloadInjection(ctx context.Context, request mcp.CallToolReq if config.namespaced { args = append(args, "-n", namespace) } + var patch string var operation string if removeAnnotation { From 368d736bcd334c3de0ef7407ce843fe2400ef01d Mon Sep 17 00:00:00 2001 From: Ivan Porta Date: Fri, 5 Dec 2025 17:05:18 +0900 Subject: [PATCH 7/7] Improve error messaging and diagnostic endpoint Signed-off-by: Ivan Porta --- .gitignore | 3 +- pkg/linkerd/linkerd.go | 250 +++++++++++++++++++----------------- pkg/linkerd/linkerd_test.go | 76 ++++++++--- 3 files changed, 195 insertions(+), 134 deletions(-) diff --git a/.gitignore b/.gitignore index 62d65d1..e15a20e 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,5 @@ bin/ /reports/tools-cve.csv .dagger/ /tools/.cache/ -.gocache/ \ No newline at end of file +.gocache/ +/dist/* \ No newline at end of file diff --git a/pkg/linkerd/linkerd.go b/pkg/linkerd/linkerd.go index 13d8c45..7f40fd3 100644 --- a/pkg/linkerd/linkerd.go +++ b/pkg/linkerd/linkerd.go @@ -4,14 +4,14 @@ import ( "bytes" "context" "encoding/json" - goerrors "errors" + "errors" "fmt" "os" "os/exec" - "sort" "strings" "time" + "github.com/kagent-dev/tools/internal/cmd" "github.com/kagent-dev/tools/internal/commands" toolerrors "github.com/kagent-dev/tools/internal/errors" "github.com/kagent-dev/tools/internal/logger" @@ -51,6 +51,15 @@ type manifestCommandExecutor interface { Run(ctx context.Context, command string, args []string) (stdout string, stderr string, err error) } +type commandOutputProvider interface { + CommandOutput() string +} + +type commandExecutionError struct { + err error + output string +} + var linkerdManifestExecutor manifestCommandExecutor = &execManifestCommandExecutor{} type execManifestCommandExecutor struct{} @@ -86,17 +95,44 @@ func (e *execManifestCommandExecutor) Run(ctx context.Context, command string, a return stdout.String(), stderr.String(), err } +func (e *commandExecutionError) Error() string { + trimmed := strings.TrimSpace(e.output) + if trimmed == "" { + return e.err.Error() + } + return fmt.Sprintf("%s: %s", e.err.Error(), trimmed) +} + +func (e *commandExecutionError) Unwrap() error { + return e.err +} + +func (e *commandExecutionError) CommandOutput() string { + return e.output +} + +type outputCapturingExecutor struct { + base cmd.ShellExecutor +} + +func (e *outputCapturingExecutor) Exec(ctx context.Context, command string, args ...string) ([]byte, error) { + output, err := e.base.Exec(ctx, command, args...) + if err == nil { + return output, nil + } + return output, &commandExecutionError{err: err, output: string(output)} +} + // ================================= // Helpers functions // ================================= -func supportedLinkerdWorkloadTypes() []string { - types := make([]string, 0, len(linkerdWorkloadTypes)) - for t := range linkerdWorkloadTypes { - types = append(types, t) +func withOutputCapturingExecutor(ctx context.Context) context.Context { + base := cmd.GetShellExecutor(ctx) + if _, ok := base.(*outputCapturingExecutor); ok { + return ctx } - sort.Strings(types) - return types + return cmd.WithShellExecutor(ctx, &outputCapturingExecutor{base: base}) } func buildAnnotationMergePatch(path []string, key, value string) (string, error) { @@ -125,21 +161,13 @@ func buildAnnotationRemovePatch(path []string, key string) string { segments := append([]string{}, path...) segments = append(segments, key) for i, segment := range segments { - segments[i] = escapeJSONPointerSegment(segment) + segment = strings.ReplaceAll(segment, "~", "~0") + segment = strings.ReplaceAll(segment, "/", "~1") + segments[i] = segment } return fmt.Sprintf(`[{"op":"remove","path":"/%s"}]`, strings.Join(segments, "/")) } -func escapeJSONPointerSegment(segment string) string { - segment = strings.ReplaceAll(segment, "~", "~0") - segment = strings.ReplaceAll(segment, "/", "~1") - return segment -} - -func runLinkerdCommand(ctx context.Context, args []string) (string, error) { - return executeLinkerdCommand(ctx, args) -} - func runLinkerdManifestCommand(ctx context.Context, args []string) (string, error) { kubeconfigPath := utils.GetKubeconfig() builder := commands.NewCommandBuilder("linkerd"). @@ -172,21 +200,14 @@ func runLinkerdManifestCommand(ctx context.Context, args []string) (string, erro return stdout, nil } -func executeLinkerdCommand(ctx context.Context, args []string) (string, error) { +func runLinkerdCommand(ctx context.Context, args []string) (string, error) { kubeconfigPath := utils.GetKubeconfig() builder := commands.NewCommandBuilder("linkerd"). WithArgs(args...). WithKubeconfig(kubeconfigPath) - return builder.Execute(ctx) -} - -func applyManifest(ctx context.Context, manifest string) (string, error) { - return runKubectlManifestCommand(ctx, "apply", manifest) -} - -func deleteManifest(ctx context.Context, manifest string) (string, error) { - return runKubectlManifestCommand(ctx, "delete", manifest) + execCtx := withOutputCapturingExecutor(ctx) + return builder.Execute(execCtx) } func runKubectlManifestCommand(ctx context.Context, action, manifest string) (string, error) { @@ -228,37 +249,67 @@ func writeManifestToTempFile(manifest string) (string, error) { } func formatLinkerdCommandResult(operation string, output string, err error) (*mcp.CallToolResult, error) { - if err == nil { - return mcp.NewToolResultText(output), nil + rawOutput := output + trimmedOutput := strings.TrimSpace(rawOutput) + if trimmedOutput == "" { + rawOutput = extractCommandOutputFromError(err) + trimmedOutput = strings.TrimSpace(rawOutput) } - trimmedOutput := strings.TrimSpace(output) - failureMessage := fmt.Sprintf("%s failed: %v", operation, err) - if trimmedOutput != "" { - failureMessage = fmt.Sprintf("%s\n\n%s", failureMessage, trimmedOutput) - } - - var toolErr *toolerrors.ToolError - if goerrors.As(err, &toolErr) { - toolErr = toolErr.WithContext("kagent_operation", operation) + if err != nil { + annotateToolErrorWithOutput(err, rawOutput) if trimmedOutput != "" { - toolErr = toolErr.WithContext("command_output", trimmedOutput) + message := fmt.Sprintf("❌ **%s failed**\n\n```\n%s\n```", operation, rawOutput) + return mcp.NewToolResultError(message), nil } - return toolErr.ToMCPResult(), nil + message := fmt.Sprintf("❌ **%s failed**\n\n%s", operation, err.Error()) + return mcp.NewToolResultError(message), nil + } + + if trimmedOutput == "" { + rawOutput = "Command completed successfully with no output." } - return mcp.NewToolResultError(failureMessage), nil + message := fmt.Sprintf("✅ **%s succeeded**\n\n```\n%s\n```", operation, rawOutput) + return mcp.NewToolResultText(message), nil } -func appendSetOverrides(args []string, overrides string) []string { - return appendCSVArgs(args, "--set", overrides) +func annotateToolErrorWithOutput(err error, output string) { + if output == "" { + return + } + var toolErr *toolerrors.ToolError + if !errors.As(err, &toolErr) { + return + } + if toolErr.Context == nil { + toolErr.Context = make(map[string]interface{}) + } + if _, exists := toolErr.Context["command_output"]; !exists { + toolErr.Context["command_output"] = output + } } -func appendCSVArgs(args []string, flag, csv string) []string { - for _, value := range parseCommaSeparated(csv) { - args = append(args, flag, value) +func extractCommandOutputFromError(err error) string { + if err == nil { + return "" + } + var toolErr *toolerrors.ToolError + if errors.As(err, &toolErr) { + if output, ok := toolErr.Context["command_output"].(string); ok && output != "" { + return output + } + if toolErr.Cause != nil { + if causeOutput := extractCommandOutputFromError(toolErr.Cause); causeOutput != "" { + return causeOutput + } + } + } + var provider commandOutputProvider + if errors.As(err, &provider) { + return provider.CommandOutput() } - return args + return "" } func appendFlagArg(args []string, flag, value string) []string { @@ -283,22 +334,6 @@ func appendBoolFlag(args []string, flag, value string) ([]string, error) { } } -func parseCommaSeparated(csv string) []string { - if csv == "" { - return nil - } - parts := strings.Split(csv, ",") - values := make([]string, 0, len(parts)) - for _, part := range parts { - trimmed := strings.TrimSpace(part) - if trimmed == "" { - continue - } - values = append(values, trimmed) - } - return values -} - // ================================= // Linkerd // ================================= @@ -370,8 +405,7 @@ func handleLinkerdCheck(ctx context.Context, request mcp.CallToolRequest) (*mcp. // # Install the core control plane. // linkerd install | kubectl apply -f - // -// The installation can be configured by using the --set, --values, --set-string and --set-file flags. -// A full list of configurable values can be found at https://artifacthub.io/packages/helm/linkerd2/linkerd-control-plane#values +// Additional configuration options are documented at https://artifacthub.io/packages/helm/linkerd2/linkerd-control-plane#values func handleLinkerdInstall(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { ha := mcp.ParseString(request, "ha", "") == "true" crdsOnly := mcp.ParseString(request, "crds_only", "") == "true" @@ -383,10 +417,6 @@ func handleLinkerdInstall(ctx context.Context, request mcp.CallToolRequest) (*mc identityIssuerCertificateFile := mcp.ParseString(request, "identity_issuer_certificate_file", "") identityIssuerKeyFile := mcp.ParseString(request, "identity_issuer_key_file", "") identityTrustDomain := mcp.ParseString(request, "identity_trust_domain", "") - setOverrides := mcp.ParseString(request, "set_overrides", "") - setStringOverrides := mcp.ParseString(request, "set_string_overrides", "") - setFileOverrides := mcp.ParseString(request, "set_file_overrides", "") - valuesFiles := mcp.ParseString(request, "values", "") adminPort := mcp.ParseString(request, "admin_port", "") clusterDomain := mcp.ParseString(request, "cluster_domain", "") controlPort := mcp.ParseString(request, "control_port", "") @@ -488,11 +518,6 @@ func handleLinkerdInstall(ctx context.Context, request mcp.CallToolRequest) (*mc args = appendFlagArg(args, "--skip-outbound-ports", skipOutboundPorts) args = appendFlagArg(args, "-o", outputFormat) - args = appendSetOverrides(args, setOverrides) - args = appendCSVArgs(args, "--set-string", setStringOverrides) - args = appendCSVArgs(args, "--set-file", setFileOverrides) - args = appendCSVArgs(args, "-f", valuesFiles) - crdArgs := append([]string{}, args...) crdArgs = append(crdArgs, "--crds") @@ -502,7 +527,7 @@ func handleLinkerdInstall(ctx context.Context, request mcp.CallToolRequest) (*mc return formatLinkerdCommandResult("linkerd install --crds", manifest, err) } - applyResult, applyErr := applyManifest(ctx, manifest) + applyResult, applyErr := runKubectlManifestCommand(ctx, "apply", manifest) return formatLinkerdCommandResult("kubectl apply linkerd install CRDs manifest", applyResult, applyErr) } @@ -513,7 +538,7 @@ func handleLinkerdInstall(ctx context.Context, request mcp.CallToolRequest) (*mc return formatLinkerdCommandResult("linkerd install --crds", crdManifest, err) } - crdApplyResult, crdApplyErr := applyManifest(ctx, crdManifest) + crdApplyResult, crdApplyErr := runKubectlManifestCommand(ctx, "apply", crdManifest) if crdApplyErr != nil { return formatLinkerdCommandResult("kubectl apply linkerd install CRDs manifest", crdApplyResult, crdApplyErr) } @@ -529,7 +554,7 @@ func handleLinkerdInstall(ctx context.Context, request mcp.CallToolRequest) (*mc return formatLinkerdCommandResult("linkerd install", manifest, err) } - applyResult, applyErr := applyManifest(ctx, manifest) + applyResult, applyErr := runKubectlManifestCommand(ctx, "apply", manifest) finalOutput := applyResult if combinedOutput.Len() > 0 { @@ -559,10 +584,6 @@ func handleLinkerdInstall(ctx context.Context, request mcp.CallToolRequest) (*mc // linkerd install-cni [flags] func handleLinkerdInstallCNI(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { skipChecks := mcp.ParseString(request, "skip_checks", "") == "true" - setOverrides := mcp.ParseString(request, "set_overrides", "") - setStringOverrides := mcp.ParseString(request, "set_string_overrides", "") - setFileOverrides := mcp.ParseString(request, "set_file_overrides", "") - valuesFiles := mcp.ParseString(request, "values", "") adminPort := mcp.ParseString(request, "admin_port", "") cniImage := mcp.ParseString(request, "cni_image", "") cniImageVersion := mcp.ParseString(request, "cni_image_version", "") @@ -610,17 +631,12 @@ func handleLinkerdInstallCNI(ctx context.Context, request mcp.CallToolRequest) ( args = appendFlagArg(args, "--skip-inbound-ports", skipInboundPorts) args = appendFlagArg(args, "--skip-outbound-ports", skipOutboundPorts) - args = appendSetOverrides(args, setOverrides) - args = appendCSVArgs(args, "--set-string", setStringOverrides) - args = appendCSVArgs(args, "--set-file", setFileOverrides) - args = appendCSVArgs(args, "-f", valuesFiles) - manifest, err := runLinkerdManifestCommand(ctx, args) if err != nil { return formatLinkerdCommandResult("linkerd install-cni", manifest, err) } - applyResult, applyErr := applyManifest(ctx, manifest) + applyResult, applyErr := runKubectlManifestCommand(ctx, "apply", manifest) return formatLinkerdCommandResult("kubectl apply linkerd install-cni manifest", applyResult, applyErr) } @@ -631,8 +647,7 @@ func handleLinkerdInstallCNI(ctx context.Context, request mcp.CallToolRequest) ( // plane. The default values displayed in the Flags section below only apply to the // install command. // -// The upgrade can be configured by using the --set, --values, --set-string and --set-file flags. -// A full list of configurable values can be found at https://www.github.com/linkerd/linkerd2/tree/main/charts/linkerd2/README.md +// Additional upgrade guidance is available at https://www.github.com/linkerd/linkerd2/tree/main/charts/linkerd2/README.md // // Usage: // @@ -652,7 +667,6 @@ func handleLinkerdUpgrade(ctx context.Context, request mcp.CallToolRequest) (*mc ha := mcp.ParseString(request, "ha", "") == "true" crdsOnly := mcp.ParseString(request, "crds_only", "") == "true" skipChecks := mcp.ParseString(request, "skip_checks", "") == "true" - setOverrides := mcp.ParseString(request, "set_overrides", "") args := []string{"upgrade"} @@ -668,14 +682,12 @@ func handleLinkerdUpgrade(ctx context.Context, request mcp.CallToolRequest) (*mc args = append(args, "--skip-checks") } - args = appendSetOverrides(args, setOverrides) - manifest, err := runLinkerdManifestCommand(ctx, args) if err != nil { return formatLinkerdCommandResult("linkerd upgrade", manifest, err) } - applyResult, applyErr := applyManifest(ctx, manifest) + applyResult, applyErr := runKubectlManifestCommand(ctx, "apply", manifest) return formatLinkerdCommandResult("kubectl apply linkerd upgrade manifest", applyResult, applyErr) } @@ -695,7 +707,7 @@ func handleLinkerdUninstall(ctx context.Context, request mcp.CallToolRequest) (* return formatLinkerdCommandResult("linkerd uninstall", manifest, err) } - deleteResult, deleteErr := deleteManifest(ctx, manifest) + deleteResult, deleteErr := runKubectlManifestCommand(ctx, "delete", manifest) return formatLinkerdCommandResult("kubectl delete linkerd uninstall manifest", deleteResult, deleteErr) } @@ -707,7 +719,7 @@ func handleLinkerdUninstall(ctx context.Context, request mcp.CallToolRequest) (* // // linkerd version [flags] func handleLinkerdVersion(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - clientOnly := mcp.ParseString(request, "client_only", "") == "true" + clientFlag := strings.TrimSpace(mcp.ParseString(request, "client_only", "")) short := mcp.ParseString(request, "short", "") == "true" proxyVersions := mcp.ParseString(request, "proxy", "") == "true" namespace := strings.TrimSpace(mcp.ParseString(request, "namespace", "")) @@ -719,8 +731,12 @@ func handleLinkerdVersion(ctx context.Context, request mcp.CallToolRequest) (*mc args := []string{"version"} - if clientOnly { - args = append(args, "--client") + if clientFlag != "" { + var err error + args, err = appendBoolFlag(args, "--client", clientFlag) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } } if proxyVersions { @@ -798,9 +814,15 @@ func handleLinkerdWorkloadInjection(ctx context.Context, request mcp.CallToolReq namespaceInput := mcp.ParseString(request, "namespace", "default") workloadType := strings.ToLower(mcp.ParseString(request, "workload_type", "deployment")) + + supportedTypes := make([]string, 0, len(linkerdWorkloadTypes)) + for t := range linkerdWorkloadTypes { + supportedTypes = append(supportedTypes, t) + } + config, ok := linkerdWorkloadTypes[workloadType] if !ok { - return mcp.NewToolResultError(fmt.Sprintf("workload_type must be one of: %s", strings.Join(supportedLinkerdWorkloadTypes(), ", "))), nil + return mcp.NewToolResultError(fmt.Sprintf("workload_type must be one of: %s", strings.Join(supportedTypes, ", "))), nil } var namespace string @@ -873,7 +895,13 @@ func handleLinkerdPolicy(ctx context.Context, request mcp.CallToolRequest) (*mcp switch command { case "generate": - result, err := runLinkerdCommand(ctx, nil) + args := []string{"policy", "generate"} + namespace := strings.TrimSpace(mcp.ParseString(request, "namespace", "")) + if namespace != "" { + args = append(args, "-n", namespace) + } + + result, err := runLinkerdCommand(ctx, args) return formatLinkerdCommandResult("linkerd policy generate", result, err) default: return mcp.NewToolResultError(fmt.Sprintf("unsupported linkerd policy command: %s", command)), nil @@ -1000,6 +1028,7 @@ func handleLinkerdFips(ctx context.Context, request mcp.CallToolRequest) (*mcp.C // linkerd diagnostics controller-metrics [flags] func handleLinkerdDiagnosticsControllerMetrics(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { namespace := mcp.ParseString(request, "namespace", "") + component := strings.TrimSpace(mcp.ParseString(request, "component", "")) waitDuration := mcp.ParseString(request, "wait", "") args := []string{"diagnostics", "controller-metrics"} @@ -1008,6 +1037,10 @@ func handleLinkerdDiagnosticsControllerMetrics(ctx context.Context, request mcp. args = append(args, "-n", namespace) } + if component != "" { + args = append(args, "--component", component) + } + if waitDuration != "" { args = append(args, "--wait", waitDuration) } @@ -1126,17 +1159,12 @@ func handleLinkerdDiagnosticsEndpoints(ctx context.Context, request mcp.CallTool return mcp.NewToolResultError("authority parameter is required"), nil } - namespace := mcp.ParseString(request, "namespace", "") destinationPod := mcp.ParseString(request, "destination_pod", "") outputFormat := mcp.ParseString(request, "output", "") token := mcp.ParseString(request, "token", "") args := []string{"diagnostics", "endpoints"} - if namespace != "" { - args = append(args, "-n", namespace) - } - if destinationPod != "" { args = append(args, "--destination-pod", destinationPod) } @@ -1275,10 +1303,6 @@ func RegisterTools(s *server.MCPServer) { mcp.WithString("registry", mcp.Description("Image registry (--registry)")), mcp.WithString("skip_inbound_ports", mcp.Description("Ports to skip inbound proxying (--skip-inbound-ports)")), mcp.WithString("skip_outbound_ports", mcp.Description("Ports to skip outbound proxying (--skip-outbound-ports)")), - mcp.WithString("set_overrides", mcp.Description("Comma-separated Helm style key=value overrides for --set")), - mcp.WithString("set_string_overrides", mcp.Description("Comma-separated key=value overrides for --set-string")), - mcp.WithString("set_file_overrides", mcp.Description("Comma-separated key=path overrides for --set-file")), - mcp.WithString("values", mcp.Description("Comma-separated list of values files/URLs for -f/--values")), ), telemetry.AdaptToolHandler(telemetry.WithTracing("linkerd_install", handleLinkerdInstall))) s.AddTool(mcp.NewTool("linkerd_patch_workload_injection", @@ -1308,13 +1332,9 @@ func RegisterTools(s *server.MCPServer) { mcp.WithString("proxy_uid", mcp.Description("Run the proxy under this UID (--proxy-uid)")), mcp.WithString("redirect_ports", mcp.Description("Ports to redirect to the proxy, comma-separated (--redirect-ports)")), mcp.WithString("registry", mcp.Description("Image registry for the plugin (--registry)")), - mcp.WithString("set_overrides", mcp.Description("Comma-separated Helm style key=value overrides for --set")), - mcp.WithString("set_string_overrides", mcp.Description("Comma-separated key=value pairs for --set-string")), - mcp.WithString("set_file_overrides", mcp.Description("Comma-separated key=path pairs for --set-file")), mcp.WithString("skip_inbound_ports", mcp.Description("Ports that should bypass the proxy (--skip-inbound-ports)")), mcp.WithString("skip_outbound_ports", mcp.Description("Outbound ports that should bypass the proxy (--skip-outbound-ports)")), mcp.WithString("use_wait_flag", mcp.Description("Set to true to enable --use-wait-flag")), - mcp.WithString("values", mcp.Description("Comma-separated list of values files or URLs for -f/--values")), ), telemetry.AdaptToolHandler(telemetry.WithTracing("linkerd_install_cni", handleLinkerdInstallCNI))) s.AddTool(mcp.NewTool("linkerd_upgrade", @@ -1322,7 +1342,6 @@ func RegisterTools(s *server.MCPServer) { mcp.WithString("ha", mcp.Description("Set to true to use high availability values")), mcp.WithString("crds_only", mcp.Description("Set to true to upgrade only CRDs")), mcp.WithString("skip_checks", mcp.Description("Skip Kubernetes and environment checks")), - mcp.WithString("set_overrides", mcp.Description("Comma-separated Helm style key=value overrides")), ), telemetry.AdaptToolHandler(telemetry.WithTracing("linkerd_upgrade", handleLinkerdUpgrade))) s.AddTool(mcp.NewTool("linkerd_uninstall", @@ -1382,7 +1401,6 @@ func RegisterTools(s *server.MCPServer) { s.AddTool(mcp.NewTool("linkerd_diagnostics_endpoints", mcp.WithDescription("Inspect Linkerd's service discovery endpoints for an authority"), mcp.WithString("authority", mcp.Description("Authority host:port to inspect"), mcp.Required()), - mcp.WithString("namespace", mcp.Description("Namespace context for the query")), mcp.WithString("destination_pod", mcp.Description("Specific destination pod to query")), mcp.WithString("output", mcp.Description(`Output format ("table" or "json")`)), mcp.WithString("token", mcp.Description("Context token for destination API requests")), diff --git a/pkg/linkerd/linkerd_test.go b/pkg/linkerd/linkerd_test.go index 32b387a..ce00f33 100644 --- a/pkg/linkerd/linkerd_test.go +++ b/pkg/linkerd/linkerd_test.go @@ -2,9 +2,11 @@ package linkerd import ( "context" + "errors" "testing" "github.com/kagent-dev/tools/internal/cmd" + toolerrors "github.com/kagent-dev/tools/internal/errors" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" "github.com/stretchr/testify/assert" @@ -52,6 +54,53 @@ func TestRegisterTools(t *testing.T) { RegisterTools(s) } +func TestFormatLinkerdCommandResultIncludesCommandOutput(t *testing.T) { + output := "Error: invalid resource type deploy/simple-app-v1; must be one of Pod or Service" + cmdErr := toolerrors.NewCommandError("linkerd", &commandExecutionError{err: errors.New("exit status 1"), output: output}) + + result, err := formatLinkerdCommandResult("linkerd diagnostics policy", output, cmdErr) + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.IsError) + + require.Len(t, result.Content, 1) + textContent, ok := result.Content[0].(mcp.TextContent) + require.True(t, ok) + assert.Contains(t, textContent.Text, output) + assert.Contains(t, textContent.Text, "failed") + + assert.Equal(t, output, cmdErr.Context["command_output"]) + assert.Contains(t, cmdErr.Cause.Error(), output) +} + +func TestFormatLinkerdCommandResultExtractsOutputFromError(t *testing.T) { + output := "Error: invalid resource type deploy/simple-app-v1; must be one of Pod or Service" + cmdErr := toolerrors.NewCommandError("linkerd", &commandExecutionError{err: errors.New("exit status 1"), output: output}) + + result, err := formatLinkerdCommandResult("linkerd diagnostics policy", "", cmdErr) + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.IsError) + + require.Len(t, result.Content, 1) + textContent, ok := result.Content[0].(mcp.TextContent) + require.True(t, ok) + assert.Contains(t, textContent.Text, output) + assert.Equal(t, output, cmdErr.Context["command_output"]) +} + +func TestFormatLinkerdCommandResultSuccessResult(t *testing.T) { + result, err := formatLinkerdCommandResult("linkerd version", "Client version: stable", nil) + require.NoError(t, err) + require.NotNil(t, result) + assert.False(t, result.IsError) + require.Len(t, result.Content, 1) + textContent, ok := result.Content[0].(mcp.TextContent) + require.True(t, ok) + assert.Contains(t, textContent.Text, "succeeded") + assert.Contains(t, textContent.Text, "Client version: stable") +} + func TestHandleLinkerdCheck(t *testing.T) { ctx := context.Background() @@ -119,7 +168,7 @@ func TestHandleLinkerdInstall(t *testing.T) { ctx = cmd.WithShellExecutor(ctx, mock) manifestMock := newMockManifestExecutor(t, []manifestExecCall{ - {args: []string{"install", "--ha", "--skip-checks", "--identity-trust-anchors-pem", "anchors", "--set", "global.proxy.logLevel=debug", "--crds"}, stdout: "manifest"}, + {args: []string{"install", "--ha", "--skip-checks", "--identity-trust-anchors-pem", "anchors", "--crds"}, stdout: "manifest"}, }) prev := linkerdManifestExecutor linkerdManifestExecutor = manifestMock @@ -134,7 +183,6 @@ func TestHandleLinkerdInstall(t *testing.T) { "crds_only": "true", "skip_checks": "true", "identity_trust_anchors_pem": "anchors", - "set_overrides": "global.proxy.logLevel=debug", } result, err := handleLinkerdInstall(ctx, request) @@ -153,9 +201,6 @@ func TestHandleLinkerdInstall(t *testing.T) { "--proxy-cpu-limit", "500m", "--registry", "registry.example.com/linkerd", "-o", "json", - "--set-string", "global.proxy.logLevel=debug", - "-f", "overrides1.yaml", - "-f", "overrides2.yaml", "--crds", } mock := cmd.NewMockShellExecutor() @@ -184,8 +229,6 @@ func TestHandleLinkerdInstall(t *testing.T) { "proxy_cpu_limit": "500m", "registry": "registry.example.com/linkerd", "output": "json", - "set_string_overrides": "global.proxy.logLevel=debug", - "values": "overrides1.yaml,overrides2.yaml", } result, err := handleLinkerdInstall(ctx, request) @@ -281,7 +324,7 @@ func TestHandleLinkerdInstallCNI(t *testing.T) { ctx = cmd.WithShellExecutor(ctx, mock) manifestMock := newMockManifestExecutor(t, []manifestExecCall{ - {args: []string{"install-cni", "--skip-checks", "--set", "cniResourceReadyTimeout=10m"}, stdout: "manifest"}, + {args: []string{"install-cni", "--skip-checks"}, stdout: "manifest"}, }) prev := linkerdManifestExecutor linkerdManifestExecutor = manifestMock @@ -292,8 +335,7 @@ func TestHandleLinkerdInstallCNI(t *testing.T) { request := mcp.CallToolRequest{} request.Params.Arguments = map[string]interface{}{ - "skip_checks": "true", - "set_overrides": "cniResourceReadyTimeout=10m", + "skip_checks": "true", } result, err := handleLinkerdInstallCNI(ctx, request) @@ -310,7 +352,7 @@ func TestHandleLinkerdUpgrade(t *testing.T) { ctx = cmd.WithShellExecutor(ctx, mock) manifestMock := newMockManifestExecutor(t, []manifestExecCall{ - {args: []string{"upgrade", "--crds", "--ha", "--skip-checks", "--set", "global.proxy.logLevel=debug"}, stdout: "manifest"}, + {args: []string{"upgrade", "--crds", "--ha", "--skip-checks"}, stdout: "manifest"}, }) prev := linkerdManifestExecutor linkerdManifestExecutor = manifestMock @@ -321,10 +363,9 @@ func TestHandleLinkerdUpgrade(t *testing.T) { request := mcp.CallToolRequest{} request.Params.Arguments = map[string]interface{}{ - "ha": "true", - "crds_only": "true", - "skip_checks": "true", - "set_overrides": "global.proxy.logLevel=debug", + "ha": "true", + "crds_only": "true", + "skip_checks": "true", } result, err := handleLinkerdUpgrade(ctx, request) @@ -539,12 +580,13 @@ func TestHandleLinkerdDiagnosticsPolicy(t *testing.T) { ctx := context.Background() mock := cmd.NewMockShellExecutor() - mock.AddCommandString("linkerd", []string{"diagnostics", "policy", "-n", "default", "web.linkerd-viz.svc.cluster.local:8084"}, "policy", nil) + mock.AddCommandString("linkerd", []string{"diagnostics", "policy", "-n", "default", "po/web-123", "8084"}, "policy", nil) ctx = cmd.WithShellExecutor(ctx, mock) request := mcp.CallToolRequest{} request.Params.Arguments = map[string]interface{}{ - "authority": "web.linkerd-viz.svc.cluster.local:8084", + "resource": "po/web-123", + "port": "8084", "namespace": "default", }