Files
ServerKit/builtin-extensions/serverkit-wordpress/backend/git_wordpress_service.py
T
Juan DenisandClaude Opus 4.8 a55e50bc16 feat(extensions): Phase 5 — extract WordPress into serverkit-wordpress (backend)
The largest single move in the plan (#38). The entire WordPress backend leaves
core for the bundled, default-installed serverkit-wordpress extension (D4).

Moved (12 files → builtin-extensions/serverkit-wordpress/backend/, intra-extension
imports made relative, core imports stay absolute):
  api/wordpress.py, api/wordpress_sites.py, api/environment_pipeline.py,
  wordpress_service.py (2676 lines), git_wordpress_service, wordpress_env_service,
  wordpress_plugin_library_service, wp_analytics/reports/security/update/vulnerability.

Wiring:
- Blueprints keep /api/v1/wordpress, /wordpress/projects and the /wordpress/pipelines
  alias (D9) via new manifest `extra_blueprints` support (3 blueprints / 4 mounts).
- Flagship (D4): seeded active on every panel (fresh + upgrade), uninstallable,
  never deletes tracked builtin/pre-bundled files; the `wordpress` module toggle is
  retired (MODULES now empty) — the plugin status guard is the gate.
- Loadability: the dashed package app.plugins.serverkit-wordpress is registered
  in-place via spec_from_file_location (no copy) — uniform across prod/dev/test.
- The 11 WP test files re-pointed through app.services.wordpress_bridge; the 6 lazy
  core callers were re-pointed in the prior commit.
- _regenerate_frontend_manifest() is a no-op under testing so the flagship boot
  seed can't clobber the checked-in plugins-manifest.json.

Deviations (documented, matching the Email precedent):
- WordPress *models* stay core — status_page/environment_activity/promotion_job hold
  FKs into wordpress_sites, so relocation would be a breaking data migration.
- Event catalog + Fail2ban WP filter stay core as two-speed items (extension calls
  them via core APIs); inverting adds risk without blocking the move.
- Frontend UI stays core this session (it lives in a TabGroupLayout — the tab-grouped
  conversion Phase 1 deliberately deferred); the extension is backend-only, so there
  is no route duplication. Frontend contribution is the scoped follow-up.

Verified: clean collection across all 1223 tests; 199 passed / 2 skipped / 0 failed
across the WP + plugin/extension + module + backup + core-caller parity set, incl.
the new tests/test_wordpress_extraction.py proving the move.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 00:15:10 -04:00

652 lines
24 KiB
Python

"""
Git WordPress Service
Connect Git repositories to WordPress wp-content for theme/plugin management.
Supports deployment from specific commits and creating dev environments for any revision.
"""
import os
import subprocess
import shutil
import json
from datetime import datetime
from typing import Dict, List, Optional
from app import db
from app.models.wordpress_site import WordPressSite, DatabaseSnapshot
from app.services.db_sync_service import DatabaseSyncService
from .wordpress_env_service import WordPressEnvService
class GitWordPressService:
"""Connect Git repositories to WordPress wp-content."""
DEFAULT_PATHS = ['wp-content/themes', 'wp-content/plugins']
@classmethod
def connect_repo(cls, site_id: int, repo_url: str, branch: str = 'main',
paths: List[str] = None, auto_deploy: bool = False) -> Dict:
"""
Connect a Git repository to a WordPress site.
Args:
site_id: ID of the WordPressSite
repo_url: Git repository URL (HTTPS or SSH)
branch: Branch to track (default: main)
paths: What to sync (default: ['wp-content/themes', 'wp-content/plugins'])
auto_deploy: Enable automatic deployment on push
Returns:
Dict with success status
"""
site = WordPressSite.query.get(site_id)
if not site:
return {'success': False, 'error': 'Site not found'}
if not site.application or not site.application.root_path:
return {'success': False, 'error': 'Site application path not found'}
wp_path = site.application.root_path
paths = paths or cls.DEFAULT_PATHS
try:
# Validate repository URL
if not cls._validate_repo_url(repo_url):
return {'success': False, 'error': 'Invalid repository URL'}
# Test repository access
test_result = cls._test_repo_access(repo_url)
if not test_result['success']:
return test_result
# Initialize git in wp-content if not exists
wp_content_path = os.path.join(wp_path, 'wp-content')
git_result = cls._init_git_tracking(wp_content_path, repo_url, branch)
if not git_result['success']:
return git_result
# Update site record
site.git_repo_url = repo_url
site.git_branch = branch
site.git_paths = json.dumps(paths)
site.auto_deploy = auto_deploy
db.session.commit()
return {
'success': True,
'message': f'Repository connected successfully',
'repo_url': repo_url,
'branch': branch,
'paths': paths
}
except Exception as e:
db.session.rollback()
return {'success': False, 'error': str(e)}
@classmethod
def disconnect_repo(cls, site_id: int) -> Dict:
"""
Disconnect a Git repository from a WordPress site.
Args:
site_id: ID of the WordPressSite
Returns:
Dict with success status
"""
site = WordPressSite.query.get(site_id)
if not site:
return {'success': False, 'error': 'Site not found'}
try:
# Remove git tracking from wp-content
if site.application and site.application.root_path:
wp_content_path = os.path.join(site.application.root_path, 'wp-content')
git_dir = os.path.join(wp_content_path, '.git')
if os.path.exists(git_dir):
shutil.rmtree(git_dir)
# Clear git settings
site.git_repo_url = None
site.git_branch = None
site.git_paths = None
site.auto_deploy = False
site.last_deploy_commit = None
site.last_deploy_at = None
db.session.commit()
return {'success': True, 'message': 'Repository disconnected'}
except Exception as e:
db.session.rollback()
return {'success': False, 'error': str(e)}
@classmethod
def deploy_from_commit(cls, site_id: int, commit_sha: str = None,
branch: str = None, create_snapshot: bool = True) -> Dict:
"""
Deploy a specific commit or branch to the WordPress site.
Args:
site_id: ID of the WordPressSite
commit_sha: Specific commit SHA to deploy (optional)
branch: Branch name to deploy latest from (optional)
create_snapshot: Create a database snapshot before deploying
Returns:
Dict with success status and deployment info
"""
site = WordPressSite.query.get(site_id)
if not site:
return {'success': False, 'error': 'Site not found'}
if not site.git_repo_url:
return {'success': False, 'error': 'No repository connected'}
if not site.application or not site.application.root_path:
return {'success': False, 'error': 'Site application path not found'}
wp_content_path = os.path.join(site.application.root_path, 'wp-content')
try:
# Create pre-deploy snapshot
if create_snapshot:
snapshot_result = DatabaseSyncService.create_snapshot(
db_name=site.db_name,
name=f"pre_deploy_{datetime.now().strftime('%Y%m%d_%H%M%S')}",
tag='pre-deploy',
commit_sha=site.last_deploy_commit,
host=site.db_host,
user=site.db_user,
password=WordPressEnvService._get_db_password(site)
)
if snapshot_result['success']:
# Save snapshot to database
snapshot = DatabaseSnapshot(
site_id=site.id,
name=snapshot_result['snapshot']['name'],
tag='pre-deploy',
file_path=snapshot_result['snapshot']['file_path'],
size_bytes=snapshot_result['snapshot']['size_bytes'],
compressed=snapshot_result['snapshot']['compressed'],
commit_sha=site.last_deploy_commit,
tables_included=json.dumps(snapshot_result['snapshot'].get('tables', [])),
row_count=snapshot_result['snapshot'].get('row_count', 0),
status='completed'
)
db.session.add(snapshot)
# Fetch latest from remote
fetch_result = cls._run_git(wp_content_path, ['fetch', 'origin'])
if not fetch_result['success']:
return fetch_result
# Determine what to checkout
if commit_sha:
target = commit_sha
elif branch:
target = f'origin/{branch}'
else:
target = f'origin/{site.git_branch}'
# Checkout the target
checkout_result = cls._run_git(wp_content_path, ['checkout', target, '--force'])
if not checkout_result['success']:
return checkout_result
# Get the actual commit SHA we deployed
rev_result = cls._run_git(wp_content_path, ['rev-parse', 'HEAD'])
deployed_sha = rev_result['output'].strip() if rev_result['success'] else commit_sha
# Get commit message
msg_result = cls._run_git(wp_content_path, ['log', '-1', '--format=%s'])
commit_message = msg_result['output'].strip() if msg_result['success'] else ''
# Update site record
site.last_deploy_commit = deployed_sha
site.last_deploy_at = datetime.utcnow()
db.session.commit()
from app.services.event_service import EventService
EventService.emit_wp('wordpress.deployed', site, commit_sha=deployed_sha, commit_message=commit_message)
if create_snapshot and snapshot_result.get('success'):
EventService.emit_wp('wordpress.backup_completed', site, tag='pre-deploy')
return {
'success': True,
'message': 'Deployment successful',
'commit_sha': deployed_sha,
'commit_message': commit_message,
'pre_deploy_snapshot': snapshot_result.get('snapshot', {}).get('file_path') if create_snapshot else None
}
except Exception as e:
db.session.rollback()
try:
from app.services.event_service import EventService
EventService.emit_wp('wordpress.deploy_failed', site, error=str(e))
except Exception:
pass
return {'success': False, 'error': str(e)}
@classmethod
def create_dev_for_commit(cls, production_site_id: int, commit_sha: str,
config: Dict, user_id: int) -> Dict:
"""
Create a development environment for a specific commit.
This creates a full dev environment with:
- Database snapshot from nearest point in time (or current)
- Code checked out at the specified commit
- URL search-replace applied
Args:
production_site_id: ID of the production WordPressSite
commit_sha: Git commit SHA to create dev for
config: Environment configuration (name, domain, etc.)
user_id: User ID creating the environment
Returns:
Dict with success status and dev environment info
"""
prod_site = WordPressSite.query.get(production_site_id)
if not prod_site:
return {'success': False, 'error': 'Production site not found'}
if not prod_site.is_production:
return {'success': False, 'error': 'Source site is not a production site'}
if not prod_site.git_repo_url:
return {'success': False, 'error': 'No repository connected to production site'}
try:
# Get short SHA for naming
short_sha = commit_sha[:7]
# Set defaults in config
if not config.get('name'):
config['name'] = f"{prod_site.application.name} @ {short_sha}"
if not config.get('domain'):
# Try to create subdomain from production domain
if prod_site.application.domains:
prod_domain = prod_site.application.domains[0].domain
config['domain'] = f"{short_sha}.dev.{prod_domain}"
# Create the environment using WordPressEnvService
env_result = WordPressEnvService.create_environment(
production_site_id=production_site_id,
env_type='development',
config=config,
user_id=user_id
)
if not env_result['success']:
return env_result
env_site_id = env_result['environment']['id']
env_site = WordPressSite.query.get(env_site_id)
# Checkout the specific commit
if env_site and env_site.application:
wp_content_path = os.path.join(env_site.application.root_path, 'wp-content')
# Initialize git in the new environment
init_result = cls._init_git_tracking(
wp_content_path,
prod_site.git_repo_url,
prod_site.git_branch
)
if init_result['success']:
# Fetch and checkout specific commit
cls._run_git(wp_content_path, ['fetch', 'origin'])
checkout_result = cls._run_git(wp_content_path, ['checkout', commit_sha, '--force'])
if checkout_result['success']:
env_site.last_deploy_commit = commit_sha
env_site.last_deploy_at = datetime.utcnow()
db.session.commit()
return {
'success': True,
'message': f'Development environment created for commit {short_sha}',
'environment': env_site.to_dict() if env_site else env_result['environment'],
'commit_sha': commit_sha,
'url': f"https://{config.get('domain')}" if config.get('domain') else None
}
except Exception as e:
db.session.rollback()
return {'success': False, 'error': str(e)}
@classmethod
def get_recent_commits(cls, site_id: int, limit: int = 20) -> Dict:
"""
Get recent commits from the repository.
Args:
site_id: ID of the WordPressSite
limit: Maximum number of commits to return
Returns:
Dict with list of commits
"""
site = WordPressSite.query.get(site_id)
if not site:
return {'success': False, 'error': 'Site not found'}
if not site.git_repo_url:
return {'success': False, 'error': 'No repository connected'}
if not site.application or not site.application.root_path:
return {'success': False, 'error': 'Site application path not found'}
wp_content_path = os.path.join(site.application.root_path, 'wp-content')
try:
# Fetch latest
cls._run_git(wp_content_path, ['fetch', 'origin'])
# Get commit log
log_format = '%H|%h|%s|%an|%ae|%ai'
log_result = cls._run_git(wp_content_path, [
'log',
f'origin/{site.git_branch}',
f'-{limit}',
f'--format={log_format}'
])
if not log_result['success']:
return log_result
commits = []
for line in log_result['output'].strip().split('\n'):
if not line:
continue
parts = line.split('|')
if len(parts) >= 6:
commits.append({
'sha': parts[0],
'short_sha': parts[1],
'message': parts[2],
'author_name': parts[3],
'author_email': parts[4],
'date': parts[5],
'is_deployed': parts[0] == site.last_deploy_commit
})
return {
'success': True,
'commits': commits,
'current_deploy': site.last_deploy_commit
}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def list_branches(cls, site_id: int) -> Dict:
"""
List remote branches for a site's connected repository.
Args:
site_id: ID of the WordPressSite
Returns:
Dict with list of branches and their latest commit info
"""
site = WordPressSite.query.get(site_id)
if not site:
return {'success': False, 'error': 'Site not found'}
if not site.git_repo_url:
return {'success': False, 'error': 'No repository connected'}
if not site.application or not site.application.root_path:
return {'success': False, 'error': 'Site application path not found'}
wp_content_path = os.path.join(site.application.root_path, 'wp-content')
git_dir = os.path.join(wp_content_path, '.git')
try:
if os.path.exists(git_dir):
# Fetch latest from remote
cls._run_git(wp_content_path, ['fetch', '--prune', 'origin'])
# List remote branches with latest commit info
log_format = '%(refname:short)|%(objectname:short)|%(subject)|%(authorname)|%(creatordate:iso8601)'
result = cls._run_git(wp_content_path, [
'for-each-ref',
'--sort=-creatordate',
f'--format={log_format}',
'refs/remotes/origin/'
])
else:
# No local clone — use ls-remote to list branches
result = subprocess.run(
['git', 'ls-remote', '--heads', site.git_repo_url],
capture_output=True, text=True, timeout=30
)
if result.returncode != 0:
return {'success': False, 'error': f'Failed to list branches: {result.stderr}'}
branches = []
for line in result.stdout.strip().split('\n'):
if not line:
continue
sha, ref = line.split('\t')
branch_name = ref.replace('refs/heads/', '')
branches.append({
'name': branch_name,
'short_sha': sha[:7],
'message': '',
'author': '',
'date': '',
'is_current': branch_name == site.git_branch,
})
return {'success': True, 'branches': branches}
if not result['success']:
return result
branches = []
existing_multidevs = set()
for env in site.environments:
if env.environment_type == 'multidev' and env.multidev_branch:
existing_multidevs.add(env.multidev_branch)
for line in result['output'].strip().split('\n'):
if not line:
continue
parts = line.split('|')
if len(parts) < 5:
continue
ref_name = parts[0]
# Strip 'origin/' prefix
branch_name = ref_name.replace('origin/', '', 1)
# Skip HEAD pointer
if branch_name == 'HEAD':
continue
branches.append({
'name': branch_name,
'short_sha': parts[1],
'message': parts[2],
'author': parts[3],
'date': parts[4],
'is_current': branch_name == site.git_branch,
'has_multidev': branch_name in existing_multidevs,
})
return {
'success': True,
'branches': branches,
'current_branch': site.git_branch,
}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def rollback_to_commit(cls, site_id: int, commit_sha: str) -> Dict:
"""
Rollback a site to a previous commit.
Args:
site_id: ID of the WordPressSite
commit_sha: Commit SHA to rollback to
Returns:
Dict with success status
"""
# This is essentially the same as deploy_from_commit
return cls.deploy_from_commit(site_id, commit_sha=commit_sha, create_snapshot=True)
@classmethod
def get_git_status(cls, site_id: int) -> Dict:
"""
Get the Git status for a site.
Args:
site_id: ID of the WordPressSite
Returns:
Dict with Git status information
"""
site = WordPressSite.query.get(site_id)
if not site:
return {'success': False, 'error': 'Site not found'}
result = {
'success': True,
'connected': bool(site.git_repo_url),
'repo_url': site.git_repo_url,
'branch': site.git_branch,
'auto_deploy': site.auto_deploy,
'last_deploy_commit': site.last_deploy_commit,
'last_deploy_at': site.last_deploy_at.isoformat() if site.last_deploy_at else None,
'paths': json.loads(site.git_paths) if site.git_paths else None
}
if site.git_repo_url and site.application and site.application.root_path:
wp_content_path = os.path.join(site.application.root_path, 'wp-content')
# Check if there are uncommitted changes
status_result = cls._run_git(wp_content_path, ['status', '--porcelain'])
if status_result['success']:
result['has_local_changes'] = bool(status_result['output'].strip())
# Check if behind remote
cls._run_git(wp_content_path, ['fetch', 'origin'])
behind_result = cls._run_git(wp_content_path, [
'rev-list',
'--count',
f'HEAD..origin/{site.git_branch}'
])
if behind_result['success']:
result['commits_behind'] = int(behind_result['output'].strip() or 0)
return result
@classmethod
def _validate_repo_url(cls, url: str) -> bool:
"""Validate a Git repository URL."""
if not url:
return False
# Accept HTTPS URLs
if url.startswith('https://'):
return True
# Accept SSH URLs
if url.startswith('git@') or url.startswith('ssh://'):
return True
return False
@classmethod
def _test_repo_access(cls, repo_url: str) -> Dict:
"""Test if we can access the repository."""
try:
result = subprocess.run(
['git', 'ls-remote', '--exit-code', repo_url],
capture_output=True,
text=True,
timeout=30
)
if result.returncode == 0:
return {'success': True}
else:
return {
'success': False,
'error': f'Cannot access repository: {result.stderr or "Authentication failed or repository not found"}'
}
except subprocess.TimeoutExpired:
return {'success': False, 'error': 'Connection to repository timed out'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def _init_git_tracking(cls, path: str, repo_url: str, branch: str) -> Dict:
"""Initialize Git tracking in a directory."""
try:
git_dir = os.path.join(path, '.git')
if os.path.exists(git_dir):
# Already initialized, just update remote
cls._run_git(path, ['remote', 'set-url', 'origin', repo_url])
else:
# Initialize new repo
init_result = cls._run_git(path, ['init'])
if not init_result['success']:
return init_result
# Add remote
remote_result = cls._run_git(path, ['remote', 'add', 'origin', repo_url])
if not remote_result['success']:
return remote_result
# Fetch from remote
fetch_result = cls._run_git(path, ['fetch', 'origin'])
if not fetch_result['success']:
return fetch_result
return {'success': True}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def _run_git(cls, cwd: str, args: List[str]) -> Dict:
"""Run a Git command."""
try:
cmd = ['git'] + args
result = subprocess.run(
cmd,
cwd=cwd,
capture_output=True,
text=True,
timeout=120
)
return {
'success': result.returncode == 0,
'output': result.stdout,
'error': result.stderr if result.returncode != 0 else None
}
except subprocess.TimeoutExpired:
return {'success': False, 'error': 'Git command timed out'}
except Exception as e:
return {'success': False, 'error': str(e)}