Fix: Add GitOps support during stack creation and document API limitations
- Updated portainer_stacks_server.py to support GitOps configuration during stack creation - Added enable_gitops parameter and related GitOps settings to create_compose_stack_from_git - Created comprehensive documentation (README_GITOPS.md) explaining: - Why GitOps cannot be enabled on existing stacks - Why GitOps intervals cannot be updated without recreating stacks - API limitations that cause Git-based stacks to detach when updated - Added test script (test_gitops_create.py) to verify GitOps functionality - Included portainer_gitops_server.py for reference The Portainer API requires StackFileContent for updates, which detaches stacks from Git. This is a fundamental API limitation, not an MCP implementation issue. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
51bd6ea954
commit
d7055a912e
514
README_GITOPS.md
Normal file
514
README_GITOPS.md
Normal file
@ -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
|
825
portainer_gitops_server.py
Executable file
825
portainer_gitops_server.py
Executable file
@ -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())
|
@ -247,6 +247,36 @@ async def handle_list_tools() -> list[types.Tool]:
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "Environment variables"
|
"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"]
|
"required": ["environment_id", "name", "repository_url"]
|
||||||
@ -588,6 +618,20 @@ async def handle_call_tool(
|
|||||||
if arguments.get("env_vars"):
|
if arguments.get("env_vars"):
|
||||||
data["env"] = arguments["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
|
# Use the correct endpoint for standalone Docker stack creation from Git
|
||||||
endpoint = f"/api/stacks/create/standalone/repository?endpointId={env_id}"
|
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"Repository: {arguments['repository_url']}\n"
|
||||||
output += f"Branch/Tag: {arguments.get('repository_ref', 'main')}\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)]
|
return [types.TextContent(type="text", text=output)]
|
||||||
|
|
||||||
# Create Kubernetes stack
|
# Create Kubernetes stack
|
||||||
|
164
test_gitops_create.py
Normal file
164
test_gitops_create.py
Normal file
@ -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())
|
Loading…
Reference in New Issue
Block a user