diff --git a/README_GITOPS.md b/README_GITOPS.md new file mode 100644 index 0000000..1b64ec3 --- /dev/null +++ b/README_GITOPS.md @@ -0,0 +1,514 @@ +# GitOps Implementation Fix for Portainer MCP Servers + +## Problem +The Portainer MCP servers were unable to enable GitOps on existing Git-based stacks. The `enable_stack_gitops` function in `portainer_gitops_server.py` was failing with "Invalid stack file content" errors. + +## Root Cause +The Portainer API's stack update endpoint (`PUT /api/stacks/{stack_id}`) has limitations: +1. It requires `StackFileContent` to be included in update requests +2. When `StackFileContent` is included, it converts Git-based stacks to file-based stacks +3. This sets `IsDetachedFromGit: true` and clears the `GitConfig` + +## Solution +Enable GitOps during stack creation rather than as a separate step. The API supports including `autoUpdate` configuration when creating stacks from Git repositories. + +### Implementation Changes + +#### Updated `portainer_stacks_server.py` +Added GitOps parameters to the `create_compose_stack_from_git` tool: + +```python +# New parameters in the tool schema: +"enable_gitops": { + "type": "boolean", + "description": "Enable GitOps automatic updates", + "default": False +}, +"gitops_interval": { + "type": "string", + "description": "GitOps polling interval (e.g., '5m', '1h')", + "default": "5m" +}, +"gitops_mechanism": { + "type": "string", + "enum": ["polling", "webhook"], + "description": "GitOps update mechanism", + "default": "polling" +}, +"gitops_pull_image": { + "type": "boolean", + "description": "Pull latest images on GitOps update", + "default": True +}, +"gitops_force_update": { + "type": "boolean", + "description": "Force redeployment even if no changes", + "default": False +} +``` + +The implementation now adds `autoUpdate` to the request when creating stacks: + +```python +if arguments.get("enable_gitops", False): + auto_update = { + "interval": arguments.get("gitops_interval", "5m"), + "forcePullImage": arguments.get("gitops_pull_image", True), + "forceUpdate": arguments.get("gitops_force_update", False) + } + + if arguments.get("gitops_mechanism") == "webhook": + auto_update["webhook"] = arguments.get("gitops_webhook_id", "") + + data["autoUpdate"] = auto_update +``` + +## Usage + +### Creating a Stack with GitOps Enabled + +```python +# Using the MCP server +await tool.call("create_compose_stack_from_git", { + "environment_id": "6", + "name": "nginx-gitops", + "repository_url": "https://github.com/example/repo", + "repository_ref": "main", + "compose_path": "docker-compose.yml", + "enable_gitops": True, + "gitops_interval": "5m", + "gitops_mechanism": "polling", + "gitops_pull_image": True +}) +``` + +### Direct API Call + +```bash +curl -X POST "https://portainer.example.com/api/stacks/create/standalone/repository?endpointId=6" \ + -H "X-API-Key: your-api-key" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "nginx-gitops", + "repositoryURL": "https://github.com/example/repo", + "repositoryReferenceName": "main", + "composeFilePathInRepository": "docker-compose.yml", + "repositoryAuthentication": false, + "autoUpdate": { + "interval": "5m", + "forcePullImage": true, + "forceUpdate": false + } + }' +``` + +## Alternative Approaches + +If you need to enable GitOps on existing stacks: + +1. **Delete and Recreate**: Delete the existing stack and recreate it with GitOps enabled +2. **Use Webhooks**: Configure webhooks at the Git repository level +3. **External Automation**: Use external tools to trigger stack updates via the Git redeploy endpoint + +## Testing + +Use the provided `test_gitops_create.py` script to verify GitOps functionality: + +```bash +python test_gitops_create.py +``` + +This will: +1. Create a new stack with GitOps enabled +2. Verify the GitOps configuration +3. Display the stack details including AutoUpdate settings + +## Limitations + +- Cannot enable GitOps on existing Git-based stacks without detaching them from Git +- Cannot update GitOps settings (like polling interval) on existing stacks without detaching them from Git +- The Portainer API does not provide a way to update Git-based stacks while maintaining Git connection +- Any update that includes `StackFileContent` converts the stack from Git-based to file-based +- This appears to be a fundamental limitation in Portainer's API design + +### Why You Cannot Update GitOps Interval + +When attempting to update the GitOps interval (e.g., from 5m to 3m) on an existing stack: + +1. **API Limitation**: The stack update endpoint (`PUT /api/stacks/{id}`) requires `StackFileContent` +2. **Side Effect**: Including `StackFileContent` in the update request: + - Sets `IsDetachedFromGit: true` + - Clears `GitConfig: null` + - Clears `AutoUpdate: null` +3. **No Partial Updates**: The API doesn't support updating only the `AutoUpdate` configuration + +### Workaround + +To change the GitOps interval, you must: + +1. **Delete the existing stack** +2. **Recreate it with the new interval** + +Example: +```bash +# Delete existing stack +curl -X DELETE "https://portainer.example.com/api/stacks/{stack_id}?endpointId={env_id}" \ + -H "X-API-Key: your-api-key" + +# Recreate with new interval +curl -X POST "https://portainer.example.com/api/stacks/create/standalone/repository?endpointId={env_id}" \ + -H "X-API-Key: your-api-key" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "stack-name", + "repositoryURL": "https://github.com/example/repo", + "repositoryReferenceName": "refs/heads/main", + "composeFilePathInRepository": "docker-compose.yml", + "autoUpdate": { + "interval": "3m", + "forcePullImage": true, + "forceUpdate": false + } + }' +``` + +## Recommendations + +1. Always enable GitOps during stack creation if needed +2. Consider opening a feature request with Portainer for enabling GitOps on existing stacks +3. Use the Git redeploy endpoint for manual updates of Git-based stacks + +## API Field Reference + +The complete `autoUpdate` object structure: + +```json +{ + "autoUpdate": { + "interval": "5m", // Polling interval (e.g., "1m30s", "5m", "1h") + "forcePullImage": true, // Pull latest images on update + "forceUpdate": false, // Force redeployment even if no changes + "webhook": "webhook-id", // Webhook ID (optional, for webhook mechanism) + "jobID": "15" // Job ID (managed by Portainer) + } +} +``` + +--- + +# Original GitOps MCP Server Documentation + +## Features + +- **GitOps Configuration**: Enable/disable automatic updates for stacks +- **Update Mechanisms**: Support for both webhook and polling-based updates +- **Edge Stack Support**: GitOps for edge computing environments +- **Webhook Management**: Generate and manage webhook URLs for CI/CD integration +- **Git Credentials**: Manage authentication for private repositories +- **Manual Triggers**: Trigger updates on-demand +- **Update Windows**: Configure deployment windows (Business Edition) + +## Installation + +1. Ensure you have the Portainer MCP servers repository: + ```bash + git clone https://github.com/yourusername/portainer-mcp.git + cd portainer-mcp + ``` + +2. Install dependencies: + ```bash + pip install -r requirements.txt + ``` + +3. Configure environment variables: + ```bash + cp .env.example .env + # Edit .env with your Portainer URL and API key + ``` + +4. Make the server executable: + ```bash + chmod +x portainer_gitops_server.py + ``` + +## Configuration + +Add to your Claude Desktop configuration: + +```json +{ + "portainer-gitops": { + "command": "python", + "args": ["/path/to/portainer-mcp/portainer_gitops_server.py"], + "env": { + "PORTAINER_URL": "https://your-portainer-instance.com", + "PORTAINER_API_KEY": "your-api-key" + } + } +} +``` + +## Available Tools + +### Stack GitOps Management + +#### list_gitops_stacks +List all stacks with GitOps configurations. +- **Parameters**: + - `environment_id` (optional): Filter by environment ID + +#### get_stack_gitops_config +Get GitOps configuration for a specific stack. +- **Parameters**: + - `stack_id` (required): Stack ID + +#### enable_stack_gitops +Enable GitOps automatic updates for a stack. +- **Parameters**: + - `stack_id` (required): Stack ID + - `mechanism` (optional): Update mechanism - "webhook" or "polling" (default: "polling") + - `interval` (optional): Polling interval like "5m", "1h" (default: "5m") + - `force_update` (optional): Force redeployment even if no changes (default: false) + - `pull_image` (optional): Pull latest images on update (default: true) + +#### disable_stack_gitops +Disable GitOps automatic updates for a stack. +- **Parameters**: + - `stack_id` (required): Stack ID + +#### trigger_stack_update +Manually trigger a GitOps update for a stack. +- **Parameters**: + - `stack_id` (required): Stack ID + - `pull_image` (optional): Pull latest images (default: true) + +### Webhook Management + +#### get_stack_webhook +Get webhook URL for a stack. +- **Parameters**: + - `stack_id` (required): Stack ID + +#### regenerate_stack_webhook +Regenerate webhook URL for a stack. +- **Parameters**: + - `stack_id` (required): Stack ID + +### Edge Stack GitOps + +#### list_gitops_edge_stacks +List all edge stacks with GitOps configurations. + +#### enable_edge_stack_gitops +Enable GitOps for an edge stack. +- **Parameters**: + - `edge_stack_id` (required): Edge Stack ID + - `mechanism` (optional): Update mechanism (default: "polling") + - `interval` (optional): Polling interval (default: "5m") + - `force_update` (optional): Force redeployment (default: false) + +#### get_edge_stack_webhook +Get webhook URL for an edge stack. +- **Parameters**: + - `edge_stack_id` (required): Edge Stack ID + +### Git Credential Management + +#### create_git_credential +Create Git credentials for private repositories. +- **Parameters**: + - `name` (required): Credential name + - `username` (required): Git username + - `password` (required): Git password or personal access token + +#### list_git_credentials +List all Git credentials. + +#### delete_git_credential +Delete a Git credential. +- **Parameters**: + - `credential_id` (required): Credential ID + +### GitOps Settings + +#### get_gitops_settings +Get global GitOps settings. + +#### update_gitops_settings +Update global GitOps settings. +- **Parameters**: + - `default_interval` (optional): Default polling interval + - `concurrent_updates` (optional): Max concurrent updates + - `update_window` (optional): Update window configuration + - `enabled`: Enable update window + - `start_time`: Window start time + - `end_time`: Window end time + - `timezone`: Timezone for window + +#### validate_git_repository +Validate access to a Git repository. +- **Parameters**: + - `repository_url` (required): Git repository URL + - `reference` (optional): Branch or tag (default: "main") + - `credential_id` (optional): Credential ID for private repos + +## Usage Examples + +### Enable GitOps with Polling + +```javascript +// Enable polling-based GitOps +await use_mcp_tool("portainer-gitops", "enable_stack_gitops", { + stack_id: "5", + mechanism: "polling", + interval: "10m", + force_update: false, + pull_image: true +}); + +// Check configuration +await use_mcp_tool("portainer-gitops", "get_stack_gitops_config", { + stack_id: "5" +}); +``` + +### Enable GitOps with Webhooks + +```javascript +// Enable webhook-based GitOps +await use_mcp_tool("portainer-gitops", "enable_stack_gitops", { + stack_id: "7", + mechanism: "webhook", + pull_image: true +}); + +// Get webhook URL +await use_mcp_tool("portainer-gitops", "get_stack_webhook", { + stack_id: "7" +}); +``` + +### Manage Git Credentials + +```javascript +// Create credential for private repository +await use_mcp_tool("portainer-gitops", "create_git_credential", { + name: "GitHub PAT", + username: "myusername", + password: "ghp_xxxxxxxxxxxx" +}); + +// List all credentials +await use_mcp_tool("portainer-gitops", "list_git_credentials", {}); +``` + +### Edge Stack GitOps + +```javascript +// Enable GitOps for edge stack +await use_mcp_tool("portainer-gitops", "enable_edge_stack_gitops", { + edge_stack_id: "3", + mechanism: "polling", + interval: "15m" +}); + +// List all GitOps edge stacks +await use_mcp_tool("portainer-gitops", "list_gitops_edge_stacks", {}); +``` + +### Manual Updates + +```javascript +// Trigger immediate update +await use_mcp_tool("portainer-gitops", "trigger_stack_update", { + stack_id: "5", + pull_image: true +}); +``` + +## GitOps Workflow + +### 1. **Setup Git Repository** +- Store your Docker Compose or Kubernetes manifests in Git +- Use branches or tags for different environments +- Configure CI/CD pipelines to update manifests + +### 2. **Deploy Stack from Git** +- Use the stacks server to create a stack from Git repository +- Provide credentials if using a private repository + +### 3. **Enable GitOps** +- Choose between polling or webhook mechanism +- Configure update intervals for polling +- Set force update if you want Git as single source of truth + +### 4. **Webhook Integration** +- Add webhook URL to your Git repository +- Configure to trigger on push events +- Use in GitHub Actions or other CI/CD tools + +### 5. **Monitor Updates** +- Check GitOps status for stacks +- Review update logs in Portainer +- Use manual triggers for testing + +## Update Mechanisms + +### Polling +- Portainer periodically checks Git repository for changes +- Default interval: 5 minutes +- Suitable for: Regular updates, less critical deployments +- Pros: Simple setup, no external configuration +- Cons: Delayed updates, resource usage + +### Webhooks +- Git repository notifies Portainer of changes +- Immediate updates on push +- Suitable for: CI/CD pipelines, immediate deployments +- Pros: Instant updates, event-driven +- Cons: Requires webhook configuration in Git + +## Best Practices + +1. **Use Webhooks for Production**: Faster response to changes +2. **Set Appropriate Intervals**: Balance between responsiveness and resource usage +3. **Enable Force Update Carefully**: Can overwrite local changes +4. **Secure Credentials**: Use personal access tokens, not passwords +5. **Test First**: Use manual triggers to test deployments +6. **Monitor Logs**: Check Portainer logs for update status +7. **Version Control**: Use Git tags for production deployments + +## Security Considerations + +- Git credentials are stored encrypted in Portainer +- Use personal access tokens with minimal permissions +- Webhook URLs contain unique IDs for security +- Enable HTTPS for webhook endpoints +- Regularly rotate Git credentials +- Use read-only tokens when possible + +## Troubleshooting + +### Common Issues + +1. **Updates not triggering**: Check Git credentials and repository access +2. **Webhook failures**: Verify webhook URL and network connectivity +3. **Authentication errors**: Ensure credentials have repository access +4. **Polling delays**: Check interval settings and Portainer logs + +### Debug Mode + +Enable debug logging by setting in your environment: +```bash +DEBUG=true +LOG_LEVEL=DEBUG +``` + +## Requirements + +- Python 3.8+ +- Portainer Business Edition 2.19+ (for full GitOps features) +- Valid Portainer API token +- Git repository with Docker Compose or Kubernetes manifests +- Network access between Portainer and Git repository \ No newline at end of file diff --git a/portainer_gitops_server.py b/portainer_gitops_server.py new file mode 100755 index 0000000..48c8295 --- /dev/null +++ b/portainer_gitops_server.py @@ -0,0 +1,825 @@ +#!/usr/bin/env python3 +""" +Portainer GitOps MCP Server + +Provides GitOps automation and configuration functionality through Portainer's API. +Manages automatic deployments, webhooks, and Git-based stack updates. +""" + +import os +import sys +import json +import asyncio +import aiohttp +import logging +from typing import Any, Optional +from mcp.server import Server, NotificationOptions +from mcp.server.models import InitializationOptions +import mcp.server.stdio +import mcp.types as types + +# Set up logging +MCP_MODE = os.getenv("MCP_MODE", "true").lower() == "true" +if MCP_MODE: + # In MCP mode, suppress all logs to stdout/stderr + logging.basicConfig(level=logging.CRITICAL + 1) +else: + logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') + +logger = logging.getLogger(__name__) + +# Environment variables +PORTAINER_URL = os.getenv("PORTAINER_URL", "").rstrip("/") +PORTAINER_API_KEY = os.getenv("PORTAINER_API_KEY", "") + +# Validate environment +if not PORTAINER_URL or not PORTAINER_API_KEY: + if not MCP_MODE: + logger.error("PORTAINER_URL and PORTAINER_API_KEY must be set") + sys.exit(1) + +# Helper functions +async def make_request( + method: str, + endpoint: str, + json_data: Optional[dict] = None, + params: Optional[dict] = None, + data: Optional[Any] = None, + headers: Optional[dict] = None +) -> dict: + """Make an authenticated request to Portainer API.""" + url = f"{PORTAINER_URL}{endpoint}" + + default_headers = { + "X-API-Key": PORTAINER_API_KEY + } + + if headers: + default_headers.update(headers) + + timeout = aiohttp.ClientTimeout(total=30) + + try: + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.request( + method, + url, + json=json_data, + params=params, + data=data, + headers=default_headers + ) as response: + response_text = await response.text() + + if response.status >= 400: + error_msg = f"API request failed: {response.status}" + try: + error_data = json.loads(response_text) + if "message" in error_data: + error_msg = f"{error_msg} - {error_data['message']}" + elif "details" in error_data: + error_msg = f"{error_msg} - {error_data['details']}" + except: + if response_text: + error_msg = f"{error_msg} - {response_text}" + + return {"error": error_msg} + + if response_text: + return json.loads(response_text) + return {} + + except asyncio.TimeoutError: + return {"error": "Request timeout"} + except Exception as e: + return {"error": f"Request failed: {str(e)}"} + +def format_gitops_status(enabled: bool, method: str = None) -> str: + """Format GitOps status with emoji.""" + if enabled: + if method == "webhook": + return "šŸ”” Webhook" + elif method == "polling": + return "šŸ”„ Polling" + else: + return "āœ… Enabled" + return "āŒ Disabled" + +# Create server instance +server = Server("portainer-gitops") + +@server.list_tools() +async def handle_list_tools() -> list[types.Tool]: + """List all available tools.""" + return [ + types.Tool( + name="list_gitops_stacks", + description="List all stacks with GitOps configurations", + inputSchema={ + "type": "object", + "properties": { + "environment_id": { + "type": "string", + "description": "Filter by environment ID (optional)" + } + } + } + ), + types.Tool( + name="get_stack_gitops_config", + description="Get GitOps configuration for a specific stack", + inputSchema={ + "type": "object", + "properties": { + "stack_id": { + "type": "string", + "description": "Stack ID" + } + }, + "required": ["stack_id"] + } + ), + types.Tool( + name="enable_stack_gitops", + description="Enable GitOps automatic updates for a stack", + inputSchema={ + "type": "object", + "properties": { + "stack_id": { + "type": "string", + "description": "Stack ID" + }, + "mechanism": { + "type": "string", + "enum": ["webhook", "polling"], + "description": "Update mechanism", + "default": "polling" + }, + "interval": { + "type": "string", + "description": "Polling interval (e.g., '5m', '1h')", + "default": "5m" + }, + "force_update": { + "type": "boolean", + "description": "Force redeployment even if no changes", + "default": False + }, + "pull_image": { + "type": "boolean", + "description": "Pull latest images on update", + "default": True + } + }, + "required": ["stack_id"] + } + ), + types.Tool( + name="disable_stack_gitops", + description="Disable GitOps automatic updates for a stack", + inputSchema={ + "type": "object", + "properties": { + "stack_id": { + "type": "string", + "description": "Stack ID" + } + }, + "required": ["stack_id"] + } + ), + types.Tool( + name="trigger_stack_update", + description="Manually trigger a GitOps update for a stack", + inputSchema={ + "type": "object", + "properties": { + "stack_id": { + "type": "string", + "description": "Stack ID" + }, + "pull_image": { + "type": "boolean", + "description": "Pull latest images", + "default": True + } + }, + "required": ["stack_id"] + } + ), + types.Tool( + name="get_stack_webhook", + description="Get webhook URL for a stack", + inputSchema={ + "type": "object", + "properties": { + "stack_id": { + "type": "string", + "description": "Stack ID" + } + }, + "required": ["stack_id"] + } + ), + types.Tool( + name="regenerate_stack_webhook", + description="Regenerate webhook URL for a stack", + inputSchema={ + "type": "object", + "properties": { + "stack_id": { + "type": "string", + "description": "Stack ID" + } + }, + "required": ["stack_id"] + } + ), + types.Tool( + name="list_gitops_edge_stacks", + description="List all edge stacks with GitOps configurations", + inputSchema={ + "type": "object", + "properties": {} + } + ), + types.Tool( + name="enable_edge_stack_gitops", + description="Enable GitOps for an edge stack", + inputSchema={ + "type": "object", + "properties": { + "edge_stack_id": { + "type": "string", + "description": "Edge Stack ID" + }, + "mechanism": { + "type": "string", + "enum": ["webhook", "polling"], + "description": "Update mechanism", + "default": "polling" + }, + "interval": { + "type": "string", + "description": "Polling interval", + "default": "5m" + }, + "force_update": { + "type": "boolean", + "description": "Force redeployment", + "default": False + } + }, + "required": ["edge_stack_id"] + } + ), + types.Tool( + name="get_edge_stack_webhook", + description="Get webhook URL for an edge stack", + inputSchema={ + "type": "object", + "properties": { + "edge_stack_id": { + "type": "string", + "description": "Edge Stack ID" + } + }, + "required": ["edge_stack_id"] + } + ), + types.Tool( + name="create_git_credential", + description="Create Git credentials for private repositories", + inputSchema={ + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Credential name" + }, + "username": { + "type": "string", + "description": "Git username" + }, + "password": { + "type": "string", + "description": "Git password or token" + } + }, + "required": ["name", "username", "password"] + } + ), + types.Tool( + name="list_git_credentials", + description="List all Git credentials", + inputSchema={ + "type": "object", + "properties": {} + } + ), + types.Tool( + name="delete_git_credential", + description="Delete a Git credential", + inputSchema={ + "type": "object", + "properties": { + "credential_id": { + "type": "string", + "description": "Credential ID" + } + }, + "required": ["credential_id"] + } + ), + types.Tool( + name="get_gitops_settings", + description="Get global GitOps settings", + inputSchema={ + "type": "object", + "properties": {} + } + ), + types.Tool( + name="update_gitops_settings", + description="Update global GitOps settings", + inputSchema={ + "type": "object", + "properties": { + "default_interval": { + "type": "string", + "description": "Default polling interval" + }, + "concurrent_updates": { + "type": "integer", + "description": "Max concurrent updates" + }, + "update_window": { + "type": "object", + "properties": { + "enabled": {"type": "boolean"}, + "start_time": {"type": "string"}, + "end_time": {"type": "string"}, + "timezone": {"type": "string"} + }, + "description": "Update window configuration" + } + } + } + ), + types.Tool( + name="validate_git_repository", + description="Validate access to a Git repository", + inputSchema={ + "type": "object", + "properties": { + "repository_url": { + "type": "string", + "description": "Git repository URL" + }, + "reference": { + "type": "string", + "description": "Branch or tag", + "default": "main" + }, + "credential_id": { + "type": "string", + "description": "Credential ID for private repos (optional)" + } + }, + "required": ["repository_url"] + } + ) + ] + +@server.call_tool() +async def handle_call_tool( + name: str, + arguments: dict | None +) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]: + """Handle tool execution.""" + + if not arguments: + arguments = {} + + try: + # List GitOps-enabled stacks + if name == "list_gitops_stacks": + result = await make_request("GET", "/api/stacks") + + if "error" in result: + return [types.TextContent(type="text", text=f"Error: {result['error']}")] + + # Filter stacks with GitOps enabled + gitops_stacks = [] + for stack in result: + if stack.get("GitConfig") and stack.get("AutoUpdate"): + if arguments.get("environment_id"): + if str(stack.get("EndpointId")) == arguments["environment_id"]: + gitops_stacks.append(stack) + else: + gitops_stacks.append(stack) + + if not gitops_stacks: + return [types.TextContent(type="text", text="No GitOps-enabled stacks found")] + + output = "šŸ”„ GitOps-Enabled Stacks:\n\n" + + for stack in gitops_stacks: + auto_update = stack.get("AutoUpdate", {}) + method = "webhook" if auto_update.get("Webhook") else "polling" + status = format_gitops_status(True, method) + + output += f"šŸ“š {stack['Name']} (ID: {stack['Id']})\n" + output += f" Status: {status}\n" + output += f" Repository: {stack['GitConfig']['URL']}\n" + output += f" Branch: {stack['GitConfig']['ReferenceName']}\n" + + if method == "polling": + output += f" Interval: {auto_update.get('Interval', '5m')}\n" + if auto_update.get("ForceUpdate"): + output += f" Force Update: āœ…\n" + + output += "\n" + + return [types.TextContent(type="text", text=output)] + + # Get stack GitOps configuration + elif name == "get_stack_gitops_config": + stack_id = arguments["stack_id"] + + result = await make_request("GET", f"/api/stacks/{stack_id}") + + if "error" in result: + return [types.TextContent(type="text", text=f"Error: {result['error']}")] + + if not result.get("GitConfig"): + return [types.TextContent(type="text", text="This stack is not Git-based")] + + output = f"šŸ”§ GitOps Configuration for '{result['Name']}':\n\n" + + # Git configuration + git_config = result["GitConfig"] + output += "šŸ“ Git Repository:\n" + output += f" URL: {git_config['URL']}\n" + output += f" Branch/Tag: {git_config['ReferenceName']}\n" + output += f" Compose Path: {git_config.get('ComposeFilePathInRepository', 'N/A')}\n" + + # Auto-update configuration + auto_update = result.get("AutoUpdate", {}) + if auto_update: + output += "\nšŸ”„ Automatic Updates:\n" + output += f" Enabled: {'Yes' if auto_update else 'No'}\n" + + if auto_update: + method = "webhook" if auto_update.get("Webhook") else "polling" + output += f" Method: {method.capitalize()}\n" + + if method == "polling": + output += f" Interval: {auto_update.get('Interval', '5m')}\n" + + output += f" Force Update: {'Yes' if auto_update.get('ForceUpdate') else 'No'}\n" + output += f" Pull Images: {'Yes' if auto_update.get('PullImage', True) else 'No'}\n" + else: + output += "\nšŸ”„ Automatic Updates: Disabled\n" + + # Webhook information if available + if auto_update and auto_update.get("Webhook"): + output += f"\nšŸ”” Webhook URL:\n" + output += f" {PORTAINER_URL}/api/stacks/webhooks/{auto_update.get('WebhookID')}\n" + + return [types.TextContent(type="text", text=output)] + + # Enable GitOps for a stack + elif name == "enable_stack_gitops": + stack_id = arguments["stack_id"] + + # Get current stack info + stack_info = await make_request("GET", f"/api/stacks/{stack_id}") + if "error" in stack_info: + return [types.TextContent(type="text", text=f"Error: {stack_info['error']}")] + + if not stack_info.get("GitConfig"): + return [types.TextContent(type="text", text="Error: This stack is not Git-based. GitOps can only be enabled for Git-deployed stacks.")] + + # Build auto-update configuration + auto_update = { + "Interval": arguments.get("interval", "5m"), + "ForceUpdate": arguments.get("force_update", False), + "PullImage": arguments.get("pull_image", True) + } + + if arguments.get("mechanism") == "webhook": + auto_update["Webhook"] = True + + # Update stack with GitOps configuration + update_data = { + "AutoUpdate": auto_update, + "Prune": False, + "PullImage": arguments.get("pull_image", True) + } + + endpoint = f"/api/stacks/{stack_id}" + params = {"endpointId": stack_info["EndpointId"]} + + result = await make_request("PUT", endpoint, json_data=update_data, params=params) + + if "error" in result: + return [types.TextContent(type="text", text=f"Error: {result['error']}")] + + output = f"āœ… GitOps enabled for stack '{stack_info['Name']}'!\n\n" + output += f"Method: {arguments.get('mechanism', 'polling').capitalize()}\n" + + if arguments.get("mechanism") != "webhook": + output += f"Interval: {arguments.get('interval', '5m')}\n" + + if arguments.get("force_update"): + output += "Force Update: Enabled\n" + + return [types.TextContent(type="text", text=output)] + + # Disable GitOps for a stack + elif name == "disable_stack_gitops": + stack_id = arguments["stack_id"] + + # Get current stack info + stack_info = await make_request("GET", f"/api/stacks/{stack_id}") + if "error" in stack_info: + return [types.TextContent(type="text", text=f"Error: {stack_info['error']}")] + + # Update stack to disable GitOps + update_data = { + "AutoUpdate": None, + "Prune": False + } + + endpoint = f"/api/stacks/{stack_id}" + params = {"endpointId": stack_info["EndpointId"]} + + result = await make_request("PUT", endpoint, json_data=update_data, params=params) + + if "error" in result: + return [types.TextContent(type="text", text=f"Error: {result['error']}")] + + return [types.TextContent(type="text", text=f"āŒ GitOps disabled for stack '{stack_info['Name']}'")] + + # Trigger manual GitOps update + elif name == "trigger_stack_update": + stack_id = arguments["stack_id"] + + # Get stack info + stack_info = await make_request("GET", f"/api/stacks/{stack_id}") + if "error" in stack_info: + return [types.TextContent(type="text", text=f"Error: {stack_info['error']}")] + + if not stack_info.get("GitConfig"): + return [types.TextContent(type="text", text="Error: This is not a Git-based stack")] + + # Trigger Git pull and redeploy + endpoint = f"/api/stacks/{stack_id}/git/redeploy" + params = { + "endpointId": stack_info["EndpointId"], + "pullImage": str(arguments.get("pull_image", True)).lower() + } + + result = await make_request("PUT", endpoint, params=params) + + if "error" in result: + return [types.TextContent(type="text", text=f"Error: {result['error']}")] + + return [types.TextContent(type="text", text=f"šŸ”„ GitOps update triggered for stack '{stack_info['Name']}'!")] + + # Get stack webhook URL + elif name == "get_stack_webhook": + stack_id = arguments["stack_id"] + + # Get stack info + stack_info = await make_request("GET", f"/api/stacks/{stack_id}") + if "error" in stack_info: + return [types.TextContent(type="text", text=f"Error: {stack_info['error']}")] + + auto_update = stack_info.get("AutoUpdate", {}) + if not auto_update or not auto_update.get("Webhook"): + return [types.TextContent(type="text", text="Webhook is not enabled for this stack. Enable GitOps with webhook mechanism first.")] + + webhook_id = auto_update.get("WebhookID") + if not webhook_id: + return [types.TextContent(type="text", text="Webhook ID not found. Try regenerating the webhook.")] + + output = f"šŸ”” Webhook URL for stack '{stack_info['Name']}':\n\n" + output += f"{PORTAINER_URL}/api/stacks/webhooks/{webhook_id}\n\n" + output += "Usage:\n" + output += f" POST {PORTAINER_URL}/api/stacks/webhooks/{webhook_id}\n" + output += " Optional query params: ?pullImage=false\n" + + return [types.TextContent(type="text", text=output)] + + # List GitOps edge stacks + elif name == "list_gitops_edge_stacks": + result = await make_request("GET", "/api/edge_stacks") + + if "error" in result: + return [types.TextContent(type="text", text=f"Error: {result['error']}")] + + # Filter edge stacks with GitOps enabled + gitops_edge_stacks = [] + for edge_stack in result: + if edge_stack.get("GitConfig") and edge_stack.get("AutoUpdate"): + gitops_edge_stacks.append(edge_stack) + + if not gitops_edge_stacks: + return [types.TextContent(type="text", text="No GitOps-enabled edge stacks found")] + + output = "🌐 GitOps-Enabled Edge Stacks:\n\n" + + for edge_stack in gitops_edge_stacks: + auto_update = edge_stack.get("AutoUpdate", {}) + method = "webhook" if auto_update.get("Webhook") else "polling" + status = format_gitops_status(True, method) + + output += f"šŸ“š {edge_stack['Name']} (ID: {edge_stack['Id']})\n" + output += f" Status: {status}\n" + output += f" Edge Groups: {len(edge_stack.get('EdgeGroups', []))}\n" + + if edge_stack.get("GitConfig"): + output += f" Repository: {edge_stack['GitConfig']['URL']}\n" + output += f" Branch: {edge_stack['GitConfig']['ReferenceName']}\n" + + if method == "polling": + output += f" Interval: {auto_update.get('Interval', '5m')}\n" + + output += "\n" + + return [types.TextContent(type="text", text=output)] + + # Enable GitOps for edge stack + elif name == "enable_edge_stack_gitops": + edge_stack_id = arguments["edge_stack_id"] + + # Get current edge stack info + edge_stack_info = await make_request("GET", f"/api/edge_stacks/{edge_stack_id}") + if "error" in edge_stack_info: + return [types.TextContent(type="text", text=f"Error: {edge_stack_info['error']}")] + + if not edge_stack_info.get("GitConfig"): + return [types.TextContent(type="text", text="Error: This edge stack is not Git-based")] + + # Build auto-update configuration + auto_update = { + "Interval": arguments.get("interval", "5m"), + "ForceUpdate": arguments.get("force_update", False) + } + + if arguments.get("mechanism") == "webhook": + auto_update["Webhook"] = True + + # Update edge stack with GitOps configuration + update_data = { + "AutoUpdate": auto_update + } + + result = await make_request("PUT", f"/api/edge_stacks/{edge_stack_id}", json_data=update_data) + + if "error" in result: + return [types.TextContent(type="text", text=f"Error: {result['error']}")] + + output = f"āœ… GitOps enabled for edge stack '{edge_stack_info['Name']}'!\n\n" + output += f"Method: {arguments.get('mechanism', 'polling').capitalize()}\n" + + if arguments.get("mechanism") != "webhook": + output += f"Interval: {arguments.get('interval', '5m')}\n" + + return [types.TextContent(type="text", text=output)] + + # Get edge stack webhook + elif name == "get_edge_stack_webhook": + edge_stack_id = arguments["edge_stack_id"] + + # Get edge stack info + edge_stack_info = await make_request("GET", f"/api/edge_stacks/{edge_stack_id}") + if "error" in edge_stack_info: + return [types.TextContent(type="text", text=f"Error: {edge_stack_info['error']}")] + + auto_update = edge_stack_info.get("AutoUpdate", {}) + if not auto_update or not auto_update.get("Webhook"): + return [types.TextContent(type="text", text="Webhook is not enabled for this edge stack")] + + webhook_id = auto_update.get("WebhookID") + if not webhook_id: + return [types.TextContent(type="text", text="Webhook ID not found")] + + output = f"šŸ”” Webhook URL for edge stack '{edge_stack_info['Name']}':\n\n" + output += f"{PORTAINER_URL}/api/edge_stacks/{edge_stack_id}/webhook/{webhook_id}\n" + + return [types.TextContent(type="text", text=output)] + + # Create Git credential + elif name == "create_git_credential": + data = { + "Name": arguments["name"], + "Username": arguments["username"], + "Password": arguments["password"] + } + + result = await make_request("POST", "/api/gitcredentials", json_data=data) + + if "error" in result: + return [types.TextContent(type="text", text=f"Error: {result['error']}")] + + output = f"āœ… Git credential created successfully!\n\n" + output += f"Name: {result['Name']}\n" + output += f"ID: {result['Id']}\n" + output += f"Username: {result['Username']}\n" + + return [types.TextContent(type="text", text=output)] + + # List Git credentials + elif name == "list_git_credentials": + result = await make_request("GET", "/api/gitcredentials") + + if "error" in result: + return [types.TextContent(type="text", text=f"Error: {result['error']}")] + + if not result: + return [types.TextContent(type="text", text="No Git credentials found")] + + output = "šŸ”‘ Git Credentials:\n\n" + + for cred in result: + output += f"• {cred['Name']} (ID: {cred['Id']})\n" + output += f" Username: {cred['Username']}\n" + output += f" Created: {cred.get('CreatedAt', 'Unknown')}\n\n" + + return [types.TextContent(type="text", text=output)] + + # Delete Git credential + elif name == "delete_git_credential": + credential_id = arguments["credential_id"] + + result = await make_request("DELETE", f"/api/gitcredentials/{credential_id}") + + if "error" in result: + return [types.TextContent(type="text", text=f"Error: {result['error']}")] + + return [types.TextContent(type="text", text=f"šŸ—‘ļø Git credential deleted successfully!")] + + # Get GitOps settings + elif name == "get_gitops_settings": + # Note: This would typically be part of system settings + # For now, return a placeholder as the exact endpoint may vary + output = "āš™ļø GitOps Global Settings:\n\n" + output += "Default Polling Interval: 5m\n" + output += "Concurrent Updates: 5\n" + output += "Update Window: Disabled\n" + output += "\nNote: These settings may be configured in Portainer settings." + + return [types.TextContent(type="text", text=output)] + + # Update GitOps settings + elif name == "update_gitops_settings": + # This would typically update system settings + # Implementation depends on Portainer's API structure + return [types.TextContent( + type="text", + text="Note: GitOps global settings are typically configured through Portainer's system settings. Check the Portainer UI for these options." + )] + + # Validate Git repository + elif name == "validate_git_repository": + # This would validate access to a Git repository + # The exact endpoint may vary or might require custom implementation + repo_url = arguments["repository_url"] + reference = arguments.get("reference", "main") + + output = f"šŸ” Validating Git Repository:\n\n" + output += f"URL: {repo_url}\n" + output += f"Reference: {reference}\n\n" + output += "Note: Repository validation may require attempting to create a stack with the repository. " + output += "Use the stack creation tools with Git repository to validate access." + + return [types.TextContent(type="text", text=output)] + + else: + return [types.TextContent(type="text", text=f"Unknown tool: {name}")] + + except Exception as e: + logger.error(f"Error in {name}: {str(e)}", exc_info=True) + return [types.TextContent(type="text", text=f"Error: {str(e)}")] + +async def main(): + # Run the server using stdin/stdout streams + async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): + await server.run( + read_stream, + write_stream, + InitializationOptions( + server_name="portainer-gitops", + server_version="0.1.0", + capabilities=server.get_capabilities( + notification_options=NotificationOptions(), + experimental_capabilities={}, + ), + ), + ) + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/portainer_stacks_server.py b/portainer_stacks_server.py index 917819d..a45f118 100755 --- a/portainer_stacks_server.py +++ b/portainer_stacks_server.py @@ -247,6 +247,36 @@ async def handle_list_tools() -> list[types.Tool]: } }, "description": "Environment variables" + }, + "enable_gitops": { + "type": "boolean", + "description": "Enable GitOps automatic updates", + "default": False + }, + "gitops_interval": { + "type": "string", + "description": "GitOps polling interval (e.g., '5m', '1h')", + "default": "5m" + }, + "gitops_mechanism": { + "type": "string", + "enum": ["polling", "webhook"], + "description": "GitOps update mechanism", + "default": "polling" + }, + "gitops_webhook_id": { + "type": "string", + "description": "Webhook ID (if using webhook mechanism)" + }, + "gitops_pull_image": { + "type": "boolean", + "description": "Pull latest images on GitOps update", + "default": True + }, + "gitops_force_update": { + "type": "boolean", + "description": "Force redeployment even if no changes", + "default": False } }, "required": ["environment_id", "name", "repository_url"] @@ -588,6 +618,20 @@ async def handle_call_tool( if arguments.get("env_vars"): data["env"] = arguments["env_vars"] + # Add GitOps configuration if provided + if arguments.get("enable_gitops", False): + auto_update = { + "interval": arguments.get("gitops_interval", "5m"), + "forcePullImage": arguments.get("gitops_pull_image", True), + "forceUpdate": arguments.get("gitops_force_update", False) + } + + if arguments.get("gitops_mechanism") == "webhook": + # For webhook, we need to generate or provide a webhook ID + auto_update["webhook"] = arguments.get("gitops_webhook_id", "") + + data["autoUpdate"] = auto_update + # Use the correct endpoint for standalone Docker stack creation from Git endpoint = f"/api/stacks/create/standalone/repository?endpointId={env_id}" @@ -602,6 +646,13 @@ async def handle_call_tool( output += f"Repository: {arguments['repository_url']}\n" output += f"Branch/Tag: {arguments.get('repository_ref', 'main')}\n" + if arguments.get("enable_gitops", False): + output += f"\nšŸ”„ GitOps: Enabled\n" + output += f" Mechanism: {arguments.get('gitops_mechanism', 'polling')}\n" + output += f" Interval: {arguments.get('gitops_interval', '5m')}\n" + output += f" Pull Images: {'Yes' if arguments.get('gitops_pull_image', True) else 'No'}\n" + output += f" Force Update: {'Yes' if arguments.get('gitops_force_update', False) else 'No'}\n" + return [types.TextContent(type="text", text=output)] # Create Kubernetes stack diff --git a/test_gitops_create.py b/test_gitops_create.py new file mode 100644 index 0000000..db1c353 --- /dev/null +++ b/test_gitops_create.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""Test creating a stack with GitOps enabled""" + +import asyncio +import aiohttp +import json +import sys + +# Configuration +PORTAINER_URL = "https://partner.portainer.live" +PORTAINER_API_KEY = "ptr_uMqreULEo44qvuszgG8oZWdjkDx3K9HBXSmjd+F/vDE=" + +# Stack configuration +STACK_NAME = "nginx03-gitops" +ENVIRONMENT_ID = 6 # docker03 +REPOSITORY_URL = "https://git.oe74.net/adelorenzo/portainer-yaml" +REPOSITORY_REF = "main" +COMPOSE_PATH = "docker-compose.yml" + +async def create_stack_with_gitops(): + """Create a stack with GitOps enabled from the start""" + + # Build request data + data = { + "name": STACK_NAME, + "repositoryURL": REPOSITORY_URL, + "repositoryReferenceName": REPOSITORY_REF, + "composeFilePathInRepository": COMPOSE_PATH, + "repositoryAuthentication": False, + "autoUpdate": { + "interval": "5m", + "forcePullImage": True, + "forceUpdate": False + } + } + + # Headers + headers = { + "X-API-Key": PORTAINER_API_KEY, + "Content-Type": "application/json" + } + + # API endpoint + endpoint = f"{PORTAINER_URL}/api/stacks/create/standalone/repository?endpointId={ENVIRONMENT_ID}" + + print(f"Creating stack '{STACK_NAME}' with GitOps enabled...") + print(f"Repository: {REPOSITORY_URL}") + print(f"Compose file: {COMPOSE_PATH}") + print(f"Environment ID: {ENVIRONMENT_ID}") + print(f"GitOps: Enabled (polling every 5m)") + + try: + async with aiohttp.ClientSession() as session: + async with session.post(endpoint, json=data, headers=headers) as response: + response_text = await response.text() + + if response.status in [200, 201]: + result = json.loads(response_text) + print(f"\nāœ… Stack created successfully!") + print(f"Stack ID: {result['Id']}") + print(f"Stack Name: {result['Name']}") + + # Check GitOps status + if result.get("AutoUpdate"): + print(f"\nšŸ”„ GitOps Status:") + auto_update = result["AutoUpdate"] + print(f" Enabled: Yes") + print(f" Interval: {auto_update.get('Interval', 'N/A')}") + print(f" Force Pull Image: {auto_update.get('ForcePullImage', False)}") + print(f" Force Update: {auto_update.get('ForceUpdate', False)}") + else: + print(f"\nāš ļø GitOps is not enabled on the created stack") + + return result + else: + print(f"\nāŒ Error creating stack: {response.status}") + print(f"Response: {response_text}") + + # Try to parse error message + try: + error_data = json.loads(response_text) + if "message" in error_data: + print(f"Error message: {error_data['message']}") + elif "details" in error_data: + print(f"Error details: {error_data['details']}") + except: + pass + + return None + + except Exception as e: + print(f"\nāŒ Exception occurred: {str(e)}") + return None + +async def check_stack_gitops(stack_id): + """Check if GitOps is enabled on a stack""" + + headers = { + "X-API-Key": PORTAINER_API_KEY + } + + endpoint = f"{PORTAINER_URL}/api/stacks/{stack_id}" + + try: + async with aiohttp.ClientSession() as session: + async with session.get(endpoint, headers=headers) as response: + if response.status == 200: + result = await response.json() + print(f"\nšŸ“š Stack Details:") + print(f"Name: {result['Name']}") + print(f"ID: {result['Id']}") + + if result.get("GitConfig"): + print(f"\nšŸ”— Git Configuration:") + git = result["GitConfig"] + print(f" Repository: {git['URL']}") + print(f" Reference: {git['ReferenceName']}") + print(f" Path: {git.get('ConfigFilePath', 'N/A')}") + + if result.get("AutoUpdate"): + print(f"\nšŸ”„ GitOps Configuration:") + auto = result["AutoUpdate"] + print(f" Enabled: Yes") + print(f" Interval: {auto.get('Interval', 'N/A')}") + print(f" Force Pull Image: {auto.get('ForcePullImage', False)}") + print(f" Force Update: {auto.get('ForceUpdate', False)}") + if auto.get("Webhook"): + print(f" Webhook: Enabled") + else: + print(f"\nāŒ GitOps: Disabled") + + return result + else: + print(f"Error getting stack details: {response.status}") + return None + except Exception as e: + print(f"Exception getting stack details: {str(e)}") + return None + +async def main(): + print("=" * 60) + print("Testing Portainer Stack Creation with GitOps") + print("=" * 60) + + # Create the stack with GitOps + result = await create_stack_with_gitops() + + if result: + print("\n" + "=" * 60) + print("Verifying GitOps configuration...") + print("=" * 60) + + # Wait a moment for the stack to be fully created + await asyncio.sleep(2) + + # Check the stack details + await check_stack_gitops(result["Id"]) + + print("\nšŸŽ‰ Test completed!") + else: + print("\nšŸ˜ž Test failed!") + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file