require 'json' require 'tempfile' namespace :cul do namespace :wp do namespace :migrate do desc "Copies the WordPress installation from one environment to another (e.g. prod to dev)" task :copy_from do require_cap_variables!([:wp_docroot]) require_src_wp_domain_if_multisite! require_dest_wp_domain_if_multisite! set :src_wp_server, ask("source WordPress server (e.g. ldpd-nginx-prod1.cul.columbia.edu)") set :src_wp_docroot, ask("server path to source WordPress installation (to copy from)") puts "Checking #{fetch(:src_wp_server)} for version of WordPress instance at #{fetch(:src_wp_docroot)}..." # Check WP version on source WordPress instance. We require latest version because we always download the latest version for the new instance. failure = false on fetch(:remote_user) + '@' + fetch(:src_wp_server) do within fetch(:src_wp_docroot) do # Ensure that source WordPress is running the latest version result = capture :wp, (fetch(:multisite, false) ? "--url=#{fetch(:src_multisite_domain)}" : ''), 'core', 'check-update' if result.index('Success') puts "Running latest version of WordPress!" else puts 'Could not copy from source WordPress because it is not running the latest version of WordPress. Please update source before running a copy operation.' failure = true end end end next if failure # End if the previous checks failed # Run setup task to destroy current docroot and download new copy of WordPress # Note: The setup task enables maintenance mode during intermediate deployment # step, and then disables maintenance mode before it completes. invoke 'cul:wp:setup' # Enable maintenance mode AGAIN invoke! 'cul:wp:enable_maintenance_mode' # Run wp-content copy task to sync certain wp-content files (everything other than plugins, themes, mu-plugins) from src instance to dest instance invoke 'cul:wp:migrate:copy_wp_content' # Copy database invoke 'cul:wp:migrate:copy_database' # Run cul:wp:searchreplace one or more times invoke 'cul:wp:migrate:post_database_copy_searchreplace' # Download non-repo-managed plugins and themes invoke 'cul:wp:migrate:download_plugins_and_themes' # Disable maintenance mode invoke! 'cul:wp:disable_maintenance_mode' puts 'Copy complete!' end desc "Copies certain wp-content (everything other than plugins, themes, and mu-plugins) from one environment to another (e.g. prod to dev). This task is part of the :copy_from task and isn't meant to be called directly." task :copy_wp_content do # Define src and dest wp content paths, for later use src_wp_content_path = File.join(fetch(:src_wp_docroot), 'wp-content') dest_wp_content_path = File.join(fetch(:wp_docroot), 'wp-content') allowed_file_extensions = [] # Get list of allowed file extensions to copy from source WP instance on fetch(:remote_user) + '@' + fetch(:src_wp_server) do within fetch(:src_wp_docroot) do allowed_file_extensions = JSON.parse(capture(:wp, (fetch(:multisite, false) ? "--url=#{fetch(:src_multisite_domain)}" : ''), 'eval', '"echo cul_allowed_upload_file_extensions_as_json();"')) end end # Copy certain files from src wp-content to dest wp-content on roles(:web) do within fetch(:wp_docroot) do # Note that because we have the '--copy-links' flag below, we're transforming all symlinks into real file copies rsync_base_params = [ '--recursive', '--perms', '--times', '--devices', '--specials', '--copy-links', '--prune-empty-dirs', # Always exclude certain file and directory patterns '--exclude=".nfs*"', '--exclude="*.tmp.*"', '--exclude=".git*"', '--exclude=".svn*"', '--exclude=".hg*"' ] # First copy all files and folders OTHER THAN # plugins, themes, mu-plugins, uploads, and blogs.dir execute :rsync, ( rsync_base_params + [ # Apply exclusions, relative to the rsync src directory '--exclude="plugins"', '--exclude="mu-plugins"', '--exclude="themes"', '--exclude="uploads"', '--exclude="blogs.dir"', # We don't want to copy wflogs because we will be symlinking the # dir on the other side, and Wordfence will recreate it anyway. '--exclude="wflogs"' ] + # Apply user-defined exclusion filters, if present fetch(:wp_content_rsync_exclude_filters, []).map{ |filter_value| "--exclude=\"#{filter_value}\"" } + [ # src directory fetch(:remote_user) + '@' + fetch(:src_wp_server) + ':' + src_wp_content_path + '/', # trailing slash so we only copy content within dir, but not dir itself # dest directory dest_wp_content_path ] ) # Then copy all files and folders from uploads AND blogs.dir, # (with cul-allowed-upload-types file extension filter applied) execute :rsync, ( rsync_base_params + # Apply user-defined exclusion filters, if present fetch(:wp_content_rsync_exclude_filters, []).map{ |filter_value| "--exclude=\"#{filter_value}\"" } + [ # Apply inclusion filters, relative to the rsync src directory '--include="/uploads**/"', '--include="/blogs.dir**/"', ] + # Apply inclusion filters based on file extension # Generating case-insensitive rsync extension inclusion filters by doing this: --include="*.[Cc][Ss][Vv]" allowed_file_extensions.map{ |allowed_file_extension| "--include=\"*.[#{allowed_file_extension.split(//).map{|char| char.upcase + char.downcase }.join('][')}]\"" } + [ # Exclude everything else not included by --include filters '--exclude="*"', # src directory fetch(:remote_user) + '@' + fetch(:src_wp_server) + ':' + src_wp_content_path + '/', # trailing slash so we only copy content within dir, but not dir itself # dest directory dest_wp_content_path ] ) # Then copy all .htaccess files in the docroot execute :rsync, ( rsync_base_params + [ # Include .htaccess files '--include=".htaccess"', # Exclude everything else not included by --include filters '--exclude="*"', # src directory fetch(:remote_user) + '@' + fetch(:src_wp_server) + ':' + File.join(fetch(:src_wp_docroot), '/.'), # trailing '/.' so that .htaccess files are included in the sync scope # dest directory fetch(:wp_docroot) ] ) end end # Compare copied files and tell use which files were not copied puts 'Comparing file lists between environments...' on roles(:web) do within dest_wp_content_path do copy_source_file_list = File.join(fetch(:deploy_to), 'copy-source-file-list.txt') copy_dest_file_list = File.join(fetch(:deploy_to), 'copy-dest-local-file-list.txt') # NOTE: We write the output of file scan operations to a file because there are sometime newline # character issues with the `find` operation output is streamed through the capistrano `execute` method. source_file_list_command = "ssh #{fetch(:remote_user)}@#{fetch(:src_wp_server)} \"cd #{src_wp_content_path} && find . -type f \\( -path '*/uploads/*' -o -path '*/blogs.dir/*' \\)\" > #{copy_source_file_list}" destination_file_list_command = "cd #{dest_wp_content_path} && find . -type f \\( -path '*/uploads/*' -o -path '*/blogs.dir/*' \\) > #{copy_dest_file_list}" execute(source_file_list_command) execute(destination_file_list_command) source_file_list_raw_output = capture("cat #{copy_source_file_list} && rm #{copy_source_file_list}") destination_file_list_raw_output = capture("cat #{copy_dest_file_list} && rm #{copy_dest_file_list}") source_file_set = Set.new(source_file_list_raw_output.split("\n")) destination_file_set = Set.new(destination_file_list_raw_output.split("\n")) files_not_copied = (source_file_set - destination_file_set).to_a.sort # Generate list of files that weren't copied. Display this list to the user. puts ( "The following files were not copied because of file and directory filters:\n" + "-------------------------\n" + "./plugins\n" + "./mu-plugins\n" + "./themes\n" + (files_not_copied.length > 0 ? files_not_copied.join("\n") + "\n" : '') + "-------------------------" ) end end unless enter_y_to_continue("Does that list look okay? Normally it should only include plugins, mu-plugins, and themes.") puts 'Exiting because the list did not look okay.' exit puts 'nope' end end desc "Copies certain wp-content (everything other than plugins, themes, and mu-plugins) from one environment to another (e.g. prod to dev). This task is part of the :copy_from task and isn't meant to be called directly." task :copy_database do export_filename = "db_export_tempfile-#{SecureRandom.uuid}.sql" remote_server_db_export_tempfile_path = File.join(fetch(:src_wp_docroot), '..', export_filename) local_server_db_export_file_path = File.join(fetch(:wp_docroot), '..', export_filename) # Export database from source WordPress instance on fetch(:remote_user) + '@' + fetch(:src_wp_server) do within fetch(:src_wp_docroot) do puts 'Exporting database from source site. This might take a while for large sites...' # Export source WP DB to a temporary file execute :wp, (fetch(:multisite, false) ? "--url=#{fetch(:src_multisite_domain)}" : ''), 'db', 'export', remote_server_db_export_tempfile_path end end begin on roles(:web) do # Import database to destination WordPress instance within fetch(:wp_docroot) do puts 'Importing database. This may take a while for large sites...' # Copy database export from other server to this server execute :rsync, fetch(:remote_user) + '@' + fetch(:src_wp_server) + ':' + remote_server_db_export_tempfile_path, local_server_db_export_file_path # Drop all tables execute :wp, 'db', 'reset', '--yes' # Read in db file execute :wp, 'db', 'import', local_server_db_export_file_path end end ensure # Regardless of whether the db import was successful, make sure to # delete DB temp files on remote server and local server. # Delete on remote server on fetch(:remote_user) + '@' + fetch(:src_wp_server) do within fetch(:src_wp_docroot) do execute :rm, remote_server_db_export_tempfile_path end end # Delete on local server on roles(:web) do within fetch(:wp_docroot) do execute :rm, local_server_db_export_file_path end end end end desc "Runs one or more cul:wp:searchreplace operations after a database copy" task :post_database_copy_searchreplace do # Invoke searchreplace task to update URL puts "\nYou'll probably want to run the cul:wp:searchreplace command now, since it's likely that your WP URL differs between environments." if enter_y_to_continue(color_text("Do you want to run cul:wp:searchreplace?")) invoke! 'cul:wp:searchreplace' while enter_y_to_continue(color_text("Do you want to run cul:wp:searchreplace again?")) invoke! 'cul:wp:searchreplace' end end end desc "Gets a list of all non-mu plugins and themes on SOURCE WP instance and installs them to DESTINATION WP instance, but does not activate them. Activation status is already determined by the copied-over database." task :download_plugins_and_themes do data_for_plugins = [] data_for_themes = [] # Within src wp instance, get list of all plugins and themes with version on fetch(:remote_user) + '@' + fetch(:src_wp_server) do within File.join(fetch(:src_wp_docroot)) do data_for_plugins = JSON.parse(capture(:wp, (fetch(:multisite, false) ? "--url=#{fetch(:src_multisite_domain)}" : ''), 'plugin', 'list', '--fields=name,version,status', '--format=json')) data_for_themes = JSON.parse(capture(:wp, (fetch(:multisite, false) ? "--url=#{fetch(:src_multisite_domain)}" : ''), 'theme', 'list', '--fields=name,version,status', '--format=json')) end end # Within dest wp instance, install specifically versioned plugins and themes on roles(:web) do within File.join(fetch(:wp_docroot)) do # Get list of repo-managed plugins and themes so that we don't attempt to overwrite these directories repo_managed_plugin_names = fetch(:wp_custom_plugins, {}).keys repo_managed_theme_names = fetch(:wp_custom_themes, {}).keys puts "Downloading new copies of non-repo-managed plugins and themes..." remote_zip_plugin_names = (fetch(:additional_plugins_from_remote_zip) || {}).keys # Skip plugins that are repo-managed data_for_plugins.delete_if{|plugin_info| repo_managed_plugin_names.include?(plugin_info['name']) } # Skip plugins that come from a remote zip url data_for_plugins.delete_if{|plugin_info| remote_zip_plugin_names.include?(plugin_info['name']) } data_for_plugins.each do |plugin_info| name = plugin_info['name'] version = plugin_info['version'] status = plugin_info['status'] case status when 'active', 'active-network', 'inactive' execute :wp, (fetch(:multisite, false) ? "--url=#{fetch(:dest_multisite_domain)}" : ''), 'plugin', 'install', name, "--version=#{version}" when 'must-use' puts "--- WARNING: must-use plugin #{name} was not migrated over. It should be put in your blog's repository and deployed through a regular deployment." end end data_for_themes.delete_if{|theme_info| repo_managed_theme_names.include?(theme_info['name']) }.each do |theme_info| name = theme_info['name'] version = theme_info['version'] status = theme_info['status'] case status when 'active' execute :wp, (fetch(:multisite, false) ? "--url=#{fetch(:dest_multisite_domain)}" : ''), 'theme', 'install', name, "--version=#{version}", '--activate' when 'inactive', 'parent' execute :wp, (fetch(:multisite, false) ? "--url=#{fetch(:dest_multisite_domain)}" : ''), 'theme', 'install', name, "--version=#{version}" end end end end end desc 'Sets correct permissions for files in the WP docroot.' task :set_correct_wp_docroot_permissions do # Make wp-content readable and executable for "other" user so nginx, which runs as "nobody", can read wp-files. # Use -L flag because we want to follow symlinks. The deployment relies on symlinks. execute :find, '-L', File.join(fetch(:wp_docroot), 'wp-content'), '-type d -exec chmod o+rx "{}" \;' execute :find, '-L', File.join(fetch(:wp_docroot), 'wp-content'), '-type f -exec chmod o+r "{}" \;' # Make sure that wp-config.php is not world readable. It's only run by php, not nginx. execute :chmod, 'o-r', File.join(fetch(:wp_docroot), 'wp-config.php') end end end end