- 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>
164 lines
6.0 KiB
Python
164 lines
6.0 KiB
Python
#!/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()) |