Initial commit: Windmill Git Sync service

Add containerized service for syncing Windmill workspaces to Git repositories.

Features:
- Flask webhook server for triggering syncs from Windmill
- wmill CLI integration for pulling workspace content
- Automated Git commits and push to remote repository
- Network-isolated (only accessible within Docker network)
- Designed to integrate with existing Windmill docker-compose files

Key components:
- Docker container with Python 3.11, wmill CLI, Git, and Flask
- Sync engine with error handling and logging
- External volume support for persistent workspace data
- Comprehensive documentation (README.md and CLAUDE.md)
This commit is contained in:
2025-11-08 18:40:26 -05:00
commit c838fa568c
10 changed files with 626 additions and 0 deletions

54
app/server.py Normal file
View File

@@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""
Flask server for receiving webhook triggers from Windmill to sync workspace to Git.
Internal service - not exposed outside Docker network.
"""
import logging
from flask import Flask, jsonify
from sync import sync_windmill_to_git
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
app = Flask(__name__)
@app.route('/health', methods=['GET'])
def health():
"""Health check endpoint."""
return jsonify({'status': 'healthy'}), 200
@app.route('/sync', methods=['POST'])
def trigger_sync():
"""
Trigger a sync from Windmill workspace to Git repository.
This endpoint is only accessible within the Docker network.
"""
logger.info("Sync triggered via webhook")
try:
result = sync_windmill_to_git()
if result['success']:
logger.info(f"Sync completed successfully: {result['message']}")
return jsonify(result), 200
else:
logger.error(f"Sync failed: {result['message']}")
return jsonify(result), 500
except Exception as e:
logger.exception("Unexpected error during sync")
return jsonify({
'success': False,
'message': f'Sync failed with error: {str(e)}'
}), 500
if __name__ == '__main__':
logger.info("Starting Windmill Git Sync server on port 8080")
app.run(host='0.0.0.0', port=8080, debug=False)

176
app/sync.py Normal file
View File

@@ -0,0 +1,176 @@
#!/usr/bin/env python3
"""
Core sync logic for pulling Windmill workspace and pushing to Git.
"""
import os
import subprocess
import logging
from pathlib import Path
from git import Repo, GitCommandError
logger = logging.getLogger(__name__)
# Configuration from environment variables
WORKSPACE_DIR = Path('/workspace')
WINDMILL_BASE_URL = os.getenv('WINDMILL_BASE_URL', 'http://windmill:8000')
WINDMILL_TOKEN = os.getenv('WINDMILL_TOKEN', '')
WINDMILL_WORKSPACE = os.getenv('WINDMILL_WORKSPACE', 'default')
GIT_REMOTE_URL = os.getenv('GIT_REMOTE_URL', '')
GIT_TOKEN = os.getenv('GIT_TOKEN', '')
GIT_BRANCH = os.getenv('GIT_BRANCH', 'main')
GIT_USER_NAME = os.getenv('GIT_USER_NAME', 'Windmill Git Sync')
GIT_USER_EMAIL = os.getenv('GIT_USER_EMAIL', 'windmill@example.com')
def validate_config():
"""Validate required configuration is present."""
missing = []
if not WINDMILL_TOKEN:
missing.append('WINDMILL_TOKEN')
if not GIT_REMOTE_URL:
missing.append('GIT_REMOTE_URL')
if not GIT_TOKEN:
missing.append('GIT_TOKEN')
if missing:
raise ValueError(f"Missing required environment variables: {', '.join(missing)}")
def get_authenticated_url(url: str, token: str) -> str:
"""Insert token into HTTPS Git URL for authentication."""
if url.startswith('https://'):
# Format: https://TOKEN@github.com/user/repo.git
return url.replace('https://', f'https://{token}@')
return url
def run_wmill_sync():
"""Run wmill sync to pull workspace from Windmill."""
logger.info(f"Syncing Windmill workspace '{WINDMILL_WORKSPACE}' from {WINDMILL_BASE_URL}")
env = os.environ.copy()
env['WM_BASE_URL'] = WINDMILL_BASE_URL
env['WM_TOKEN'] = WINDMILL_TOKEN
env['WM_WORKSPACE'] = WINDMILL_WORKSPACE
try:
# Run wmill sync in the workspace directory
result = subprocess.run(
['wmill', 'sync', 'pull', '--yes'],
cwd=WORKSPACE_DIR,
env=env,
capture_output=True,
text=True,
check=True
)
logger.info("Windmill sync completed successfully")
logger.debug(f"wmill output: {result.stdout}")
return True
except subprocess.CalledProcessError as e:
logger.error(f"wmill sync failed: {e.stderr}")
raise RuntimeError(f"Failed to sync from Windmill: {e.stderr}")
def init_or_update_git_repo():
"""Initialize Git repository or open existing one."""
git_dir = WORKSPACE_DIR / '.git'
if git_dir.exists():
logger.info("Opening existing Git repository")
repo = Repo(WORKSPACE_DIR)
else:
logger.info("Initializing new Git repository")
repo = Repo.init(WORKSPACE_DIR)
# Configure user
repo.config_writer().set_value("user", "name", GIT_USER_NAME).release()
repo.config_writer().set_value("user", "email", GIT_USER_EMAIL).release()
return repo
def commit_and_push_changes(repo: Repo):
"""Commit changes and push to remote Git repository."""
# Check if there are any changes
if not repo.is_dirty(untracked_files=True):
logger.info("No changes to commit")
return False
# Stage all changes
repo.git.add(A=True)
# Create commit
commit_message = f"Automated Windmill workspace backup - {WINDMILL_WORKSPACE}"
repo.index.commit(commit_message)
logger.info(f"Created commit: {commit_message}")
# Configure remote with authentication
authenticated_url = get_authenticated_url(GIT_REMOTE_URL, GIT_TOKEN)
try:
# Check if remote exists
if 'origin' in [remote.name for remote in repo.remotes]:
origin = repo.remote('origin')
origin.set_url(authenticated_url)
else:
origin = repo.create_remote('origin', authenticated_url)
# Push to remote
logger.info(f"Pushing to {GIT_REMOTE_URL} (branch: {GIT_BRANCH})")
origin.push(refspec=f'HEAD:{GIT_BRANCH}', force=False)
logger.info("Push completed successfully")
return True
except GitCommandError as e:
logger.error(f"Git push failed: {str(e)}")
raise RuntimeError(f"Failed to push to Git remote: {str(e)}")
def sync_windmill_to_git():
"""
Main sync function: pulls from Windmill, commits, and pushes to Git.
Returns:
dict: Result with 'success' boolean and 'message' string
"""
try:
# Validate configuration
validate_config()
# Pull from Windmill
run_wmill_sync()
# Initialize/update Git repo
repo = init_or_update_git_repo()
# Commit and push changes
has_changes = commit_and_push_changes(repo)
if has_changes:
message = f"Successfully synced workspace '{WINDMILL_WORKSPACE}' to Git"
else:
message = "Sync completed - no changes to commit"
return {
'success': True,
'message': message
}
except Exception as e:
logger.exception("Sync failed")
return {
'success': False,
'message': str(e)
}
if __name__ == '__main__':
# Allow running sync directly for testing
logging.basicConfig(level=logging.INFO)
result = sync_windmill_to_git()
print(result)