diff --git a/.gitignore b/.gitignore index 4b3d33a..e15a20e 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,6 @@ bin/ /helm/kagent-tools/Chart.yaml /reports/tools-cve.csv .dagger/ +/tools/.cache/ +.gocache/ +/dist/* \ No newline at end of file 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..7f40fd3 --- /dev/null +++ b/pkg/linkerd/linkerd.go @@ -0,0 +1,1426 @@ +package linkerd + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "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" + "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" +) + +// ================================= +// Constants and variables +// ================================= + +const ( + 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}, +} + +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{} + +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 +} + +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 withOutputCapturingExecutor(ctx context.Context) context.Context { + base := cmd.GetShellExecutor(ctx) + if _, ok := base.(*outputCapturingExecutor); ok { + return ctx + } + return cmd.WithShellExecutor(ctx, &outputCapturingExecutor{base: base}) +} + +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 { + segment = strings.ReplaceAll(segment, "~", "~0") + segment = strings.ReplaceAll(segment, "/", "~1") + segments[i] = segment + } + return fmt.Sprintf(`[{"op":"remove","path":"/%s"}]`, strings.Join(segments, "/")) +} + +func runLinkerdManifestCommand(ctx context.Context, args []string) (string, error) { + 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 runLinkerdCommand(ctx context.Context, args []string) (string, error) { + kubeconfigPath := utils.GetKubeconfig() + builder := commands.NewCommandBuilder("linkerd"). + WithArgs(args...). + WithKubeconfig(kubeconfigPath) + + execCtx := withOutputCapturingExecutor(ctx) + return builder.Execute(execCtx) +} + +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) { + rawOutput := output + trimmedOutput := strings.TrimSpace(rawOutput) + if trimmedOutput == "" { + rawOutput = extractCommandOutputFromError(err) + trimmedOutput = strings.TrimSpace(rawOutput) + } + + if err != nil { + annotateToolErrorWithOutput(err, rawOutput) + if trimmedOutput != "" { + message := fmt.Sprintf("❌ **%s failed**\n\n```\n%s\n```", operation, rawOutput) + return mcp.NewToolResultError(message), 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." + } + + message := fmt.Sprintf("✅ **%s succeeded**\n\n```\n%s\n```", operation, rawOutput) + return mcp.NewToolResultText(message), nil +} + +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 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 "" +} + +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) + } +} + +// ================================= +// 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" + 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) + +} + +// 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 - +// +// 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" + 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", "") + 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"} + + if ha { + args = append(args, "--ha") + } + + if skipChecks { + 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) + + 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 := runKubectlManifestCommand(ctx, "apply", 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 := runKubectlManifestCommand(ctx, "apply", 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 := runKubectlManifestCommand(ctx, "apply", 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) + +} + +// 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" + 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"} + + if skipChecks { + 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) + + manifest, err := runLinkerdManifestCommand(ctx, args) + if err != nil { + return formatLinkerdCommandResult("linkerd install-cni", manifest, err) + } + + applyResult, applyErr := runKubectlManifestCommand(ctx, "apply", manifest) + return formatLinkerdCommandResult("kubectl apply linkerd install-cni manifest", applyResult, applyErr) + +} + +// 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. +// +// Additional upgrade guidance is available 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" + skipChecks := mcp.ParseString(request, "skip_checks", "") == "true" + + args := []string{"upgrade"} + + if crdsOnly { + args = append(args, "--crds") + } + + if ha { + args = append(args, "--ha") + } + + if skipChecks { + args = append(args, "--skip-checks") + } + + manifest, err := runLinkerdManifestCommand(ctx, args) + if err != nil { + return formatLinkerdCommandResult("linkerd upgrade", manifest, err) + } + + applyResult, applyErr := runKubectlManifestCommand(ctx, "apply", 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 := runKubectlManifestCommand(ctx, "delete", manifest) + return formatLinkerdCommandResult("kubectl delete linkerd uninstall manifest", deleteResult, deleteErr) + +} + +// Linkerd version +// Print the client and server version information +// +// Usage: +// +// linkerd version [flags] +func handleLinkerdVersion(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + 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", "")) + if namespace != "" { + if err := security.ValidateNamespace(namespace); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("invalid namespace: %v", err)), nil + } + } + + args := []string{"version"} + + if clientFlag != "" { + var err error + args, err = appendBoolFlag(args, "--client", clientFlag) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + } + + if proxyVersions { + args = append(args, "--proxy") + } + + if namespace != "" { + args = append(args, "-n", namespace) + } + + if short { + args = append(args, "--short") + } + + result, err := runLinkerdCommand(ctx, args) + return formatLinkerdCommandResult("linkerd version", result, err) + +} + +// 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 == "" { + 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) + +} + +// 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 + } + + if err := security.ValidateK8sResourceName(workloadName); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("invalid workload name: %v", err)), nil + } + + 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(supportedTypes, ", "))), nil + } + + 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 + } + + 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} + if config.namespaced { + args = append(args, "-n", namespace) + } + + var patch string + var operation string + 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 := runKubectlCommand(ctx, args) + return formatLinkerdCommandResult(operation, result, err) +} + +// 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": + 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 + } +} + +// 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 != "" { + if err := security.ValidateNamespace(namespace); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("invalid namespace: %v", err)), nil + } + } + + 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", "")) + + modeCount := 0 + if templateOutput { + modeCount++ + } + if openAPIFile != "" { + modeCount++ + } + if protoFile != "" { + modeCount++ + } + if modeCount == 0 { + return mcp.NewToolResultError("one of template, open_api, or proto must be provided"), nil + } + if modeCount > 1 { + return mcp.NewToolResultError("template, open_api, and proto options are mutually exclusive"), nil + } + + args := []string{"profile"} + if namespace != "" { + args = append(args, "-n", namespace) + } + if ignoreCluster { + args = append(args, "--ignore-cluster") + } + if templateOutput { + args = append(args, "--template") + } else if openAPIFile != "" { + args = append(args, "--open-api", openAPIFile) + } else if protoFile != "" { + args = append(args, "--proto", protoFile) + } + if outputFormat != "" { + args = append(args, "-o", outputFormat) + } + args = append(args, serviceName) + + result, err := runLinkerdCommand(ctx, args) + return formatLinkerdCommandResult("linkerd profile", result, err) +} + +// 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", "") + + args := []string{"fips", "audit"} + + if namespace != "" { + args = append(args, "-n", namespace) + } + + result, err := runLinkerdCommand(ctx, args) + return formatLinkerdCommandResult("linkerd fips audit", result, err) + +} + +// ================================= +// 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 := strings.TrimSpace(mcp.ParseString(request, "component", "")) + waitDuration := mcp.ParseString(request, "wait", "") + + args := []string{"diagnostics", "controller-metrics"} + + if namespace != "" { + 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) +} + +// 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", "profile"} + + if namespace != "" { + args = append(args, "-n", namespace) + } + + if destinationPod != "" { + args = append(args, "--destination-pod", destinationPod) + } + + if token != "" { + args = append(args, "--token", token) + } + + args = append(args, authority) + + result, err := runLinkerdCommand(ctx, args) + return formatLinkerdCommandResult("linkerd diagnostics profile", result, err) + +} + +// 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", "proxy-metrics"} + + if namespace != "" { + args = append(args, "-n", namespace) + } + + if obfuscate { + args = append(args, "--obfuscate") + } + + args = append(args, resource) + + result, err := runLinkerdCommand(ctx, args) + return formatLinkerdCommandResult("linkerd diagnostics proxy-metrics", result, err) +} + +// 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 + } + + destinationPod := mcp.ParseString(request, "destination_pod", "") + outputFormat := mcp.ParseString(request, "output", "") + token := mcp.ParseString(request, "token", "") + + args := []string{"diagnostics", "endpoints"} + + if destinationPod != "" { + args = append(args, "--destination-pod", destinationPod) + } + + if outputFormat != "" { + args = append(args, "-o", outputFormat) + } + + if token != "" { + args = append(args, "--token", token) + } + + args = append(args, authority) + + result, err := runLinkerdCommand(ctx, args) + return formatLinkerdCommandResult("linkerd diagnostics endpoints", result, err) + +} + +// 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 + } + + port := strings.TrimSpace(mcp.ParseString(request, "port", "")) + if port == "" { + return mcp.NewToolResultError("port 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", "policy"} + + if namespace != "" { + args = append(args, "-n", namespace) + } + + if destinationPod != "" { + args = append(args, "--destination-pod", destinationPod) + } + + if outputFormat != "" { + args = append(args, "-o", outputFormat) + } + + if token != "" { + args = append(args, "--token", token) + } + + args = append(args, resource, port) + + result, err := runLinkerdCommand(ctx, args) + return formatLinkerdCommandResult("linkerd diagnostics policy", result, err) + +} + +// ================================= +// 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 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)")), + ), 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: 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))) + + 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("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("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")), + ), 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")), + ), 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("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))) + + 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_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("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("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 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))) +} diff --git a/pkg/linkerd/linkerd_test.go b/pkg/linkerd/linkerd_test.go new file mode 100644 index 0000000..ce00f33 --- /dev/null +++ b/pkg/linkerd/linkerd_test.go @@ -0,0 +1,661 @@ +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" + "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) +} + +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() + + 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.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) + }) + + t.Run("ha install with overrides", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + 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", "--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", + "crds_only": "true", + "skip_checks": "true", + "identity_trust_anchors_pem": "anchors", + } + + result, err := handleLinkerdInstall(ctx, request) + require.NoError(t, err) + assert.False(t, result.IsError) + }) + + t.Run("install with advanced flags", func(t *testing.T) { + 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", + "--crds", + } + 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", + "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", + } + + 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) { + 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.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) + }) + + t.Run("install-cni with overrides", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + mock.AddPartialMatcherString("kubectl", []string{"apply", "-f"}, "applied", nil) + ctx = cmd.WithShellExecutor(ctx, mock) + + manifestMock := newMockManifestExecutor(t, []manifestExecCall{ + {args: []string{"install-cni", "--skip-checks"}, 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", + } + + 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.AddPartialMatcherString("kubectl", []string{"apply", "-f"}, "applied", nil) + ctx = cmd.WithShellExecutor(ctx, mock) + + manifestMock := newMockManifestExecutor(t, []manifestExecCall{ + {args: []string{"upgrade", "--crds", "--ha", "--skip-checks"}, stdout: "manifest"}, + }) + prev := linkerdManifestExecutor + linkerdManifestExecutor = manifestMock + t.Cleanup(func() { + manifestMock.assertDone() + linkerdManifestExecutor = prev + }) + + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]interface{}{ + "ha": "true", + "crds_only": "true", + "skip_checks": "true", + } + + 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.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", + } + + result, err := handleLinkerdUninstall(ctx, request) + require.NoError(t, err) + assert.False(t, result.IsError) +} + +func TestHandleLinkerdVersion(t *testing.T) { + ctx := context.Background() + + t.Run("client short", func(t *testing.T) { + 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) + }) + + t.Run("proxy namespace", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("linkerd", []string{"version", "--proxy", "-n", "linkerd"}, "version", nil) + ctx = cmd.WithShellExecutor(ctx, mock) + + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]interface{}{ + "proxy": "true", + "namespace": "linkerd", + } + + result, err := handleLinkerdVersion(ctx, request) + require.NoError(t, err) + assert.False(t, result.IsError) + }) + + t.Run("explicit false client flag", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("linkerd", []string{"version", "--client=false"}, "version", nil) + ctx = cmd.WithShellExecutor(ctx, mock) + + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]interface{}{ + "client_only": "false", + } + + result, err := handleLinkerdVersion(ctx, request) + require.NoError(t, err) + assert.False(t, result.IsError) + }) + + t.Run("invalid bool flag", func(t *testing.T) { + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]interface{}{ + "short": "maybe", + } + + result, err := handleLinkerdVersion(ctx, request) + require.NoError(t, err) + assert.True(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 TestHandleLinkerdDiagnosticsProxyMetrics(t *testing.T) { + ctx := context.Background() + + 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", "-n", "emojivoto", "--obfuscate", "deploy/web"}, "metrics", nil) + mockCtx = cmd.WithShellExecutor(mockCtx, mock) + + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]interface{}{ + "resource": "deploy/web", + "namespace": "emojivoto", + "obfuscate": "true", + } + + result, err := handleLinkerdDiagnosticsProxyMetrics(mockCtx, request) + require.NoError(t, err) + assert.False(t, result.IsError) + }) + + t.Run("without namespace", func(t *testing.T) { + mockCtx := context.Background() + mock := cmd.NewMockShellExecutor() + 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{}{ + "resource": "po/pod-foo", + } + + result, err := handleLinkerdDiagnosticsProxyMetrics(mockCtx, 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", "--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) + 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", "po/web-123", "8084"}, "policy", nil) + ctx = cmd.WithShellExecutor(ctx, mock) + + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]interface{}{ + "resource": "po/web-123", + "port": "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 TestHandleLinkerdFips(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 := handleLinkerdFips(ctx, request) + require.NoError(t, err) + assert.False(t, result.IsError) +} + +func TestHandleLinkerdPolicy(t *testing.T) { + t.Run("defaults to generate", func(t *testing.T) { + ctx := context.Background() + + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("linkerd", []string{"policy", "generate", "-n", "ns"}, "policy", nil) + ctx = cmd.WithShellExecutor(ctx, mock) + + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]interface{}{ + "namespace": "ns", + } + + result, err := handleLinkerdPolicy(ctx, request) + require.NoError(t, err) + assert.False(t, result.IsError) + }) + + t.Run("unsupported command", func(t *testing.T) { + ctx := context.Background() + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]interface{}{ + "command": "unknown", + } + + result, err := handleLinkerdPolicy(ctx, request) + require.NoError(t, err) + assert.True(t, result.IsError) + }) +}