4 Commits

Author SHA1 Message Date
5ad72fe986 Fix remote sync bugs and improve error handling
Fixes two critical issues:
1. Remote clone/sync was never triggered because wmill sync ran before
   checking if workspace was empty. Reordered flow to check/clone/sync
   git repo BEFORE running wmill sync.

2. Push failures were reported as success because GitPython's push()
   doesn't raise exceptions for rejections. Added explicit checking of
   push result flags (ERROR and REJECTED).

Additional improvements:
- When workspace has files but no .git, delete contents and clone from
  remote to ensure proper sync state
- All three cases now properly sync with remote before Windmill overwrites

New flow:
1. Check workspace state and init/clone/sync git repo
2. Run wmill sync (Windmill overwrites files)
3. Commit and push (with proper error detection)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-09 22:20:52 -05:00
f276beae3b Add remote repository sync support
- Clone remote repository on first sync if workspace is empty
- Sync local repo with remote (fetch + hard reset) on subsequent syncs
- Ensures Windmill workspace changes are built on latest remote commits
- Prevents push failures due to divergent history

New functions:
- is_workspace_empty(): Check if workspace needs cloning
- clone_remote_repository(): Clone and checkout/create branch
- sync_local_with_remote(): Fetch and hard reset to remote

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-09 22:09:01 -05:00
ecc598788a Fix wmill sync authentication by using CLI flags
Replace environment variable authentication (WM_TOKEN, WM_WORKSPACE,
WM_BASE_URL) with explicit command-line flags (--token, --workspace,
--base-url) as required by wmill CLI.

According to wmill documentation, when using --base-url, the --token
and --workspace flags are required and no local workspace configuration
will be used. This is the correct approach for a stateless containerized
service where credentials are passed per-request.

This fixes the exit code 255 error that occurred when wmill tried to
run sync without proper workspace configuration.
2025-11-09 21:32:11 -05:00
d380cee815 Fix wmill CLI installation for multi-architecture support
Replace direct binary download with npm-based installation to properly
support both amd64 and arm64 architectures.

Changes:
- Install Node.js 20 via NodeSource repository
- Install windmill-cli globally via npm
- Remove architecture-specific binary download logic that was failing
  because wmill binaries are not published separately from windmill server

This fixes the "Exec format error" that occurred when attempting to run
the wmill CLI on ARM64 systems.
2025-11-09 21:22:30 -05:00
2 changed files with 162 additions and 25 deletions

View File

@@ -2,15 +2,16 @@ FROM python:3.11-slim
WORKDIR /app WORKDIR /app
# Install system dependencies # Install system dependencies including Node.js
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
git \ git \
curl \ curl \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Install wmill CLI # Install wmill CLI via npm
RUN curl -L https://github.com/windmill-labs/windmill/releases/latest/download/wmill-linux-amd64 -o /usr/local/bin/wmill \ RUN npm install -g windmill-cli
&& chmod +x /usr/local/bin/wmill
# Copy requirements and install Python dependencies # Copy requirements and install Python dependencies
COPY requirements.txt . COPY requirements.txt .

View File

