Files
ServerKit/builtin-extensions/serverkit-wordpress/backend/wordpress_env_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

531 lines
20 KiB
Python

"""
WordPress Environment Service
Manage WordPress production/development/staging environments.
Handles environment creation, database cloning, and synchronization.
For Docker-isolated environments (compose_project_name set), delegates to
EnvironmentPipelineService. For legacy/standalone environments, handles
directly using host MySQL and file system operations.
"""
import os
import json
import secrets
import string
import shutil
from datetime import datetime
from typing import Dict, List, Optional
from app import db
from app.models.application import Application
from app.models.wordpress_site import WordPressSite, DatabaseSnapshot, SyncJob
from app.services.db_sync_service import DatabaseSyncService
from .wordpress_service import WordPressService
from app.services.database_service import DatabaseService
class WordPressEnvService:
"""Manage WordPress production/development/staging environments."""
@classmethod
def create_environment(cls, production_site_id: int, env_type: str,
config: Dict, user_id: int) -> Dict:
"""
Create a new environment linked to production.
If config['isolated'] is True, delegates to EnvironmentPipelineService
for full Docker-isolated environment creation. Otherwise uses the
legacy shared-host approach.
Args:
production_site_id: ID of the production WordPressSite
env_type: 'development' or 'staging'
config: Dict with:
- name: Environment name (e.g., 'My Site Dev')
- domain: Domain for the environment (e.g., 'dev.mysite.com')
- port: Optional custom port
- clone_db: Whether to clone the production database
- sync_schedule: Optional cron schedule for auto-sync
- isolated: If True, use Docker-isolated pipeline (default False)
user_id: User ID creating the environment
Returns:
Dict with success status and environment info
"""
# Delegate to pipeline service for Docker-isolated environments
if config.get('isolated'):
from app.services.environment_pipeline_service import EnvironmentPipelineService
return EnvironmentPipelineService.create_project_environment(
production_site_id=production_site_id,
env_type=env_type,
config=config,
user_id=user_id
)
# Validate environment type
if env_type not in ['development', 'staging']:
return {'success': False, 'error': 'Invalid environment type. Must be development or staging.'}
# Get production site
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'}
prod_app = prod_site.application
if not prod_app:
return {'success': False, 'error': 'Production application not found'}
try:
# Generate names
env_name = config.get('name', f"{prod_app.name} ({env_type.title()})")
env_domain = config.get('domain')
# Generate unique database name and prefix
short_id = secrets.token_hex(4)
env_db_name = f"{prod_site.db_name}_{env_type[:3]}_{short_id}"
env_db_prefix = f"wp_{env_type[:3]}_"
# Determine root path for new environment
prod_path = prod_app.root_path
base_path = os.path.dirname(prod_path)
env_path = os.path.join(base_path, f"{os.path.basename(prod_path)}_{env_type}")
# Step 1: Clone the database if requested
if config.get('clone_db', True):
# First create a snapshot
snapshot_result = DatabaseSyncService.create_snapshot(
db_name=prod_site.db_name,
name=f"pre_env_create_{env_type}",
tag=f"env-create-{env_type}",
host=prod_site.db_host,
user=prod_site.db_user,
password=cls._get_db_password(prod_site)
)
if not snapshot_result['success']:
return {'success': False, 'error': f"Failed to create snapshot: {snapshot_result.get('error')}"}
# Clone with transformations
clone_options = {
'table_prefix': env_db_prefix,
'truncate_tables': config.get('truncate_tables', [
'actionscheduler_actions',
'actionscheduler_logs',
])
}
# Add search-replace for domain if provided
if env_domain and prod_app.domains:
prod_domain = prod_app.domains[0].domain if prod_app.domains else None
if prod_domain:
clone_options['search_replace'] = {
f'https://{prod_domain}': f'https://{env_domain}',
f'http://{prod_domain}': f'http://{env_domain}',
prod_domain: env_domain
}
clone_result = DatabaseSyncService.clone_database(
source_db=prod_site.db_name,
target_db=env_db_name,
source_host=prod_site.db_host,
target_host=prod_site.db_host,
source_user=prod_site.db_user,
target_user=prod_site.db_user,
source_password=cls._get_db_password(prod_site),
target_password=cls._get_db_password(prod_site),
options=clone_options
)
if not clone_result['success']:
return {'success': False, 'error': f"Failed to clone database: {clone_result.get('error')}"}
# Step 2: Create the Application record
env_app = Application(
name=env_name,
app_type='wordpress',
status='deploying',
php_version=prod_app.php_version,
port=config.get('port'),
root_path=env_path,
environment_type=env_type,
linked_app_id=prod_app.id,
user_id=user_id
)
db.session.add(env_app)
db.session.flush() # Get the ID
# Step 3: Create the WordPressSite record
env_site = WordPressSite(
application_id=env_app.id,
wp_version=prod_site.wp_version,
multisite=prod_site.multisite,
admin_user=prod_site.admin_user,
admin_email=prod_site.admin_email,
db_name=env_db_name,
db_user=prod_site.db_user,
db_host=prod_site.db_host,
db_prefix=env_db_prefix,
git_repo_url=prod_site.git_repo_url,
git_branch=config.get('git_branch', prod_site.git_branch),
git_paths=prod_site.git_paths,
auto_deploy=config.get('auto_deploy', False),
is_production=False,
production_site_id=production_site_id
)
db.session.add(env_site)
# Step 4: Copy WordPress files
copy_result = cls._copy_wordpress_files(prod_path, env_path)
if not copy_result['success']:
db.session.rollback()
return copy_result
# Step 5: Update wp-config.php for new environment
config_result = cls._update_wp_config(
env_path,
db_name=env_db_name,
db_prefix=env_db_prefix,
site_url=f"https://{env_domain}" if env_domain else None
)
if not config_result['success']:
db.session.rollback()
return config_result
# Step 6: Create sync job if schedule provided
if config.get('sync_schedule'):
sync_job = SyncJob(
source_site_id=production_site_id,
target_site_id=env_site.id,
name=f"Auto-sync {prod_app.name} to {env_name}",
schedule=config['sync_schedule'],
enabled=True,
config=json.dumps({
'search_replace': clone_options.get('search_replace', {}),
'truncate_tables': clone_options.get('truncate_tables', [])
})
)
db.session.add(sync_job)
# Update application status
env_app.status = 'running'
db.session.commit()
return {
'success': True,
'message': f'{env_type.title()} environment created successfully',
'environment': env_site.to_dict(),
'application': env_app.to_dict()
}
except Exception as e:
db.session.rollback()
return {'success': False, 'error': str(e)}
@classmethod
def sync_environment(cls, env_site_id: int, options: Dict = None) -> Dict:
"""
Sync an environment from its production source.
Docker-isolated environments (those with compose_project_name) are
delegated to EnvironmentPipelineService for container-aware sync.
Args:
env_site_id: ID of the environment WordPressSite to sync
options: Optional override options for sync
Returns:
Dict with success status
"""
options = options or {}
env_site = WordPressSite.query.get(env_site_id)
if not env_site:
return {'success': False, 'error': 'Environment not found'}
if env_site.is_production:
return {'success': False, 'error': 'Cannot sync a production site'}
# Delegate Docker-isolated environments to pipeline service
if env_site.compose_project_name:
from app.services.environment_pipeline_service import EnvironmentPipelineService
return EnvironmentPipelineService.sync_from_production(
env_site_id=env_site_id,
sync_type=options.pop('sync_type', 'full'),
options=options,
user_id=options.pop('user_id', None)
)
prod_site = env_site.production_site
if not prod_site:
return {'success': False, 'error': 'Production site not found'}
try:
# Get sync config
sync_config = json.loads(env_site.sync_config) if env_site.sync_config else {}
sync_config.update(options)
# Create snapshot before sync
snapshot_result = DatabaseSyncService.create_snapshot(
db_name=env_site.db_name,
name=f"pre_sync_{datetime.now().strftime('%Y%m%d_%H%M%S')}",
tag='pre-sync',
host=env_site.db_host,
user=env_site.db_user,
password=cls._get_db_password(env_site)
)
# Clone production to environment
clone_options = {
'table_prefix': env_site.db_prefix,
'search_replace': sync_config.get('search_replace', {}),
'truncate_tables': sync_config.get('truncate_tables', []),
'anonymize': sync_config.get('anonymize', False)
}
clone_result = DatabaseSyncService.clone_database(
source_db=prod_site.db_name,
target_db=env_site.db_name,
source_host=prod_site.db_host,
target_host=env_site.db_host,
source_user=prod_site.db_user,
target_user=env_site.db_user,
source_password=cls._get_db_password(prod_site),
target_password=cls._get_db_password(env_site),
options=clone_options
)
if not clone_result['success']:
return clone_result
# Update sync timestamp
env_site.updated_at = datetime.utcnow()
db.session.commit()
return {
'success': True,
'message': 'Environment synced successfully',
'pre_sync_snapshot': snapshot_result.get('snapshot', {}).get('file_path')
}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def get_environment_status(cls, production_site_id: int) -> Dict:
"""
Get status of all environments for a production site.
Args:
production_site_id: ID of the production WordPressSite
Returns:
Dict with production site and all environments
"""
prod_site = WordPressSite.query.get(production_site_id)
if not prod_site:
return {'success': False, 'error': 'Production site not found'}
environments = []
for env in prod_site.environments:
env_data = env.to_dict()
# Get last sync info
last_sync_job = SyncJob.query.filter_by(
target_site_id=env.id
).order_by(SyncJob.last_run.desc()).first()
if last_sync_job:
env_data['last_sync'] = {
'time': last_sync_job.last_run.isoformat() if last_sync_job.last_run else None,
'status': last_sync_job.last_run_status,
'next_scheduled': last_sync_job.next_run.isoformat() if last_sync_job.next_run else None
}
environments.append(env_data)
return {
'success': True,
'production': prod_site.to_dict(),
'environments': environments
}
@classmethod
def delete_environment(cls, env_site_id: int, delete_files: bool = True,
delete_database: bool = True, user_id: int = None) -> Dict:
"""
Delete an environment.
Docker-isolated environments are delegated to EnvironmentPipelineService
for proper container and Nginx cleanup.
Args:
env_site_id: ID of the environment WordPressSite
delete_files: Whether to delete WordPress files
delete_database: Whether to drop the database
user_id: ID of the user performing the deletion
Returns:
Dict with success status
"""
env_site = WordPressSite.query.get(env_site_id)
if not env_site:
return {'success': False, 'error': 'Environment not found'}
if env_site.is_production:
return {'success': False, 'error': 'Cannot delete a production site this way'}
# Delegate Docker-isolated environments to pipeline service
if env_site.compose_project_name:
from app.services.environment_pipeline_service import EnvironmentPipelineService
return EnvironmentPipelineService.delete_environment(
env_site_id=env_site_id,
user_id=user_id
)
env_app = env_site.application
try:
# Delete database
if delete_database and env_site.db_name:
DatabaseService.mysql_execute(
f"DROP DATABASE IF EXISTS `{env_site.db_name}`"
)
# Delete files
if delete_files and env_app and env_app.root_path:
if os.path.exists(env_app.root_path):
shutil.rmtree(env_app.root_path)
# Delete related sync jobs
SyncJob.query.filter(
(SyncJob.source_site_id == env_site_id) |
(SyncJob.target_site_id == env_site_id)
).delete()
# Delete snapshots
for snapshot in env_site.snapshots:
if os.path.exists(snapshot.file_path):
os.remove(snapshot.file_path)
# Delete records
db.session.delete(env_site)
if env_app:
db.session.delete(env_app)
db.session.commit()
return {'success': True, 'message': 'Environment deleted successfully'}
except Exception as e:
db.session.rollback()
return {'success': False, 'error': str(e)}
@classmethod
def _copy_wordpress_files(cls, source_path: str, target_path: str) -> Dict:
"""Copy WordPress files from source to target."""
try:
if os.path.exists(target_path):
shutil.rmtree(target_path)
shutil.copytree(source_path, target_path)
# Set permissions
import subprocess
subprocess.run(['sudo', 'chown', '-R', 'www-data:www-data', target_path], capture_output=True)
return {'success': True}
except Exception as e:
return {'success': False, 'error': f'Failed to copy files: {str(e)}'}
@classmethod
def _update_wp_config(cls, wp_path: str, db_name: str = None,
db_prefix: str = None, site_url: str = None) -> Dict:
"""Update wp-config.php for the new environment."""
config_path = os.path.join(wp_path, 'wp-config.php')
if not os.path.exists(config_path):
return {'success': False, 'error': 'wp-config.php not found'}
try:
with open(config_path, 'r') as f:
content = f.read()
# Update database name
if db_name:
content = cls._replace_define(content, 'DB_NAME', db_name)
# Update table prefix
if db_prefix:
content = cls._replace_table_prefix(content, db_prefix)
# Update site URL if provided
if site_url:
content = cls._replace_define(content, 'WP_HOME', site_url)
content = cls._replace_define(content, 'WP_SITEURL', site_url)
# Add debug mode for non-production
if "define( 'WP_DEBUG'" not in content and "define('WP_DEBUG'" not in content:
content = content.replace(
"/* That's all",
"define('WP_DEBUG', true);\ndefine('WP_DEBUG_LOG', true);\n\n/* That's all"
)
with open(config_path, 'w') as f:
f.write(content)
return {'success': True}
except Exception as e:
return {'success': False, 'error': f'Failed to update wp-config.php: {str(e)}'}
@staticmethod
def _replace_define(content: str, constant: str, value: str) -> str:
"""Replace a define() statement in wp-config.php."""
import re
# Match both styles: define('CONST', 'value') and define( 'CONST', 'value' )
pattern = rf"define\s*\(\s*['\"]({constant})['\"]\s*,\s*['\"]([^'\"]*)['\"]"
replacement = f"define('{constant}', '{value}'"
return re.sub(pattern, replacement, content)
@staticmethod
def _replace_table_prefix(content: str, prefix: str) -> str:
"""Replace the table prefix in wp-config.php."""
import re
pattern = r"\$table_prefix\s*=\s*['\"]([^'\"]*)['\"]"
replacement = f"$table_prefix = '{prefix}'"
return re.sub(pattern, replacement, content)
@staticmethod
def _get_db_password(site: WordPressSite) -> Optional[str]:
"""Get database password for a site from wp-config.php."""
if not site.application or not site.application.root_path:
return None
config_path = os.path.join(site.application.root_path, 'wp-config.php')
if not os.path.exists(config_path):
return None
try:
with open(config_path, 'r') as f:
content = f.read()
import re
match = re.search(r"define\s*\(\s*['\"]DB_PASSWORD['\"]\s*,\s*['\"]([^'\"]*)['\"]", content)
return match.group(1) if match else None
except Exception:
return None
@staticmethod
def _generate_password(length: int = 16) -> str:
"""Generate a secure random password."""
alphabet = string.ascii_letters + string.digits + '!@#$%^&*'
return ''.join(secrets.choice(alphabet) for _ in range(length))