@@ -3,6 +3,7 @@
Core sync logic for pulling Windmill workspace and pushing to Git. Core sync logic for pulling Windmill workspace and pushing to Git.
""" """
import os import os
import shutil
import subprocess import subprocess
import logging import logging
from pathlib import Path from pathlib import Path
@@ -59,17 +60,18 @@ def run_wmill_sync(config: Dict[str, Any]) -> bool:
logger.info(f"Syncing Windmill workspace '{workspace}' from {WINDMILL_BASE_URL}") logger.info(f"Syncing Windmill workspace '{workspace}' from {WINDMILL_BASE_URL}")
env = os.environ.copy()
env['WM_BASE_URL'] = WINDMILL_BASE_URL
env['WM_TOKEN'] = windmill_token
env['WM_WORKSPACE'] = workspace
try: try:
# Run wmill sync in the workspace directory # Run wmill sync in the workspace directory with explicit flags
# Note: When using --base-url, --token and --workspace are required
result = subprocess.run( result = subprocess.run(
['wmill', 'sync', 'pull', '--yes'], [
'wmill', 'sync', 'pull',
'--base-url', WINDMILL_BASE_URL,
'--token', windmill_token,
'--workspace', workspace,
'--yes'
],
cwd=WORKSPACE_DIR, cwd=WORKSPACE_DIR,
env=env,
capture_output=True, capture_output=True,
text=True, text=True,
check=True check=True
@@ -85,9 +87,115 @@ def run_wmill_sync(config: Dict[str, Any]) -> bool:
raise RuntimeError(f"Failed to sync from Windmill: {e.stderr}") raise RuntimeError(f"Failed to sync from Windmill: {e.stderr}")
def is_workspace_empty() -> bool:
"""
Check if workspace directory is empty or not initialized as a git repo.
Returns:
bool: True if directory is empty or not a git repository
"""
git_dir = WORKSPACE_DIR / '.git'
# Check if .git directory exists
if not git_dir.exists():
# Check if workspace is completely empty or has no meaningful content
workspace_contents = list(WORKSPACE_DIR.iterdir())
return len(workspace_contents) == 0
return False
def clone_remote_repository(config: Dict[str, Any]) -> Repo:
"""
Clone remote repository to workspace directory.
Args:
config: Configuration dictionary containing git_remote_url, git_token, git_branch
Returns:
Repo: GitPython repository object
Raises:
RuntimeError: If clone fails
"""
git_remote_url = config['git_remote_url']
git_token = config['git_token']
git_branch = config.get('git_branch', 'main')
git_user_name = config.get('git_user_name', 'Windmill Git Sync')
git_user_email = config.get('git_user_email', 'windmill@example.com')
authenticated_url = get_authenticated_url(git_remote_url, git_token)
logger.info(f"Cloning remote repository from {git_remote_url}")
try:
# Clone the repository
repo = Repo.clone_from(authenticated_url, 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()
# Check if the specified branch exists
try:
repo.git.checkout(git_branch)
logger.info(f"Checked out existing branch '{git_branch}'")
except GitCommandError:
# Branch doesn't exist, create it as orphan
logger.info(f"Branch '{git_branch}' doesn't exist, creating new branch")
repo.git.checkout('--orphan', git_branch)
# Remove all files from staging (orphan branch starts with staged files)
try:
repo.git.rm('-rf', '.')
except GitCommandError:
# If rm fails (no files to remove), that's fine
pass
logger.info("Repository cloned successfully")
return repo
except GitCommandError as e:
logger.error(f"Failed to clone repository: {str(e)}")
raise RuntimeError(f"Failed to clone remote repository: {str(e)}")
def sync_local_with_remote(repo: Repo, config: Dict[str, Any]) -> None:
"""
Sync local repository with remote (fetch and hard reset).
Args:
repo: GitPython Repo object
config: Configuration dictionary containing git_branch
Raises:
RuntimeError: If sync fails
"""
git_branch = config.get('git_branch', 'main')
logger.info(f"Syncing local repository with remote branch '{git_branch}'")
try:
# Fetch from remote
origin = repo.remote('origin')
origin.fetch()
logger.info("Fetched from remote")
# Reset local branch to match remote
try:
repo.git.reset('--hard', f'origin/{git_branch}')
logger.info(f"Reset local branch to match origin/{git_branch}")
except GitCommandError as e:
# Branch might not exist on remote yet, which is fine
logger.info(f"Branch '{git_branch}' doesn't exist on remote yet, will be created on push")
except GitCommandError as e:
logger.error(f"Failed to sync with remote: {str(e)}")
raise RuntimeError(f"Failed to sync local repository with remote: {str(e)}")
def init_or_update_git_repo(config: Dict[str, Any]) -> Repo: def init_or_update_git_repo(config: Dict[str, Any]) -> Repo:
""" """
Initialize Git repository or open existing one. Initialize Git repository, clone from remote if needed, or open existing one.
Args: Args:
config: Configuration dictionary containing optional git_user_name and git_user_email config: Configuration dictionary containing optional git_user_name and git_user_email
@@ -100,18 +208,34 @@ def init_or_update_git_repo(config: Dict[str, Any]) -> Repo:
git_dir = WORKSPACE_DIR / '.git' git_dir = WORKSPACE_DIR / '.git'
# Check if workspace is empty/uninitialized
if is_workspace_empty():
# Try to clone from remote
logger.info("Workspace is empty, attempting to clone from remote")
return clone_remote_repository(config)
# Repository already exists
if git_dir.exists(): if git_dir.exists():
logger.info("Opening existing Git repository") logger.info("Opening existing Git repository")
repo = Repo(WORKSPACE_DIR) repo = Repo(WORKSPACE_DIR)
else:
logger.info("Initializing new Git repository")
repo = Repo.init(WORKSPACE_DIR)
# Configure user # Sync with remote before continuing
repo.config_writer().set_value("user", "name", git_user_name).release() sync_local_with_remote(repo, config)
repo.config_writer().set_value("user", "email", git_user_email).release()
return repo return repo
else:
# Workspace has files but no git repo - delete contents and clone
logger.info("Workspace has files but no git repository, cleaning and cloning from remote")
# Delete all contents in workspace
for item in WORKSPACE_DIR.iterdir():
if item.is_dir():
shutil.rmtree(item)
else:
item.unlink()
logger.info("Workspace cleaned, attempting to clone from remote")
return clone_remote_repository(config)
def commit_and_push_changes(repo: Repo, config: Dict[str, Any]) -> bool: def commit_and_push_changes(repo: Repo, config: Dict[str, Any]) -> bool:
@@ -159,9 +283,21 @@ def commit_and_push_changes(repo: Repo, config: Dict[str, Any]) -> bool:
# Push to remote # Push to remote
logger.info(f"Pushing to {git_remote_url} (branch: {git_branch})") logger.info(f"Pushing to {git_remote_url} (branch: {git_branch})")
origin.push(refspec=f'HEAD:{git_branch}', force=False) push_info = origin.push(refspec=f'HEAD:{git_branch}', force=False)
logger.info("Push completed successfully")
# Check push results for errors
if push_info:
for info in push_info:
if info.flags & info.ERROR:
error_msg = f"Push failed: {info.summary}"
logger.error(error_msg)
raise RuntimeError(error_msg)
elif info.flags & info.REJECTED:
error_msg = f"Push rejected (non-fast-forward): {info.summary}"
logger.error(error_msg)
raise RuntimeError(error_msg)
logger.info("Push completed successfully")
return True return True
except GitCommandError as e: except GitCommandError as e:
@@ -192,12 +328,12 @@ def sync_windmill_to_git(config: Dict[str, Any]) -> Dict[str, Any]:
workspace = config.get('workspace', 'admins') workspace = config.get('workspace', 'admins')
# Pull from Windmill # Initialize/update Git repo (must happen BEFORE wmill sync to clone if needed)
run_wmill_sync(config)
# Initialize/update Git repo
repo = init_or_update_git_repo(config) repo = init_or_update_git_repo(config)
# Pull from Windmill (overwrites files with Windmill workspace content)
run_wmill_sync(config)
# Commit and push changes # Commit and push changes
has_changes = commit_and_push_changes(repo, config) has_changes = commit_and_push_changes(repo, config)