diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index e47de4e7..6d02b36e 100644 --- a/.github/workflows/ruby.yml +++ b/.github/workflows/ruby.yml @@ -15,16 +15,23 @@ on: jobs: test: - runs-on: ubuntu-latest + container: + image: welaika/wordmove:php7-wordmove6 steps: - uses: actions/checkout@v2 - - name: Set up Ruby - uses: ruby/setup-ruby@v1 + - name: Cache gems + uses: actions/cache@v2 with: - ruby-version: 2.6 + path: .vendor/bundle + key: wordmove-${{ hashFiles('wordmove.gemspec') }} + - name: Install bundler + run: | + echo $(pwd) + bundle config set path .vendor/bundle + gem install bundler:2.3.3 - name: Install dependencies - run: bundle install + run : bundle install - name: Run tests run: bundle exec rake diff --git a/.rubocop.yml b/.rubocop.yml index 53b7e37f..3a4358e7 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,7 +1,8 @@ AllCops: - TargetRubyVersion: 2.6 + TargetRubyVersion: 3.1.0 DisplayCopNames: true DisplayStyleGuide: true + NewCops: enable Exclude: - 'bin/*' @@ -27,10 +28,6 @@ Metrics/CyclomaticComplexity: Metrics/PerceivedComplexity: Max: 10 -Style/StringLiterals: - Enabled: false - EnforcedStyle: double_quotes - Style/Documentation: Enabled: false diff --git a/.ruby-version b/.ruby-version index 57cf282e..fd2a0186 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -2.6.5 +3.1.0 diff --git a/.vscode/launch.json b/.vscode/launch.json index 0eb376f0..e710b07f 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -2,72 +2,53 @@ "version": "0.2.0", "configurations": [ { - "name": "Debug Local File", - "type": "Ruby", - "request": "launch", - "cwd": "${workspaceRoot}", - "program": "${workspaceRoot}/main.rb" + "type": "rdbg", + "name": "Attach to remote rdbg", + "request": "attach" }, { - "name": "Listen for rdebug-ide", - "type": "Ruby", - "request": "attach", - "cwd": "${workspaceRoot}", - "remoteHost": "127.0.0.1", - "remotePort": "1234", - "remoteWorkspaceRoot": "${workspaceRoot}" + "type": "rdbg", + "name": "Debug current file", + "request": "launch", + "script": "${file}", + "askParameters": true }, { - "name": "Rails server", + "name": "Debug Local File", "type": "Ruby", "request": "launch", "cwd": "${workspaceRoot}", - "program": "${workspaceRoot}/bin/rails", - "args": [ - "server" - ] + "program": "${workspaceRoot}/main.rb" }, { "name": "RSpec - all", - "type": "Ruby", + "type": "rdbg", "request": "launch", "cwd": "${workspaceRoot}", - "program": "${workspaceRoot}/bin/rspec", - "args": [ - "-I", - "${workspaceRoot}" - ] + "command": "bundle exec", + "script": "rspec", + "askParameters": false, + "args": [] }, { "name": "RSpec - active spec file only", - "type": "Ruby", + "type": "rdbg", "request": "launch", "cwd": "${workspaceRoot}", - "program": "${workspaceRoot}/bin/rspec", - "args": [ - "-I", - "${workspaceRoot}", - "${file}" - ] + "command": "bundle exec rspec", + "script": "${file}", + "askParameters": false, + "args": [] }, { "name": "RSpec - active test only", - "type": "Ruby", - "request": "launch", - "cwd": "${workspaceRoot}", - "program": "${workspaceRoot}/bin/rspec", - "args": [ - "-I", - "${workspaceRoot}", - "${file}:${lineNumber}" - ] - }, - { - "name": "Cucumber", - "type": "Ruby", + "type": "rdbg", "request": "launch", "cwd": "${workspaceRoot}", - "program": "${workspaceRoot}/bin/cucumber" + "command": "bundle exec rspec", + "script": "${file}:${lineNumber}", + "askParameters": false, + "args": [] } ] } diff --git a/.yardopts b/.yardopts new file mode 100644 index 00000000..4c0d3525 --- /dev/null +++ b/.yardopts @@ -0,0 +1 @@ +--plugin activesupport-concern diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7ae91a90..1c4456e8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -77,6 +77,21 @@ or run the executable directly bin/wordmove --version ``` +#### Documenting + +A large part of the code is documented using https://yardoc.org/. + +Please, stick with this practice when you're implementing new code. If you want to preview yard +documentation locally + +``` +yard server -r +``` + +then visit the served HTML at http://localhost:8808. + +YARD documentation is automatically published by rubygems site at each release. + ### Maintainer tasks ToDo: diff --git a/Rakefile b/Rakefile index 5972272b..65468408 100644 --- a/Rakefile +++ b/Rakefile @@ -1,8 +1,9 @@ -require "bundler/gem_tasks" -require "rspec/core/rake_task" +require 'bundler/gem_tasks' +require 'rspec/core/rake_task' require 'rubocop/rake_task' RSpec::Core::RakeTask.new(:spec) -task default: :spec RuboCop::RakeTask.new(:rubocop) + task default: :rubocop +task default: :spec diff --git a/exe/wordmove b/exe/wordmove index e53071b9..9dce4f1a 100755 --- a/exe/wordmove +++ b/exe/wordmove @@ -3,4 +3,5 @@ $LOAD_PATH.unshift File.expand_path('../lib', __dir__) require 'wordmove' -Wordmove::CLI.start + +Dry::CLI.new(Wordmove::CLI::Commands).call diff --git a/lib/wordmove.rb b/lib/wordmove.rb index 355c7b71..a3b619b0 100644 --- a/lib/wordmove.rb +++ b/lib/wordmove.rb @@ -4,17 +4,21 @@ require 'active_support/core_ext' require 'colorize' require 'dotenv' +require 'dry/cli' +require 'dry-configurable' +require 'dry/files' require 'erb' require 'kwalify' +require 'light-service' require 'logger' require 'open-uri' require 'ostruct' -require 'thor' -require 'thor/group' require 'yaml' require 'photocopier' +require 'wordmove/wpcli' + require 'wordmove/cli' require 'wordmove/doctor' require 'wordmove/doctor/movefile' @@ -27,20 +31,38 @@ require 'wordmove/hook' require 'wordmove/logger' require 'wordmove/movefile' -require 'wordmove/sql_adapter/default' -require 'wordmove/sql_adapter/wpcli' require 'wordmove/wordpress_directory' -require "wordmove/version" -require "wordmove/environments_list" +require 'wordmove/version' +require 'wordmove/environments_list' -require 'wordmove/generators/movefile_adapter' require 'wordmove/generators/movefile' -require 'wordmove/deployer/base' -require 'wordmove/deployer/ftp' -require 'wordmove/deployer/ssh' -require 'wordmove/deployer/ssh/default_sql_adapter' -require 'wordmove/deployer/ssh/wpcli_sql_adapter' +require 'wordmove/db_paths_config' + +require 'wordmove/actions/helpers' +require 'wordmove/actions/ssh/helpers' +require 'wordmove/actions/ftp/helpers' +Dir[File.join(__dir__, 'wordmove/actions/**/*.rb')].each { |file| require file } +Dir[File.join(__dir__, 'wordmove/organizers/**/*.rb')].each { |file| require file } module Wordmove + # Interactors' namespce. Interactors are called "Actions", following the LightService convention. + # In this namespace there are two kinds of "Actions": + # * local environment actions + # * protocol agnostic remote environment actions + # @see https://github.com/adomokos/light-service/blob/master/README.md LightService README + module Actions + # Ssh actions' namespace. Here are SSH protocol specific actions and organizers + # for remote environments + module Ssh + end + end + + # Organizers are responsible of running organizer procedures putting together Actions + # following business logic requirements. + module Organizers + # Organizers for the Ssh protocol + module Ssh + end + end end diff --git a/lib/wordmove/actions/adapt_local_db.rb b/lib/wordmove/actions/adapt_local_db.rb new file mode 100644 index 00000000..b03777d6 --- /dev/null +++ b/lib/wordmove/actions/adapt_local_db.rb @@ -0,0 +1,142 @@ +module Wordmove + module Actions + # + # Adapt the local DB for the remote destination. + # "To adapt" in Wordmove jargon means to transform URLs strings into the database. This action + # will substitute local URLs with remote ones, in order to make the DB to work correctly once + # pushed to the remote wordpress installation. + # + class AdaptLocalDb + extend ::LightService::Action + include Wordmove::Actions::Helpers + include Wordmove::WpcliHelpers + + expects :local_options, + :remote_options, + :cli_options, + :logger, + :photocopier, + :db_paths + + # @!method execute + # @param local_options [Hash] Local host options fetched from + # movefile (with symbolized keys) + # @param remote_options [Hash] Remote host options fetched from + # movefile (with symbolized keys) + # @param cli_options [Hash] Command line options + # @param logger [Wordmove::Logger] + # @param photocopier [Photocopier::SSH|Photocopier::FTP] + # @param db_paths [BbPathsConfig] Configuration object for database + # @!scope class + # @return [LightService::Context] Action's context + executed do |context| # rubocop:disable Metrics/BlockLength + context.logger.task 'Adapt local DB' + + unless wp_in_path? + raise UnmetPeerDependencyError, 'WP-CLI is not installed or not in your $PATH' + end + + next context if simulate?(cli_options: context.cli_options) + + context.logger.task_step true, dump_command(context) + begin + system(dump_command(context), exception: true) + rescue RuntimeError, SystemExit => e + context.fail_and_return!("Local command status reports an error: #{e.message}") + end + + if context.cli_options[:no_adapt] + context.logger.warn 'Skipping DB adapt' + else + %i[vhost wordpress_path].each do |key| + command = search_replace_command(context, key) + context.logger.task_step true, command + + begin + system(command, exception: true) + rescue RuntimeError, SystemExit => e + context.fail_and_return!("Local command status reports an error: #{e.message}") + end + end + end + + context.logger.task_step true, dump_adapted_command(context) + begin + system(dump_adapted_command(context), exception: true) + rescue RuntimeError, SystemExit => e + context.fail_and_return!("Local command status reports an error: #{e.message}") + end + + if context.photocopier.is_a? Photocopier::SSH + context.logger.task_step true, compress_command(context) + begin + system(compress_command(context), exception: true) + rescue RuntimeError, SystemExit => e + context.fail_and_return!("Local command status reports an error: #{e.message}") + end + end + + context.logger.task_step true, import_original_db_command(context) + begin + system(import_original_db_command(context), exception: true) + rescue RuntimeError, SystemExit => e + context.fail_and_return!("Local command status reports an error: #{e.message}") + end + end + + def self.dump_command(context) + "wp db export #{context.db_paths.local.path} --allow-root --quiet " \ + "--path=#{wpcli_config_path(context)}" + end + + def self.dump_adapted_command(context) + "wp db export #{context.db_paths.local.adapted_path} --allow-root --quiet " \ + "--path=#{wpcli_config_path(context)}" + end + + def self.import_original_db_command(context) + "wp db import #{context.db_paths.local.path} --allow-root --quiet " \ + "--path=#{wpcli_config_path(context)}" + end + + def self.compress_command(context) + command = ['nice'] + command << '-n' + command << '0' + command << 'gzip' + command << '-9' + command << '-f' + command << "\"#{context.db_paths.local.adapted_path}\"" + command.join(' ') + end + + # Compose and returns the search-replace command. It's intended to be + # used from a +LightService::Action+ + # + # @param context [LightService::Context] The context of an action + # @param config_key [:vhost, :wordpress_path] Determines what will be replaced in DB + # @return [String] + # @!scope class + def self.search_replace_command(context, config_key) + unless %i[vhost wordpress_path].include?(config_key) + raise ArgumentError, "Unexpected `config_key` #{config_key}.:vhost" \ + 'or :wordpress_path expected' + end + + [ + 'wp search-replace', + "--path=#{wpcli_config_path(context)}", + '"\A' + context.dig(:local_options, config_key) + '\Z"', # rubocop:disable Style/StringConcatenation + '"' + context.dig(:remote_options, config_key) + '"', # rubocop:disable Style/StringConcatenation + '--regex-delimiter="|"', + '--regex', + '--precise', + '--quiet', + '--skip-columns=guid', + '--all-tables', + '--allow-root' + ].join(' ') + end + end + end +end diff --git a/lib/wordmove/actions/adapt_remote_db.rb b/lib/wordmove/actions/adapt_remote_db.rb new file mode 100644 index 00000000..9fa59046 --- /dev/null +++ b/lib/wordmove/actions/adapt_remote_db.rb @@ -0,0 +1,128 @@ +module Wordmove + module Actions + # + # Adapt the remote DB for the local destination. + # "To adapt" in Wordmove jargon means to transform URLs strings into the database. This action + # will substitute remote URLs with local ones, in order to make the DB to work correctly once + # pulled to the local wordpress installation. + # + # @note This action is not responsible to download the remote DB nor to backup any DB at all. + # It expects to find a dump of the remote DB into +context.db_paths.local.gzipped_path+ + # (SSH) or +context.db_paths.local.path+ (FTP), otherwise it will fail and stop the + # procedure. + # + class AdaptRemoteDb + extend ::LightService::Action + include Wordmove::Actions::Helpers + include Wordmove::WpcliHelpers + + expects :local_options, + :cli_options, + :logger, + :db_paths + + # @!method execute + # @param local_options [Hash] Local host options fetched from + # movefile (with symbolized keys) + # @param cli_options [Hash] Command line options + # @param logger [Wordmove::Logger] + # @param db_paths [BbPathsConfig] Configuration object for database + # @!scope class + # @return [LightService::Context] Action's context + executed do |context| # rubocop:disable Metrics/BlockLength + context.logger.task 'Adapt remote DB' + + unless wp_in_path? + raise UnmetPeerDependencyError, 'WP-CLI is not installed or not in your $PATH' + end + + next context if simulate?(cli_options: context.cli_options) + + if File.exist?(context.db_paths.local.gzipped_path) + context.logger.task_step true, uncompress_command(context) + begin + system(uncompress_command(context), exception: true) + rescue RuntimeError, SystemExit => e + context.fail_and_return!("Local command status reports an error: #{e.message}") + end + end + + unless File.exist?(context.db_paths.local.path) + context.fail_and_return!( + "Cannot find the dump file to adapt in #{context.db_paths.local.path}" + ) + end + + context.logger.task_step true, import_db_command(context) + begin + system(import_db_command(context), exception: true) + rescue RuntimeError, SystemExit => e + context.fail_and_return!("Local command status reports an error: #{e.message}") + end + + if context.cli_options[:no_adapt] + context.logger.warn 'Skipping DB adapt' + next context + end + + %i[vhost wordpress_path].each do |key| + command = search_replace_command(context, key) + context.logger.task_step true, command + begin + system(command, exception: true) + rescue RuntimeError, SystemExit => e + context.fail_and_return!("Local command status reports an error: #{e.message}") + end + end + + context.logger.success 'Local DB adapted' + end + + # Construct the command to deflate a compressed file as a string. + # + # @param file_path [String] The path where the file to be deflated is located + # @return [String] the command + # @!scope class + def self.uncompress_command(context) + command = ['gzip'] + command << '-d' + command << '-f' + command << "\"#{context.db_paths.local.gzipped_path}\"" + command.join(' ') + end + + def self.import_db_command(context) + "wp db import #{context.db_paths.local.path} --allow-root --quiet " \ + "--path=#{wpcli_config_path(context)}" + end + + # Compose and returns the search-replace command. It's intended to be + # used from a +LightService::Action+ + # + # @param context [LightService::Context] The context of an action + # @param config_key [:vhost, :wordpress_path] Determines what will be replaced in DB + # @return [String] + # @!scope class + def self.search_replace_command(context, config_key) + unless %i[vhost wordpress_path].include?(config_key) + raise ArgumentError, "Unexpected `config_key` #{config_key}.:vhost" \ + 'or :wordpress_path expected' + end + + [ + 'wp search-replace', + "--path=#{wpcli_config_path(context)}", + '"\A' + context.dig(:remote_options, config_key) + '\Z"', # rubocop:disable Style/StringConcatenation + '"' + context.dig(:local_options, config_key) + '"', # rubocop:disable Style/StringConcatenation + '--regex-delimiter="|"', + '--regex', + '--precise', + '--quiet', + '--skip-columns=guid', + '--all-tables', + '--allow-root' + ].join(' ') + end + end + end +end diff --git a/lib/wordmove/actions/backup_local_db.rb b/lib/wordmove/actions/backup_local_db.rb new file mode 100644 index 00000000..c6fb152f --- /dev/null +++ b/lib/wordmove/actions/backup_local_db.rb @@ -0,0 +1,70 @@ +module Wordmove + module Actions + # + # Take a backup of the local database and save it in +wp-content/+ folder. + # + class BackupLocalDb + extend ::LightService::Action + include Wordmove::Actions::Helpers + + expects :local_options + expects :cli_options + expects :db_paths + expects :logger + + # @!method execute + # @param local_options [Hash] Local host options fetched from + # movefile (with symbolized keys) + # @param cli_options [Hash] Command line options + # @param db_paths [BbPathsConfig] Configuration object for database + # @param logger [Wordmove::Logger] + # @!scope class + # @return [LightService::Context] Action's context + executed do |context| + context.logger.task 'Backup local DB' + + if simulate?(cli_options: context.cli_options) + context.logger.info 'A backup of the local DB would have been saved into ' \ + "#{context.db_paths.backup.local.gzipped_path}, " \ + 'but you\'re simulating' + next context + end + + context.logger.task_step true, dump_command(context) + + begin + system(dump_command(context), exception: true) + rescue RuntimeError, SystemExit => e + context.fail_and_return!("Local command status reports an error: #{e.message}") + end + + context.logger.task_step true, compress_command(context) + + begin + system(compress_command(context), exception: true) + rescue RuntimeError, SystemExit => e + context.fail_and_return!("Local command status reports an error: #{e.message}") + end + + context.logger.success( + "Backup saved at #{context.db_paths.backup.local.gzipped_path}" + ) + end + + def self.dump_command(context) + "wp db export #{context.db_paths.backup.local.path} --allow-root --quiet" + end + + def self.compress_command(context) + command = ['nice'] + command << '-n' + command << '0' + command << 'gzip' + command << '-9' + command << '-f' + command << "\"#{context.db_paths.backup.local.path}\"" + command.join(' ') + end + end + end +end diff --git a/lib/wordmove/actions/delete_local_file.rb b/lib/wordmove/actions/delete_local_file.rb new file mode 100644 index 00000000..c78aaff2 --- /dev/null +++ b/lib/wordmove/actions/delete_local_file.rb @@ -0,0 +1,34 @@ +module Wordmove + module Actions + # Delete a local file situated at the given path. + # Command won't be run if +--simulate+ flag is present on CLI. + # @note This action is *not* meant to be organized, but as a standalone one. + class DeleteLocalFile + extend LightService::Action + include Wordmove::Actions::Helpers + + expects :file_path, + :logger, + :cli_options + + # @!method execute + # @param file_path [String] + # @param logger [Wordmove::Logger] + # @param cli_options [Hash] Command line options (with symbolized keys) + # @!scope class + # @return [LightService::Context] Action's context + executed do |context| + context.logger.task_step true, "delete: '#{context.file_path}'" + + next context if simulate?(cli_options: context.cli_options) + + unless File.exist?(context.file_path) + context.logger.info "File #{context.file_path} does not exist. Nothing done." + next context + end + + File.delete(context.file_path) + end + end + end +end diff --git a/lib/wordmove/actions/delete_remote_file.rb b/lib/wordmove/actions/delete_remote_file.rb new file mode 100644 index 00000000..e0166ec5 --- /dev/null +++ b/lib/wordmove/actions/delete_remote_file.rb @@ -0,0 +1,43 @@ +module Wordmove + module Actions + # Delete a remote file + # @note This action is *not* meant to be organized, but as a standalone one. + class DeleteRemoteFile + extend LightService::Action + include Wordmove::Actions::Helpers + + expects :photocopier, + :logger, + :cli_options, + :remote_file + + # @!method execute + # @param photocopier [Photocopier] + # @param logger [Wordmove::Logger] + # @param cli_options [Hash] Command line options (with symbolized keys) + # @param remote_file ((String) remote file path) + # @!scope class + # @return [LightService::Context] Action's context + executed do |context| + command = 'delete' + + context.logger.task_step false, "#{command}: #{context.remote_file}" + + next context if simulate?(cli_options: context.cli_options) + + _stdout, stderr, exit_code = context.photocopier.send(command, context.remote_file) + + next context if exit_code&.zero? + + # When +context.photocopier+ is a +Photocopier::FTP+ instance, +delte+ will always + # return +nil+; so it's impossible to correctly fail the context when using + # FTP protocol. The problem is how +Net::FTP+ ruby class behaves. + # IMO this is an acceptable tradeoff. + unless exit_code.nil? + context.fail! "Error code #{exit_code} returned while deleting file "\ + "#{context.remote_file}: #{stderr}" + end + end + end + end +end diff --git a/lib/wordmove/actions/filter_and_setup_tasks_to_run.rb b/lib/wordmove/actions/filter_and_setup_tasks_to_run.rb new file mode 100644 index 00000000..366d010c --- /dev/null +++ b/lib/wordmove/actions/filter_and_setup_tasks_to_run.rb @@ -0,0 +1,42 @@ +module Wordmove + module Actions + # Given the command line options and given the denied-by-config actions, + # selects the actions to be run altering the context. + class FilterAndSetupTasksToRun + extend ::LightService::Action + include Wordmove::Actions::Helpers + include Wordmove::Actions::Ssh::Helpers + include WordpressDirectory::RemoteHelperMethods + + expects :guardian, + :cli_options + promises :folder_tasks, + :database_task, + :wordpress_task + + # @!method execute + # @param guardian [Wordmove::Guardian] + # @param cli_options [Hash] + # @!scope class + # @return [LightService::Context] Action's context + executed do |context| + all_tasks = Wordmove::CLI::PullPushShared::WORDPRESS_OPTIONS + + requested_tasks = all_tasks.select do |task| + context.cli_options[task] || + (context.cli_options[:all] && context.cli_options[task] != false) + end + + allowed_tasks = requested_tasks.select { |task| context.guardian.allows task } + + # Since we `promises` the following variables, we cannot set them as `nil` + context.database_task = allowed_tasks.delete(:db) || false + context.wordpress_task = allowed_tasks.delete(:wordpress) || false + # :db and :wordpress were just removed, so we consider + # the reminders as folder tasks. It's a weak assumption + # though. + context.folder_tasks = allowed_tasks + end + end + end +end diff --git a/lib/wordmove/actions/ftp/backup_remote_db.rb b/lib/wordmove/actions/ftp/backup_remote_db.rb new file mode 100644 index 00000000..cad04311 --- /dev/null +++ b/lib/wordmove/actions/ftp/backup_remote_db.rb @@ -0,0 +1,54 @@ +module Wordmove + module Actions + module Ftp + # Bakups an already downloaded remote DB dump + class BackupRemoteDb + extend ::LightService::Action + include Wordmove::Actions::Helpers + + expects :remote_options, + :cli_options, + :logger, + :photocopier, + :db_paths + + # @!method execute + # @param remote_options [Hash] Options for the remote host fetched from the movefile + # @param cli_options [Hash] Command line options (with symbolized keys) + # @param logger [Wordmove::Logger] + # @param photocopier [Photocopier::FTP] + # @param db_paths [BbPathsConfig] Configuration object for database + # @!scope class + # @return [LightService::Context] Action's context + executed do |context| + context.logger.task 'Backup remote DB' + + if simulate?(cli_options: context.cli_options) + context.logger.info 'A backup of the remote DB would have been saved into ' \ + "#{context.db_paths.backup.remote.gzipped_path}, " \ + 'but you\'re simulating' + next context + end + + begin + result = Wordmove::Actions::RunLocalCommand.execute( + logger: context.logger, + cli_options: context.cli_options, + command: compress_command(file_path: context.db_paths.local.path) + ) + raise(result.message) if result.failure? + + FileUtils.mv( + context.db_paths.local.gzipped_path, + context.db_paths.backup.remote.gzipped_path + ) + + context.logger.success("Backup saved at #{context.db_paths.backup.remote.gzipped_path}") + rescue Errno::ENOENT, RuntimeError => e + context.fail_and_return!("Remote DB backup failed with: <#{e.message}>. Aborting.") + end + end + end + end + end +end diff --git a/lib/wordmove/actions/ftp/cleanup_after_adapt.rb b/lib/wordmove/actions/ftp/cleanup_after_adapt.rb new file mode 100644 index 00000000..91ab43dc --- /dev/null +++ b/lib/wordmove/actions/ftp/cleanup_after_adapt.rb @@ -0,0 +1,70 @@ +module Wordmove + module Actions + module Ftp + # Cleanup file created during DB push/pull operations + class CleanupAfterAdapt + extend ::LightService::Action + include Wordmove::Actions::Helpers + + expects :db_paths, + :cli_options, + :logger, + :photocopier + + # @!method execute + # @param logger [Wordmove::Logger] + # @param cli_options [Hash] Command line options (with symbolized keys) + # @param db_paths [BbPathsConfig] Configuration object for database + # @param photocopier [Photocopier::FTP] + # @!scope class + # @return [LightService::Context] Action's context + executed do |context| # rubocop:disable Metrics/BlockLength + context.logger.task 'Cleanup' + + if simulate?(cli_options: context.cli_options) + context.logger.info 'No cleanup during simulation' + next context + end + + result = Wordmove::Actions::DeleteLocalFile.execute( + logger: context.logger, + cli_options: context.cli_options, + file_path: context.db_paths.local.path + ) + + if result.failure? + context.logger.warning 'Failed to delete local file ' \ + "#{context.db_paths.local.path} because: " \ + "#{result.message}" \ + '. Manual intervention required' + end + + [ + context.db_paths.ftp.remote.dump_script_path, + context.db_paths.ftp.remote.import_script_path, + context.db_paths.remote.path, + context.db_paths.ftp.remote.dumped_path + ].each do |file| + begin + result = Wordmove::Actions::DeleteRemoteFile.execute( + photocopier: context.photocopier, + logger: context.logger, + cli_options: context.cli_options, + remote_file: file + ) + rescue Net::FTPPermError => _e + context.logger.info "#{file} doesn't exist remotely. Nothing to cleanup" + end + + if result.failure? # rubocop:disable Style/Next + context.logger.warning 'Failed to delete remote file ' \ + "#{file} because: " \ + "#{result.message}" \ + '. Manual intervention required' + end + end + end + end + end + end +end diff --git a/lib/wordmove/actions/ftp/download_remote_db.rb b/lib/wordmove/actions/ftp/download_remote_db.rb new file mode 100644 index 00000000..1d31fad6 --- /dev/null +++ b/lib/wordmove/actions/ftp/download_remote_db.rb @@ -0,0 +1,69 @@ +module Wordmove + module Actions + module Ftp + # Downloads the remote DB over FTP protocol + class DownloadRemoteDb + extend ::LightService::Action + include Wordmove::Actions::Helpers + include Wordmove::Actions::Ftp::Helpers + include WordpressDirectory::LocalHelperMethods + include WordpressDirectory::RemoteHelperMethods + + expects :remote_options, + :cli_options, + :logger, + :photocopier, + :db_paths + + # @!method execute + # @param remote_options [Hash] Remote host options fetched from + # movefile (with symbolized keys) + # @param cli_options [Hash] Command line options (with symbolized keys) + # @param logger [Wordmove::Logger] + # @param photocopier [Photocopier::FTP] + # @param db_paths [BbPathsConfig] Configuration object for database + # @!scope class + # @return [LightService::Context] Action's context + executed do |context| # rubocop:disable Metrics/BlockLength + context.logger.task 'Download remote DB' + + if simulate?(cli_options: context.cli_options) + context.logger.info 'A dump of the remote DB would have been saved into ' \ + "#{context.db_paths.local.path}, " \ + 'but you\'re simulating' + next context + end + + result = Wordmove::Actions::PutFile.execute( + photocopier: context.photocopier, + logger: context.logger, + cli_options: context.cli_options, + command_args: [ + context.db_paths.ftp.local.generated_dump_script_path, + context.db_paths.ftp.remote.dump_script_path + ] + ) + context.fail_and_return!(result.message) if result.failure? + + dump_url = [ + context.db_paths.ftp.remote.dump_script_url, + '?shared_key=', + context.db_paths.ftp.token + ].join + + begin + download(url: dump_url, local_path: context.db_paths.local.path) + rescue => _e # rubocop:disable Style/RescueStandardError + context.fail_and_return!(e.message) + end + + unless File.exist? context.db_paths.local.path + context.fail_and_return!('Download of remote DB failed') + end + + context.logger.success "Remote DB dump downloaded in #{context.db_paths.local.path}" + end + end + end + end +end diff --git a/lib/wordmove/actions/ftp/get_directory.rb b/lib/wordmove/actions/ftp/get_directory.rb new file mode 100644 index 00000000..3a15a883 --- /dev/null +++ b/lib/wordmove/actions/ftp/get_directory.rb @@ -0,0 +1,67 @@ +module Wordmove + module Actions + module Ftp + # Syncs a whole directory over FTP protocol from the remote server to local host + class GetDirectory + extend LightService::Action + include Wordmove::Actions::Helpers + include WordpressDirectory::RemoteHelperMethods + include WordpressDirectory::LocalHelperMethods + + # :folder_task is expected to be one symbol from Wordmove::CLI.wordpress_options array + expects :logger, + :local_options, + :remote_options, + :cli_options, + :photocopier, + :folder_task + + # @!method execute + # @param logger [Wordmove::Logger] + # @param local_options [Hash] Local host options fetched from + # movefile (with symbolized keys) + # @param remote_options [Hash] Remote host options fetched from + # movefile (with symbolized keys) + # @param cli_options [Hash] Command line options (with symbolized keys) + # @param photocopier [Photocopier::FTP] + # @param folder_task [Symbol] Symbolazied folder name + # @!scope class + # @return [LightService::Context] Action's context + executed do |context| + context.logger.task "Pulling #{context.folder_task}" + + next context if simulate?(cli_options: context.cli_options) + + command = 'get_directory' + + # This action can generate `command_args` by itself, + # but it gives the context the chance to ovveride it. + # By the way this variable is not `expects`ed. + # Note that we do not use the second argument to `fetch` + # to express a default value, because it would be greedly interpreted + # but if `command_args` is already defined by context, then it's + # possible that `"remote_#{context.folder_task}_dir"` could + # not be defined. + command_args = context.fetch(:command_args) || [ + send( + "remote_#{context.folder_task}_dir", + remote_options: context.remote_options + ).path, + send( + "local_#{context.folder_task}_dir", + local_options: context.local_options + ).path, + paths_to_exclude(remote_options: context.remote_options) + ] + + context.logger.task_step false, "#{command}: #{command_args.join(' ')}" + result = context.photocopier.send(command, *command_args) + + next context if result == true + + context.fail!("Failed to pull #{context.folder_task}") + end + end + end + end +end diff --git a/lib/wordmove/actions/ftp/helpers.rb b/lib/wordmove/actions/ftp/helpers.rb new file mode 100644 index 00000000..e7bd123b --- /dev/null +++ b/lib/wordmove/actions/ftp/helpers.rb @@ -0,0 +1,91 @@ +module Wordmove + module Actions + module Ftp + module Helpers + extend ActiveSupport::Concern + + class_methods do # rubocop:disable Metrics/BlockLength + # + # (In)Utility method to retrieve ftp options from the superset of remote options + # + # @param [Hash] remote_options Remote host options fetched from movefile + # (with symbolized keys) + # + # @return [Hash] Ftp options from the movefile + # + def ftp_options(remote_options:) + remote_options[:ftp] + end + + # + # Escape a string to be printed into PHP files + # + # @param [String] string The string to escape + # + # @return [String] The escaped string + # + def escape_php(string:) + return '' unless string + + # replaces \ with \\ + # replaces ' with \' + string.gsub('\\', '\\\\\\').gsub(/'/, '\\\\\'') + end + + # + # Generate a token + # + # @return [String] A random hexadecimal string + # + def remote_php_scripts_token + SecureRandom.hex(40) + end + + # + # Generate THE PHP dump script, protected by a token to ensure only Wordmove will run it + # + # @param [Hash] remote_db_options The remote DB configurations fetched from movefile + # @param [String] token The token that will be used to protect the execution of the script + # + # @return [String] The PHP file as string + # + def generate_dump_script(remote_db_options:, token:) + template = ERB.new( + File.read(File.join(File.dirname(__FILE__), '../../assets/dump.php.erb')) + ) + template.result(binding) + end + + # + # Generate THE PHP import script, protected by a token to ensure only Wordmove will run it + # + # @param [Hash] remote_db_options The remote DB configurations fetched from movefile + # @param [String] token The token that will be used to protect the execution of the script + # + # @return [String] The PHP file as string + # + def generate_import_script(remote_db_options:, token:) + template = ERB.new( + File.read(File.join(File.dirname(__FILE__), '../../assets/import.php.erb')) + ) + template.result(binding) + end + + # + # Download a file from the internet making a simple GET request + # + # @param [String] url The URL of the resource to download + # @param [String] local_path The local path where the resource will be saved + # + # @return [nil] + # + def download(url:, local_path:) + File.open(local_path, 'w') do |file| + file << URI.parse(url).read + end + end + end + end + end + end +end diff --git a/lib/wordmove/actions/ftp/pull_wordpress.rb b/lib/wordmove/actions/ftp/pull_wordpress.rb new file mode 100644 index 00000000..a6325d5d --- /dev/null +++ b/lib/wordmove/actions/ftp/pull_wordpress.rb @@ -0,0 +1,56 @@ +module Wordmove + module Actions + module Ftp + # Syncs wordpress folder (usually root folder), exluding +wp-content/+ folder, over FTP + # protocol from the remote server to local host + class PullWordpress + extend ::LightService::Action + include Wordmove::Actions::Helpers + include WordpressDirectory::RemoteHelperMethods + + expects :remote_options, + :local_options, + :logger, + :photocopier + + # @!method execute + # @param remote_options [Hash] Remote host options fetched from + # movefile (with symbolized keys) + # @param local_options [Hash] Local host options fetched from + # movefile (with symbolized keys) + # @param logger [Wordmove::Logger] + # @param photocopier [Photocopier::FTP] + # @!scope class + # @return [LightService::Context] Action's context + executed do |context| + local_path = context.local_options[:wordpress_path] + + remote_path = context.remote_options[:wordpress_path] + + wp_content_relative_path = remote_wp_content_dir( + remote_options: context.remote_options + ).relative_path + + exclude_wp_content = exclude_dir_contents( + path: wp_content_relative_path + ) + + exclude_paths = paths_to_exclude( + remote_options: context.remote_options + ).push(exclude_wp_content) + + result = Wordmove::Actions::Ftp::GetDirectory.execute( + photocopier: context.photocopier, + logger: context.logger, + command_args: [remote_path, local_path, exclude_paths], + folder_task: :wordpress, + local_options: context.local_options, + remote_options: context.remote_options, + cli_options: context.cli_options + ) + context.fail!(result.message) if result.failure? + end + end + end + end +end diff --git a/lib/wordmove/actions/ftp/push_wordpress.rb b/lib/wordmove/actions/ftp/push_wordpress.rb new file mode 100644 index 00000000..971c2bab --- /dev/null +++ b/lib/wordmove/actions/ftp/push_wordpress.rb @@ -0,0 +1,54 @@ +module Wordmove + module Actions + module Ftp + # Syncs wordpress folder (usually root folder), exluding +wp-content/+ folder, over FTP + # protocol from local host to the remote server + class PushWordpress + extend ::LightService::Action + include Wordmove::Actions::Helpers + include WordpressDirectory::LocalHelperMethods + + expects :remote_options, + :local_options, + :logger, + :photocopier + + # @!method execute + # @param remote_options [Hash] Remote host options fetched from + # movefile (with symbolized keys) + # @param local_options [Hash] Local host options fetched from + # movefile (with symbolized keys) + # @param logger [Wordmove::Logger] + # @param photocopier [Photocopier::FTP] + # @!scope class + # @return [LightService::Context] Action's context + executed do |context| + local_path = context.local_options[:wordpress_path] + + remote_path = context.remote_options[:wordpress_path] + + wp_content_relative_path = local_wp_content_dir( + local_options: context.local_options + ).relative_path + + exclude_wp_content = exclude_dir_contents(path: wp_content_relative_path) + + exclude_paths = paths_to_exclude( + remote_options: context.remote_options + ).push(exclude_wp_content) + + result = Wordmove::Actions::Ftp::PutDirectory.execute( + photocopier: context.photocopier, + logger: context.logger, + command_args: [local_path, remote_path, exclude_paths], + folder_task: :wordpress, + local_options: context.local_options, + remote_options: context.remote_options, + cli_options: context.cli_options + ) + context.fail!(result.message) if result.failure? + end + end + end + end +end diff --git a/lib/wordmove/actions/ftp/put_and_import_dump_remotely.rb b/lib/wordmove/actions/ftp/put_and_import_dump_remotely.rb new file mode 100644 index 00000000..a551dc5b --- /dev/null +++ b/lib/wordmove/actions/ftp/put_and_import_dump_remotely.rb @@ -0,0 +1,81 @@ +module Wordmove + module Actions + module Ftp + # Uploads a DB dump to remote host and import it in the remote database over FTP protocol + class PutAndImportDumpRemotely + extend ::LightService::Action + include Wordmove::Actions::Helpers + include Wordmove::Actions::Ftp::Helpers + include WordpressDirectory::RemoteHelperMethods + include WordpressDirectory::LocalHelperMethods + + expects :remote_options, + :cli_options, + :logger, + :photocopier, + :db_paths + + # @!method execute + # @param logger [Wordmove::Logger] + # @param cli_options [Hash] Command line options (with symbolized keys) + # @param remote_options [Hash] Remote host options fetched from + # movefile (with symbolized keys) + # @param db_paths [BbPathsConfig] Configuration object for database + # @param photocopier [Photocopier::FTP] + # @!scope class + # @return [LightService::Context] Action's context + executed do |context| # rubocop:disable Metrics/BlockLength + context.logger.task 'Upload and import adapted DB' + + result = Wordmove::Actions::PutFile.execute( + logger: context.logger, + photocopier: context.photocopier, + cli_options: context.cli_options, + command_args: [ + context.db_paths.local.adapted_path, + context.db_paths.remote.path + ] + ) + context.fail_and_return!(result.message) if result.failure? + + result = Wordmove::Actions::PutFile.execute( + logger: context.logger, + photocopier: context.photocopier, + cli_options: context.cli_options, + command_args: [ + context.db_paths.ftp.local.generated_import_script_path, + context.db_paths.ftp.remote.import_script_path + ] + ) + context.fail_and_return!(result.message) if result.failure? + + import_url = [ + context.db_paths.ftp.remote.import_script_url, + '?shared_key=', + context.db_paths.ftp.token, + '&start=1&foffset=0&totalqueries=0&fn=dump.sql' + ].join + + download(url: import_url, local_path: context.db_paths.ftp.local.temp_path) + + if context.cli_options[:debug] + context.logger.debug "Operation log located at: #{context.db_paths.ftp.local.temp_path}" + else + result = Wordmove::Actions::DeleteLocalFile.execute( + cli_options: context.cli_options, + logger: context.logger, + file_path: context.db_paths.ftp.local.temp_path + ) + + if result.failure? + context.logger.warning 'Failed to delete local file ' \ + "#{context.db_paths.ftp.local.temp_path} because: " \ + "#{result.message}" \ + '. Manual intervention required' + end + end + end + end + end + end +end diff --git a/lib/wordmove/actions/ftp/put_directory.rb b/lib/wordmove/actions/ftp/put_directory.rb new file mode 100644 index 00000000..35a34ef0 --- /dev/null +++ b/lib/wordmove/actions/ftp/put_directory.rb @@ -0,0 +1,67 @@ +module Wordmove + module Actions + module Ftp + # Syncs a whole directory over FTP protocol from local host to remote server + class PutDirectory + extend LightService::Action + include Wordmove::Actions::Helpers + include WordpressDirectory::LocalHelperMethods + include WordpressDirectory::RemoteHelperMethods + + # :folder_task is expected to be one symbol from Wordmove::CLI.wordpress_options array + expects :logger, + :local_options, + :remote_options, + :cli_options, + :photocopier, + :folder_task + + # @!method execute + # @param logger [Wordmove::Logger] + # @param local_options [Hash] Local host options fetched from + # movefile (with symbolized keys) + # @param remote_options [Hash] Remote host options fetched from + # movefile (with symbolized keys) + # @param cli_options [Hash] Command line options (with symbolized keys) + # @param photocopier [Photocopier::FTP] + # @param folder_task [Symbol] Symbolazied folder name + # @!scope class + # @return [LightService::Context] Action's context + executed do |context| + context.logger.task "Pushing #{context.folder_task}" + + next context if simulate?(cli_options: context.cli_options) + + command = 'put_directory' + + # This action can generate `command_args` by itself, + # but it gives the context the chance to ovveride it. + # By the way this variable is not `expects`ed. + # Note that we do not use the second argument to `fetch` + # to express a default value, because it would be greedly interpreted + # but if `command_args` is already defined by context, then it's + # possible that `"local_#{context.folder_task}_dir"` could + # not be defined. + command_args = context.fetch(:command_args) || [ + send( + "local_#{context.folder_task}_dir", + local_options: context.local_options + ).path, + send( + "remote_#{context.folder_task}_dir", + remote_options: context.remote_options + ).path, + paths_to_exclude(remote_options: context.remote_options) + ] + + context.logger.task_step false, "#{command}: #{command_args.join(' ')}" + result = context.photocopier.send(command, *command_args) + + next context if result == true + + context.fail!("Failed to push #{context.folder_task}") + end + end + end + end +end diff --git a/lib/wordmove/actions/get_file.rb b/lib/wordmove/actions/get_file.rb new file mode 100644 index 00000000..1a87a22a --- /dev/null +++ b/lib/wordmove/actions/get_file.rb @@ -0,0 +1,38 @@ +module Wordmove + module Actions + # Download a single file from the remote server. + # + # @note The remote server is already configured inside the Photocopier object + # @note This action is *not* meant to be organized, but as a standalone one. + class GetFile + extend LightService::Action + include Wordmove::Actions::Helpers + + expects :photocopier, + :logger, + :cli_options, + :command_args + + # @!method execute + # @param photocopier [Photocopier::SSH|Photocopier::FTP] + # @param logger [Wordmove::Logger] + # @param cli_options [Hash] Command line options (with symbolized keys) + # @param command_args ((String) remote file path, (String) local file path) + # @!scope class + # @return [LightService::Context] Action's context + executed do |context| + command = 'get' + + context.logger.task_step false, "#{command}: #{context.command_args.join(' ')}" + + next context if simulate?(cli_options: context.cli_options) + + result = context.photocopier.send(command, *context.command_args) + + next context if result == true + + context.fail! "Failed to download file: #{context.command_args.first}" + end + end + end +end diff --git a/lib/wordmove/actions/helpers.rb b/lib/wordmove/actions/helpers.rb new file mode 100644 index 00000000..85465628 --- /dev/null +++ b/lib/wordmove/actions/helpers.rb @@ -0,0 +1,142 @@ +module Wordmove + module Actions + # Helpers for +Wordmove::Actions+ + # + # All helpers methos are class methods; this way we force avoiding the use + # of persistence. All actions have to be approached more as functional code + # than OO code. Thus helpers are condidered as functional code too. + module Helpers + extend ActiveSupport::Concern + + # rubocop:disable Metrics/BlockLength + class_methods do + # Determines if we're running a simulated command. Actually this is a + # wrapper around command line arguments set by the user. + # + # @param cli_options [Hash] Command line options hash (deep symbolized). + # Generally you will find this into action's context + # @return [Boolean] + # @!scope class + def simulate?(cli_options:) + cli_options.fetch(:simulate, false) + end + + # Returns the path to be excluded as per movefile.yml configuration. + # `remote_options` is always valid for both push and pull actions, + # because path exclusions are configured only on remote environments + # + # @param remote_options [Hash] The options hash for the selected remote + # remote environment. Generally you will find this into action's context. + # @return [Array] + # @!scope class + def paths_to_exclude(remote_options:) + remote_options.fetch(:exclude, []) + end + + # Given a path, it will append the `/*` string to it. This is how + # folder content - thus not the folder itself - is represented by rsync. + # The name of this method is not explicative nor expressive, but we retain + # it for backward compatibility. + # + # @param path [String] + # @return [String] + # @!scope class + def exclude_dir_contents(path:) + "#{path}/*" + end + + # Construct the mysql dump command as a string + # + # @param env_db_options [Hash] This hash is defined by the user through movefile.yml + # @param save_to_path [String] The path where the db dump will be saved + # @return [String] The full composed mysql command + # @!scope class + def mysql_dump_command(env_db_options:, save_to_path:) + command = ['mysqldump'] + + if env_db_options[:host].present? + command << "--host=#{Shellwords.escape(env_db_options[:host])}" + end + + if env_db_options[:port].present? + command << "--port=#{Shellwords.escape(env_db_options[:port])}" + end + + if env_db_options[:user].present? + command << "--user=#{Shellwords.escape(env_db_options[:user])}" + end + + if env_db_options[:password].present? + command << "--password=#{Shellwords.escape(env_db_options[:password])}" + end + + command << "--result-file=\"#{save_to_path}\"" + + if env_db_options[:mysqldump_options].present? + command << Shellwords.split(env_db_options[:mysqldump_options]) + end + + command << Shellwords.escape(env_db_options[:name]) + + command.join(' ') + end + + # Construct the mysql import command as a string + # + # @param dump_path [String] The path where the dump to import is located + # @param env_db_options [Hash] This hash is defined by the user through movefile.yml + # @return [String] The full composed mysql command + # @!scope class + def mysql_import_command(dump_path:, env_db_options:) + command = ['mysql'] + %i[host port user].each do |option| + if env_db_options[option].present? + command << "--#{option}=#{Shellwords.escape(env_db_options[option])}" + end + end + if env_db_options[:password].present? + command << "--password=#{Shellwords.escape(env_db_options[:password])}" + end + command << "--database=#{Shellwords.escape(env_db_options[:name])}" + if env_db_options[:mysql_options].present? + command << Shellwords.split(env_db_options[:mysql_options]) + end + command << "--execute=\"SET autocommit=0;SOURCE #{dump_path};COMMIT\"" + command.join(' ') + end + + # Construct the command to compress a file as a string. The command will be wrapped + # as argument to the +nice+ command, in order to lower the process priority and do + # not lock the system while compressing large files. + # + # @param file_path [String] The path where the file to be compressed is located + # @return [String] the command + # @!scope class + def compress_command(file_path:) + command = ['nice'] + command << '-n' + command << '0' + command << 'gzip' + command << '-9' + command << '-f' + command << "\"#{file_path}\"" + command.join(' ') + end + + # Construct the command to deflate a compressed file as a string. + # + # @param file_path [String] The path where the file to be deflated is located + # @return [String] the command + # @!scope class + def uncompress_command(file_path:) + command = ['gzip'] + command << '-d' + command << '-f' + command << "\"#{file_path}\"" + command.join(' ') + end + end + # rubocop:enable Metrics/BlockLength + end + end +end diff --git a/lib/wordmove/actions/put_file.rb b/lib/wordmove/actions/put_file.rb new file mode 100644 index 00000000..44d92995 --- /dev/null +++ b/lib/wordmove/actions/put_file.rb @@ -0,0 +1,48 @@ +module Wordmove + module Actions + # Upload a single file to the remote server. + # + # @note The remote server is already configured inside the Photocopier object + # @note This action is *not* meant to be organized, but as a standalone one. + class PutFile + extend LightService::Action + include Wordmove::Actions::Helpers + + expects :photocopier, + :logger, + :command_args, + :cli_options + + # @!method execute + # @param photocopier [Photocopier] + # @param logger [Wordmove::Logger] + # @param command_args ((String) local file path, (String) remote file path) + # @return [LightService::Context] Action's context + executed do |context| + command = 'put' + + # First argument could be a file or a content string. Do not log if the latter + message = if File.exist?(context.command_args.first) + context.command_args.join(' ') + else + context.command_args.second + end + + context.logger.task_step false, "#{command}: #{message}" + + result = if simulate?(cli_options: context.cli_options) + true + else + context.photocopier.send(command, *context.command_args) + end + + next context if result == true + # We can't trust the return from the fotocopier method when using FTP. Keep on + # and have faith. + next context if context.photocopier.is_a? Photocopier::FTP + + context.fail! "Failed to upload file: #{context.command_args.first}" + end + end + end +end diff --git a/lib/wordmove/actions/run_after_pull_hook.rb b/lib/wordmove/actions/run_after_pull_hook.rb new file mode 100644 index 00000000..8b7fe78e --- /dev/null +++ b/lib/wordmove/actions/run_after_pull_hook.rb @@ -0,0 +1,26 @@ +module Wordmove + module Actions + # Runs before push hooks by invoking the external service + # Wordmove::Hook + class RunAfterPullHook + extend ::LightService::Action + include Wordmove::Actions::Helpers + + expects :movefile, + :cli_options + + # @!method execute + # @param movefile [Wordmove::Movefile] + # @param cli_options [Hash] + # @return [LightService::Context] Action's context + executed do |context| + Wordmove::Hook.run( + :pull, + :after, + movefile: context.movefile, + simulate: simulate?(cli_options: context.cli_options) + ) + end + end + end +end diff --git a/lib/wordmove/actions/run_after_push_hook.rb b/lib/wordmove/actions/run_after_push_hook.rb new file mode 100644 index 00000000..39a6f202 --- /dev/null +++ b/lib/wordmove/actions/run_after_push_hook.rb @@ -0,0 +1,26 @@ +module Wordmove + module Actions + # Runs after push hooks by invoking the external service + # Wordmove::Hook + class RunAfterPushHook + extend ::LightService::Action + include Wordmove::Actions::Helpers + + expects :movefile, + :cli_options + + # @!method execute + # @param movefile [Wordmove::Movefile] + # @param cli_options [Hash] + # @return [LightService::Context] Action's context + executed do |context| + Wordmove::Hook.run( + :push, + :after, + movefile: context.movefile, + simulate: simulate?(cli_options: context.cli_options) + ) + end + end + end +end diff --git a/lib/wordmove/actions/run_before_pull_hook.rb b/lib/wordmove/actions/run_before_pull_hook.rb new file mode 100644 index 00000000..c107e332 --- /dev/null +++ b/lib/wordmove/actions/run_before_pull_hook.rb @@ -0,0 +1,26 @@ +module Wordmove + module Actions + # Runs before pull hooks by invoking the external service + # Wordmove::Hook + class RunBeforePullHook + extend ::LightService::Action + include Wordmove::Actions::Helpers + + expects :movefile, + :cli_options + + # @!method execute + # @param movefile [Wordmove::Movefile] + # @param cli_options [Hash] + # @return [LightService::Context] Action's context + executed do |context| + Wordmove::Hook.run( + :pull, + :before, + movefile: context.movefile, + simulate: simulate?(cli_options: context.cli_options) + ) + end + end + end +end diff --git a/lib/wordmove/actions/run_before_push_hook.rb b/lib/wordmove/actions/run_before_push_hook.rb new file mode 100644 index 00000000..78e6c53b --- /dev/null +++ b/lib/wordmove/actions/run_before_push_hook.rb @@ -0,0 +1,26 @@ +module Wordmove + module Actions + # Runs before push hooks by invoking the external service + # Wordmove::Hook + class RunBeforePushHook + extend ::LightService::Action + include Wordmove::Actions::Helpers + + expects :movefile, + :cli_options + + # @!method execute + # @param movefile [Wordmove::Movefile] + # @param cli_options [Hash] + # @return [LightService::Context] Action's context + executed do |context| + Wordmove::Hook.run( + :push, + :before, + movefile: context.movefile, + simulate: simulate?(cli_options: context.cli_options) + ) + end + end + end +end diff --git a/lib/wordmove/actions/run_local_command.rb b/lib/wordmove/actions/run_local_command.rb new file mode 100644 index 00000000..82308ef0 --- /dev/null +++ b/lib/wordmove/actions/run_local_command.rb @@ -0,0 +1,34 @@ +require 'English' + +module Wordmove + module Actions + # Run a command on the local system. + # Command won't be run if +--simulate+ flag is present on CLI. + # @note This action is *not* meant to be organized, but as a standalone one. + class RunLocalCommand + extend LightService::Action + include Wordmove::Actions::Helpers + + expects :command, + :cli_options, + :logger + + # @!method execute + # @param command [String] The command to run + # @param cli_options [Hash] + # @param logger [Wordmove::Logger] + # @return [LightService::Context] Action's context + executed do |context| + context.logger.task_step true, context.command + + next context if simulate?(cli_options: context.cli_options) + + begin + system(context.command, exception: true) + rescue RuntimeError, SystemExit => e + context.fail!("Local command status reports an error: #{e.message}") + end + end + end + end +end diff --git a/lib/wordmove/actions/setup_context_for_db.rb b/lib/wordmove/actions/setup_context_for_db.rb new file mode 100644 index 00000000..7fa9ebb7 --- /dev/null +++ b/lib/wordmove/actions/setup_context_for_db.rb @@ -0,0 +1,69 @@ +module Wordmove + module Actions + class SetupContextForDb + extend ::LightService::Action + include Wordmove::Actions::Helpers + include Wordmove::Actions::Ftp::Helpers + include WordpressDirectory::LocalHelperMethods + include WordpressDirectory::RemoteHelperMethods + + expects :cli_options, + :local_options, + :remote_options, + :logger, + :movefile, + :database_task + promises :db_paths + + executed do |context| # rubocop:disable Metrics/BlockLength + content_dir = local_wp_content_dir(local_options: context.local_options) + + token = remote_php_scripts_token + + DbPathsConfig.local.path = content_dir.path('dump.sql') + DbPathsConfig.local.gzipped_path = "#{DbPathsConfig.local.path}.gz" + DbPathsConfig.remote.path = remote_wp_content_dir( + remote_options: context.remote_options + ).path('dump.sql') + DbPathsConfig.remote.gzipped_path = "#{DbPathsConfig.remote.path}.gz" + DbPathsConfig.local.adapted_path = content_dir.path('search_replace_dump.sql') + DbPathsConfig.local.gzipped_adapted_path = "#{DbPathsConfig.local.adapted_path}.gz" + DbPathsConfig.backup.local.path = content_dir.path("local-backup-#{Time.now.to_i}.sql") + DbPathsConfig.backup.local.gzipped_path = "#{DbPathsConfig.backup.local.path}.gz" + DbPathsConfig.backup.remote.path = + content_dir.path("#{context.movefile.environment}-backup-#{Time.now.to_i}.sql") + DbPathsConfig.backup.remote.gzipped_path = "#{DbPathsConfig.backup.remote.path}.gz" + + DbPathsConfig.ftp.remote.dump_script_path = remote_wp_content_dir( + remote_options: context.remote_options + ).path('dump.php') + DbPathsConfig.ftp.remote.dumped_path = remote_wp_content_dir( + remote_options: context.remote_options + ).path('dump.mysql') + DbPathsConfig.ftp.remote.dump_script_url = remote_wp_content_dir( + remote_options: context.remote_options + ).url('dump.php') + DbPathsConfig.ftp.remote.import_script_path = remote_wp_content_dir( + remote_options: context.remote_options + ).path('import.php') + DbPathsConfig.ftp.remote.import_script_url = remote_wp_content_dir( + remote_options: context.remote_options + ).url('import.php') + DbPathsConfig.ftp.local.generated_dump_script_path = generate_dump_script( + remote_db_options: context.remote_options[:database], token: + ) + DbPathsConfig.ftp.local.generated_import_script_path = generate_import_script( + remote_db_options: context.remote_options[:database], token: + ) + DbPathsConfig.ftp.local.temp_path = local_wp_content_dir( + local_options: context.local_options + ).path('log.html') + # I know this is not a path, but it's used to generate + # a URL to dump the DB, so it's somewhat in context + DbPathsConfig.ftp.token = token + + context.db_paths = DbPathsConfig + end + end + end +end diff --git a/lib/wordmove/actions/ssh/backup_remote_db.rb b/lib/wordmove/actions/ssh/backup_remote_db.rb new file mode 100644 index 00000000..1836dae5 --- /dev/null +++ b/lib/wordmove/actions/ssh/backup_remote_db.rb @@ -0,0 +1,49 @@ +module Wordmove + module Actions + module Ssh + # Bakups an alrady downloaded remote dump + class BackupRemoteDb + extend ::LightService::Action + include Wordmove::Actions::Helpers + + expects :cli_options, + :logger, + :db_paths + + # @!method execute + # @param cli_options [Hash] Command line options (with symbolized keys) + # @param logger [Wordmove::Logger] + # @param db_paths [BbPathsConfig] Configuration object for database + # @!scope class + # @return [LightService::Context] Action's context + executed do |context| + context.logger.task 'Backup remote DB' + + if simulate?(cli_options: context.cli_options) + context.logger.info 'A backup of the remote DB would have been saved into ' \ + "#{context.db_paths.backup.remote.gzipped_path}, " \ + 'but you\'re simulating' + next context + end + + # Most of the expectations are needed to be proxied to `DownloadRemoteDb` + # Wordmove::Actions::Ssh::DownloadRemoteDb.execute(context) + # DownloadRemoteDB will save the file in `db_paths.local.gzipped_path` + + begin + FileUtils.mv( + context.db_paths.local.gzipped_path, + context.db_paths.backup.remote.gzipped_path + ) + + context.logger.success( + "Backup saved at #{context.db_paths.backup.remote.gzipped_path}" + ) + rescue Errno::ENOENT => e + context.fail_and_return!("Remote DB backup failed with: #{e.message}. Aborting.") + end + end + end + end + end +end diff --git a/lib/wordmove/actions/ssh/cleanup_after_adapt.rb b/lib/wordmove/actions/ssh/cleanup_after_adapt.rb new file mode 100644 index 00000000..c363b66e --- /dev/null +++ b/lib/wordmove/actions/ssh/cleanup_after_adapt.rb @@ -0,0 +1,54 @@ +module Wordmove + module Actions + module Ssh + # Cleanup file created during DB push/pull operations + class CleanupAfterAdapt + extend ::LightService::Action + include Wordmove::Actions::Helpers + + expects :db_paths, + :cli_options, + :logger + + # @!method execute + # @param db_paths [BbPathsConfig] Configuration object for database + # @param cli_options [Hash] Command line options (with symbolized keys) + # @param logger [Wordmove::Logger] + # @!scope class + # @return [LightService::Context] Action's context + executed do |context| # rubocop:disable Metrics/BlockLength + context.logger.task 'Cleanup' + + if simulate?(cli_options: context.cli_options) + context.logger.info 'No cleanup during simulation' + next context + end + + result = Wordmove::Actions::DeleteLocalFile.execute( + logger: context.logger, + cli_options: context.cli_options, + file_path: context.db_paths.local.path + ) + if result.failure? + context.logger.warning 'Failed to delete local file ' \ + "#{context.db_paths.local.path} because: " \ + "#{result.message}" \ + '. Manual intervention required' + end + + result = Wordmove::Actions::DeleteLocalFile.execute( + cli_options: context.cli_options, + logger: context.logger, + file_path: context.db_paths.local.gzipped_adapted_path + ) + if result.failure? + context.logger.warning 'Failed to delete local file ' \ + "#{context.db_paths.local.gzipped_adapted_path} because: " \ + "#{result.message}" \ + '. Manual intervention required' + end + end + end + end + end +end diff --git a/lib/wordmove/actions/ssh/download_remote_db.rb b/lib/wordmove/actions/ssh/download_remote_db.rb new file mode 100644 index 00000000..f9784885 --- /dev/null +++ b/lib/wordmove/actions/ssh/download_remote_db.rb @@ -0,0 +1,81 @@ +module Wordmove + module Actions + module Ssh + # Downloads the remote DB over SSH protocol + class DownloadRemoteDb + extend ::LightService::Action + include Wordmove::Actions::Helpers + include WordpressDirectory::LocalHelperMethods + include WordpressDirectory::RemoteHelperMethods + + expects :remote_options, + :cli_options, + :logger, + :photocopier, + :db_paths + + # @!method execute + # @param remote_options [Hash] Remote host options fetched from + # movefile (with symbolized keys) + # @param cli_options [Hash] Command line options (with symbolized keys) + # @param logger [Wordmove::Logger] + # @param photocopier [Photocopier::SSH] + # @param db_paths [BbPathsConfig] Configuration object for database + # @!scope class + # @return [LightService::Context] Action's context + executed do |context| # rubocop:disable Metrics/BlockLength + context.logger.task 'Download remote DB' + + next context if simulate?(cli_options: context.cli_options) + + result = Wordmove::Actions::Ssh::RunRemoteCommand.execute( + cli_options: context.cli_options, + photocopier: context.photocopier, + logger: context.logger, + command: mysql_dump_command( + env_db_options: context.remote_options[:database], + save_to_path: context.db_paths.remote.path + ) + ) + context.fail_and_return!(result.message) if result.failure? + + result = Wordmove::Actions::Ssh::RunRemoteCommand.execute( + cli_options: context.cli_options, + photocopier: context.photocopier, + logger: context.logger, + command: compress_command(file_path: context.db_paths.remote.path) + ) + context.fail_and_return!(result.message) if result.failure? + + result = Wordmove::Actions::GetFile.execute( + photocopier: context.photocopier, + logger: context.logger, + cli_options: context.cli_options, + command_args: [ + context.db_paths.remote.gzipped_path, + context.db_paths.local.gzipped_path + ] + ) + context.fail_and_return!(result.message) if result.failure? + + result = Wordmove::Actions::DeleteRemoteFile.execute( + photocopier: context.photocopier, + logger: context.logger, + cli_options: context.cli_options, + remote_file: context.db_paths.remote.gzipped_path + ) + if result.failure? + context.logger.warning 'Failed to delete remote file ' \ + "#{context.db_paths.remote.gzipped_path} because: " \ + "#{result.message}" \ + '. Manual intervention required' + end + + context.logger.success( + "Remote DB dump downloaded in #{context.db_paths.local.gzipped_path}" + ) + end + end + end + end +end diff --git a/lib/wordmove/actions/ssh/get_directory.rb b/lib/wordmove/actions/ssh/get_directory.rb new file mode 100644 index 00000000..9c876d73 --- /dev/null +++ b/lib/wordmove/actions/ssh/get_directory.rb @@ -0,0 +1,76 @@ +module Wordmove + module Actions + module Ssh + # Syncs a whole directory over SSH protocol from the remote server to local host + class GetDirectory + extend LightService::Action + include Wordmove::Actions::Helpers + include Wordmove::Actions::Ssh::Helpers + include WordpressDirectory::RemoteHelperMethods + + # :folder_task is expected to be one symbol from Wordmove::CLI.wordpress_options array + expects :logger, + :local_options, + :remote_options, + :cli_options, + :photocopier, + :folder_task + + # @!method execute + # @param logger [Wordmove::Logger] + # @param local_options [Hash] Local host options fetched from + # movefile (with symbolized keys) + # @param remote_options [Hash] Remote host options fetched from + # movefile (with symbolized keys) + # @param cli_options [Hash] Command line options (with symbolized keys) + # @param photocopier [Photocopier::SSH] + # @param folder_task [Symbol] Symbolazied folder name + # @!scope class + # @return [LightService::Context] Action's context + executed do |context| + context.logger.task "Pulling #{context.folder_task}" + + next context if simulate?(cli_options: context.cli_options) + + command = 'get_directory' + # For this action `local_path` and `remote_path` will always be + # `:wordpress_path`; specific folder for `context.folder_task` will be included by + # `pull_include_paths` + local_path = context.local_options[:wordpress_path] + remote_path = context.remote_options[:wordpress_path] + + # This action can generate `command_args` by itself, + # but it gives the context the chance to ovveride it. + # By the way this variable is not `expects`ed. + # Note that we do not use the second argument to `fetch` + # to express a default value, because it would be greedly interpreted + # but if `command_args` is already defined by context, then it's + # possible that `"remote_#{context.folder_task}_dir"` could + # not be defined. + command_args = context.fetch(:command_args) || [ + remote_path, + local_path, + pull_exclude_paths( + remote_task_dir: send( + "remote_#{context.folder_task}_dir", + remote_options: context.remote_options + ), + paths_to_exclude: paths_to_exclude(remote_options: context.remote_options) + ), + pull_include_paths(remote_task_dir: send( + "remote_#{context.folder_task}_dir", + remote_options: context.remote_options + )) + ] + + context.logger.task_step false, "#{command}: #{command_args.join(' ')}" + result = context.photocopier.send(command, *command_args) + + next context if result == true + + context.fail!("Failed to push #{context.folder_task}") + end + end + end + end +end diff --git a/lib/wordmove/actions/ssh/helpers.rb b/lib/wordmove/actions/ssh/helpers.rb new file mode 100644 index 00000000..4af83ba6 --- /dev/null +++ b/lib/wordmove/actions/ssh/helpers.rb @@ -0,0 +1,128 @@ +require 'pathname' + +# rubocop:disable Metrics/BlockLength +module Wordmove + module Actions + module Ssh + module Helpers + extend ActiveSupport::Concern + + class_methods do + # + # Utility method to retrieve and augment ssh options from the superset of remote options. + # This is useful most because it appends +--dy-run+ rsync's flag to ssh options based + # on +--simulate+ flag presence + # + # @param [Hash] remote_options Remote host options fetcehd from movefile + # @param [Bool] simulate Tell the moethod if you're in a simulated operation + # + # @return [Hash] Ssh options + # + def ssh_options(remote_options:, simulate: false) + ssh_options = remote_options[:ssh] + + if simulate == true && ssh_options[:rsync_options] + ssh_options[:rsync_options].concat(' --dry-run') + elsif simulate == true + ssh_options[:rsync_options] = '--dry-run' + end + + ssh_options + end + + # + # Given the directory you're pushing/pulling, generates an array of path to be included + # by rsync while pushing. Note that by design include paths are always required but are + # only programmatically deduced and never user configured. + # + # @note The business logic behind how these paths are produced should be deepened + # + # @param [WordpressDirectory] local_task_dir An object representing a wordpress folder + # + # @return [Array] The array of path to be included by rsync + # + def push_include_paths(local_task_dir:) + Pathname.new(local_task_dir.relative_path) + .ascend + .each_with_object([]) do |directory, array| + path = directory.to_path + path.prepend('/') unless path.match? %r{^/} + path.concat('/') unless path.match? %r{/$} + array << path + end + end + + # + # Given the directory you're pushing/pulling and the user configured exclude list, + # generates an array of path to be excluded + # by rsync while pushing. Note that by design exclude some paths are always required + # even when the user does not confiure any exclusion. + # + # @note The business logic behind how these paths are produced should be deepened + # + # @param [WordpressDirectory] local_task_dir An object representing a wordpress folder + # @param [Array] pats_to_exclude An array of paths + # + # @return [Array] The array of path to be included by rsync + # + def push_exclude_paths(local_task_dir:, paths_to_exclude:) + Pathname.new(local_task_dir.relative_path) + .dirname + .ascend + .each_with_object([]) do |directory, array| + path = directory.to_path + path.prepend('/') unless path.match? %r{^/} + path.concat('/') unless path.match? %r{/$} + path.concat('*') + array << path + end + .concat(paths_to_exclude) + .concat(['/*']) + end + + # + # Same as Wordmove::Actions::Ssh::Helpers.push_include_path but for pull actions + # + # @param [WordpressDirectory] local_task_dir An object representing a wordpress folder + # + # @return [Array] An array of paths + # + def pull_include_paths(remote_task_dir:) + Pathname.new(remote_task_dir.relative_path) + .ascend + .each_with_object([]) do |directory, array| + path = directory.to_path + path.prepend('/') unless path.match? %r{^/} + path.concat('/') unless path.match? %r{/$} + array << path + end + end + + # + # Same as Wordmove::Actions::Ssh::Helpers.push_exclude_path but for pull actions + # + # @param [WordpressDirectory] local_task_dir An object representing a wordpress folder + # @param [Array] paths_to_exclude User configured array of paths to exclude + # + # @return [Array] Array of paths to be excluded + # + def pull_exclude_paths(remote_task_dir:, paths_to_exclude:) + Pathname.new(remote_task_dir.relative_path) + .dirname + .ascend + .each_with_object([]) do |directory, array| + path = directory.to_path + path.prepend('/') unless path.match? %r{^/} + path.concat('/') unless path.match? %r{/$} + path.concat('*') + array << path + end + .concat(paths_to_exclude) + .concat(['/*']) + end + end + end + end + end +end +# rubocop:enable Metrics/BlockLength diff --git a/lib/wordmove/actions/ssh/pull_wordpress.rb b/lib/wordmove/actions/ssh/pull_wordpress.rb new file mode 100644 index 00000000..eb023c5d --- /dev/null +++ b/lib/wordmove/actions/ssh/pull_wordpress.rb @@ -0,0 +1,56 @@ +module Wordmove + module Actions + module Ssh + # Syncs wordpress folder (usually root folder), exluding +wp-content/+ folder, over SSH + # protocol from the remote server to local host + class PullWordpress + extend ::LightService::Action + include Wordmove::Actions::Helpers + include WordpressDirectory::RemoteHelperMethods + + expects :remote_options, + :local_options, + :logger, + :photocopier + + # @!method execute + # @param remote_options [Hash] Remote host options fetched from + # movefile (with symbolized keys) + # @param local_options [Hash] Local host options fetched from + # movefile (with symbolized keys) + # @param logger [Wordmove::Logger] + # @param photocopier [Photocopier::SSH] + # @!scope class + # @return [LightService::Context] Action's context + executed do |context| + local_path = context.local_options[:wordpress_path] + + remote_path = context.remote_options[:wordpress_path] + + wp_content_relative_path = remote_wp_content_dir( + remote_options: context.remote_options + ).relative_path + + exclude_wp_content = exclude_dir_contents( + path: wp_content_relative_path + ) + + exclude_paths = paths_to_exclude( + remote_options: context.remote_options + ).push(exclude_wp_content) + + result = Wordmove::Actions::Ssh::GetDirectory.execute( + photocopier: context.photocopier, + logger: context.logger, + command_args: [remote_path, local_path, exclude_paths], + folder_task: :wordpress, + local_options: context.local_options, + remote_options: context.remote_options, + cli_options: context.cli_options + ) + context.fail!(result.message) if result.failure? + end + end + end + end +end diff --git a/lib/wordmove/actions/ssh/push_wordpress.rb b/lib/wordmove/actions/ssh/push_wordpress.rb new file mode 100644 index 00000000..07bc0102 --- /dev/null +++ b/lib/wordmove/actions/ssh/push_wordpress.rb @@ -0,0 +1,54 @@ +module Wordmove + module Actions + module Ssh + # Syncs wordpress folder (usually root folder), exluding +wp-content/+ folder, over SSH + # protocol from local host to the remote server + class PushWordpress + extend ::LightService::Action + include Wordmove::Actions::Helpers + include WordpressDirectory::LocalHelperMethods + + expects :remote_options, + :local_options, + :logger, + :photocopier + + # @!method execute + # @param remote_options [Hash] Remote host options fetched from + # movefile (with symbolized keys) + # @param local_options [Hash] Local host options fetched from + # movefile (with symbolized keys) + # @param logger [Wordmove::Logger] + # @param photocopier [Photocopier::SSH] + # @!scope class + # @return [LightService::Context] Action's context + executed do |context| + local_path = context.local_options[:wordpress_path] + + remote_path = context.remote_options[:wordpress_path] + + wp_content_relative_path = local_wp_content_dir( + local_options: context.local_options + ).relative_path + + exclude_wp_content = exclude_dir_contents(path: wp_content_relative_path) + + exclude_paths = paths_to_exclude( + remote_options: context.remote_options + ).push(exclude_wp_content) + + result = Wordmove::Actions::Ssh::PutDirectory.execute( + photocopier: context.photocopier, + logger: context.logger, + command_args: [local_path, remote_path, exclude_paths], + folder_task: :wordpress, + local_options: context.local_options, + remote_options: context.remote_options, + cli_options: context.cli_options + ) + context.fail!(result.message) if result.failure? + end + end + end + end +end diff --git a/lib/wordmove/actions/ssh/put_and_import_dump_remotely.rb b/lib/wordmove/actions/ssh/put_and_import_dump_remotely.rb new file mode 100644 index 00000000..a29d74e5 --- /dev/null +++ b/lib/wordmove/actions/ssh/put_and_import_dump_remotely.rb @@ -0,0 +1,75 @@ +module Wordmove + module Actions + module Ssh + # Uploads a DB dump to remote host and import it in the remote database over SSH protocol + class PutAndImportDumpRemotely + extend ::LightService::Action + include Wordmove::Actions::Helpers + include WordpressDirectory::RemoteHelperMethods + include WordpressDirectory::LocalHelperMethods + + expects :remote_options, + :cli_options, + :logger, + :photocopier, + :db_paths + + # @!method execute + # @param remote_options [Hash] Remote host options fetched from + # movefile (with symbolized keys) + # @param cli_options [Hash] Command line options (with symbolized keys) + # @param logger [Wordmove::Logger] + # @param photocopier [Photocopier::SSH] + # @param db_paths [BbPathsConfig] Configuration object for database + # @!scope class + # @return [LightService::Context] Action's context + executed do |context| # rubocop:disable Metrics/BlockLength + context.logger.task 'Upload and import adapted DB' + + result = Wordmove::Actions::PutFile.execute( + logger: context.logger, + photocopier: context.photocopier, + cli_options: context.cli_options, + command_args: [ + context.db_paths.local.gzipped_adapted_path, + context.db_paths.remote.gzipped_path + ] + ) + context.fail_and_return!(result.message) if result.failure? + + result = Wordmove::Actions::Ssh::RunRemoteCommand.execute( + cli_options: context.cli_options, + logger: context.logger, + photocopier: context.photocopier, + command: uncompress_command(file_path: context.db_paths.remote.gzipped_path) + ) + context.fail_and_return!(result.message) if result.failure? + + result = Wordmove::Actions::Ssh::RunRemoteCommand.execute( + cli_options: context.cli_options, + logger: context.logger, + photocopier: context.photocopier, + command: mysql_import_command( + dump_path: context.db_paths.remote.path, + env_db_options: context.remote_options[:database] + ) + ) + context.fail_and_return!(result.message) if result.failure? + + result = Wordmove::Actions::DeleteRemoteFile.execute( + photocopier: context.photocopier, + logger: context.logger, + cli_options: context.cli_options, + remote_file: context.db_paths.remote.path + ) + if result.failure? + context.logger.warning 'Failed to delete remote file ' \ + "#{context.db_paths.remote.path} because: " \ + "#{result.message}" \ + '. Manual intervention required' + end + end + end + end + end +end diff --git a/lib/wordmove/actions/ssh/put_directory.rb b/lib/wordmove/actions/ssh/put_directory.rb new file mode 100644 index 00000000..baa78265 --- /dev/null +++ b/lib/wordmove/actions/ssh/put_directory.rb @@ -0,0 +1,76 @@ +module Wordmove + module Actions + module Ssh + # Syncs a whole directory over SSH protocol from local host to remote server + class PutDirectory + extend LightService::Action + include Wordmove::Actions::Helpers + include Wordmove::Actions::Ssh::Helpers + include WordpressDirectory::LocalHelperMethods + + # :folder_task is expected to be one symbol from Wordmove::CLI.wordpress_options array + expects :logger, + :local_options, + :remote_options, + :cli_options, + :photocopier, + :folder_task + + # @!method execute + # @param logger [Wordmove::Logger] + # @param local_options [Hash] Local host options fetched from + # movefile (with symbolized keys) + # @param remote_options [Hash] Remote host options fetched from + # movefile (with symbolized keys) + # @param cli_options [Hash] Command line options (with symbolized keys) + # @param photocopier [Photocopier::SSH] + # @param folder_task [Symbol] Symbolazied folder name + # @!scope class + # @return [LightService::Context] Action's context + executed do |context| + context.logger.task "Pushing #{context.folder_task}" + + next context if simulate?(cli_options: context.cli_options) + + command = 'put_directory' + # For this action `local_path` and `remote_path` will always be + # `:wordpress_path`; specific folder for `context.folder_task` will be included by + # `push_include_paths` + local_path = context.local_options[:wordpress_path] + remote_path = context.remote_options[:wordpress_path] + + # This action can generate `command_args` by itself, + # but it gives the context the chance to ovveride it. + # By the way this variable is not `expects`ed. + # Note that we do not use the second argument to `fetch` + # to express a default value, because it would be greedly interpreted + # but if `command_args` is already defined by context, then it's + # possible that `"local_#{context.folder_task}_dir"` could + # not be defined. + command_args = context.fetch(:command_args) || [ + local_path, + remote_path, + push_exclude_paths( + local_task_dir: send( + "local_#{context.folder_task}_dir", + local_options: context.local_options + ), + paths_to_exclude: paths_to_exclude(remote_options: context.remote_options) + ), + push_include_paths(local_task_dir: send( + "local_#{context.folder_task}_dir", + local_options: context.local_options + )) + ] + + context.logger.task_step false, "#{command}: #{command_args.join(' ')}" + result = context.photocopier.send(command, *command_args) + + next context if result == true + + context.fail!("Failed to push #{context.folder_task}") + end + end + end + end +end diff --git a/lib/wordmove/actions/ssh/run_remote_command.rb b/lib/wordmove/actions/ssh/run_remote_command.rb new file mode 100644 index 00000000..94bc8fda --- /dev/null +++ b/lib/wordmove/actions/ssh/run_remote_command.rb @@ -0,0 +1,39 @@ +module Wordmove + module Actions + module Ssh + # Run a command on a remote host using Photocopier + # + # @note The remote server is already configured inside the Photocopier object + # @note This action is *not* meant to be organized, but as a standalone one. + class RunRemoteCommand + extend LightService::Action + include Wordmove::Actions::Helpers + + expects :photocopier, + :logger, + :cli_options, + :command + + # @!method execute + # @param photocopier [Photocopier] + # @param logger [Wordmove::Logger] + # @param cli_options [Hash] The hash of command line options + # @param command [String] the command to run + # @!scope class + # @return [LightService::Context] Action's context + executed do |context| + context.logger.task_step false, context.command + + next context if simulate?(cli_options: context.cli_options) + + _stdout, stderr, exit_code = context.photocopier.exec!(context.command) + + next context if exit_code.zero? + + context.fail! "Error code #{exit_code} returned by command "\ + "#{context.command}: #{stderr}" + end + end + end + end +end diff --git a/lib/wordmove/assets/dump.php.erb b/lib/wordmove/assets/dump.php.erb index e4909e40..6a7e1322 100644 --- a/lib/wordmove/assets/dump.php.erb +++ b/lib/wordmove/assets/dump.php.erb @@ -1,6 +1,6 @@ '; +$shared_key = '<%= token %>'; if ($_GET['shared_key'] != $shared_key) { die(); } @@ -214,14 +214,14 @@ class MySQLDump } -$db_host = '<%= escape_php db[:host] %>'; -$db_port = '<%= db[:port] %>'; +$db_host = '<%= escape_php(string: remote_db_options[:host]) %>'; +$db_port = '<%= remote_db_options[:port] %>'; if (!$db_port) { $db_port = ini_get("mysqli.default_port"); } -$db_user = '<%= escape_php db[:user] %>'; -$db_password = '<%= escape_php db[:password] %>'; -$db_name = '<%= escape_php db[:name] %>'; +$db_user = '<%= escape_php(string: remote_db_options[:user]) %>'; +$db_password = '<%= escape_php(string: remote_db_options[:password]) %>'; +$db_name = '<%= escape_php(string: remote_db_options[:name]) %>'; $connection = new mysqli($db_host, $db_user, $db_password, $db_name, $db_port); $dump = new MySQLDump($connection); diff --git a/lib/wordmove/assets/import.php.erb b/lib/wordmove/assets/import.php.erb index 334fdbb8..84341810 100644 --- a/lib/wordmove/assets/import.php.erb +++ b/lib/wordmove/assets/import.php.erb @@ -1,6 +1,6 @@ '; +$shared_key = '<%= token %>'; if ($_GET['shared_key'] != $shared_key) { die(); } @@ -43,20 +43,20 @@ error_reporting(E_ALL); // Database configuration -$db_port = '<%= escape_php db[:port] %>'; +$db_port = '<%= escape_php(string: remote_db_options[:port]) %>'; if (!$db_port) { $db_port = ini_get("mysqli.default_port"); } -$db_server = '<%= escape_php db[:host] %>'; -$db_username = '<%= escape_php db[:user] %>'; -$db_password = '<%= escape_php db[:password] %>'; -$db_name = '<%= escape_php db[:name] %>'; +$db_server = '<%= escape_php(string: remote_db_options[:host]) %>'; +$db_username = '<%= escape_php(string: remote_db_options[:user]) %>'; +$db_password = '<%= escape_php(string: remote_db_options[:password]) %>'; +$db_name = '<%= escape_php(string: remote_db_options[:name]) %>'; // Connection charset should be the same as the dump file charset (utf8, latin1, cp1251, koi8r etc.) // See http://dev.mysql.com/doc/refman/5.0/en/charset-charsets.html for the full list // Change this if you have problems with non-latin letters -$db_connection_charset = '<%= db[:charset] || 'utf8' %>'; +$db_connection_charset = '<%= remote_db_options[:charset] || 'utf8' %>'; // OPTIONAL SETTINGS diff --git a/lib/wordmove/assets/wordmove_schema_global.yml b/lib/wordmove/assets/wordmove_schema_global.yml index 21207886..7b66a837 100644 --- a/lib/wordmove/assets/wordmove_schema_global.yml +++ b/lib/wordmove/assets/wordmove_schema_global.yml @@ -1,4 +1,6 @@ type: map mapping: sql_adapter: + type: str required: true + pattern: /\Awpcli\Z/ diff --git a/lib/wordmove/assets/wordmove_schema_local.yml b/lib/wordmove/assets/wordmove_schema_local.yml index e2cbe6c5..c8e0d55a 100644 --- a/lib/wordmove/assets/wordmove_schema_local.yml +++ b/lib/wordmove/assets/wordmove_schema_local.yml @@ -3,6 +3,7 @@ mapping: vhost: pattern: /^https?:\/\// wordpress_path: + required: true database: type: map required: true @@ -15,9 +16,6 @@ mapping: required: true host: required: true - mysqldump_options: - port: - charset: paths: type: map mapping: diff --git a/lib/wordmove/cli.rb b/lib/wordmove/cli.rb index 3dc1b59d..531e667c 100644 --- a/lib/wordmove/cli.rb +++ b/lib/wordmove/cli.rb @@ -1,122 +1,176 @@ module Wordmove - class CLI < Thor - map %w[--version -v] => :__print_version + module CLI + module PullPushShared + extend ActiveSupport::Concern + WORDPRESS_OPTIONS = %i[wordpress uploads themes plugins mu_plugins languages db].freeze + + included do # rubocop:disable Metrics/BlockLength + option :wordpress, type: :boolean, aliases: %w[w] + option :uploads, type: :boolean, aliases: %w[u] + option :themes, type: :boolean, aliases: %w[t] + option :plugins, type: :boolean, aliases: %w[p] + option :mu_plugins, type: :boolean, aliases: %w[m] + option :languages, type: :boolean, aliases: %w[l] + option :db, type: :boolean, aliases: %w[d] + option :simulate, type: :boolean + option :environment, aliases: %w[e] + option :config, aliases: %w[c] + option :no_adapt, type: :boolean + option :all, type: :boolean + # option :verbose, type: :boolean, aliases: %w[v] + option :debug, type: :boolean + + private + + def ensure_wordpress_options_presence!(cli_options) + return if ( + cli_options.deep_symbolize_keys.keys & + (Wordmove::CLI::PullPushShared::WORDPRESS_OPTIONS + [:all]) + ).present? + + puts 'No options given. See wordmove --help' + exit 1 + end - desc "--version, -v", "Print the version" - def __print_version - puts Wordmove::VERSION - end + def movefile_from(cli_options) + ensure_wordpress_options_presence!(cli_options) + Wordmove::Movefile.new(cli_options, nil, true) + rescue MovefileNotFound => e + Logger.new($stdout).error(e.message) + exit 1 + end - desc "init", "Generates a brand new movefile.yml" - def init - Wordmove::Generators::Movefile.start - end + def call_organizer_with(klass:, movefile:, cli_options:) + result = klass.call(cli_options, movefile) - desc "doctor", "Do some local configuration and environment checks" - def doctor - Wordmove::Doctor.start - end + exit 0 if result.success? - shared_options = { - wordpress: { aliases: "-w", type: :boolean }, - uploads: { aliases: "-u", type: :boolean }, - themes: { aliases: "-t", type: :boolean }, - plugins: { aliases: "-p", type: :boolean }, - mu_plugins: { aliases: "-m", type: :boolean }, - languages: { aliases: "-l", type: :boolean }, - db: { aliases: "-d", type: :boolean }, - verbose: { aliases: "-v", type: :boolean }, - simulate: { aliases: "-s", type: :boolean }, - environment: { aliases: "-e" }, - config: { aliases: "-c" }, - debug: { type: :boolean }, - no_adapt: { type: :boolean }, - all: { type: :boolean } - } - - no_tasks do - def handle_options(options) - wordpress_options.each do |task| - yield task if options[task] || (options["all"] && options[task] != false) + Logger.new($stdout).error(result.message) + exit 1 end end + end - def wordpress_options - %w[wordpress uploads themes plugins mu_plugins languages db] - end + module Commands + extend Dry::CLI::Registry - def ensure_wordpress_options_presence!(options) - return if (options.keys & (wordpress_options + ["all"])).present? + class Version < Dry::CLI::Command + desc 'Print the version' - puts "No options given. See wordmove --help" - exit 1 + def call(*) + puts Wordmove::VERSION + end end - def logger - Logger.new(STDOUT).tap { |l| l.level = Logger::DEBUG } + class Init < Dry::CLI::Command + desc 'Generates a brand new movefile.yml' + + def call(*) + Wordmove::Generators::Movefile.generate + end end - end - desc "list", "List all environments and vhosts" - shared_options.each do |option, args| - method_option option, args - end - def list - Wordmove::EnvironmentsList.print(options) - rescue Wordmove::MovefileNotFound => e - logger.error(e.message) - exit 1 - rescue Psych::SyntaxError => e - logger.error("Your movefile is not parsable due to a syntax error: #{e.message}") - exit 1 - end + class Doctor < Dry::CLI::Command + desc 'Do some local configuration and environment checks' - desc "pull", "Pulls WP data from remote host to the local machine" - shared_options.each do |option, args| - method_option option, args - end - def pull - ensure_wordpress_options_presence!(options) - begin - deployer = Wordmove::Deployer::Base.deployer_for(options.deep_symbolize_keys) - rescue MovefileNotFound => e - logger.error(e.message) - exit 1 + def call(*) + Wordmove::Doctor.start + end end - Wordmove::Hook.run(:pull, :before, options) + class List < Dry::CLI::Command + desc 'List all environments and vhosts' - guardian = Wordmove::Guardian.new(options: options, action: :pull) + option :config, aliases: %w[c] - handle_options(options) do |task| - deployer.send("pull_#{task}") if guardian.allows(task.to_sym) + def call(**cli_options) + Wordmove::EnvironmentsList.print(cli_options) + rescue Wordmove::MovefileNotFound => e + Logger.new($stdout).error(e.message) + exit 1 + rescue Psych::SyntaxError => e + Logger.new($stdout) + .error("Your movefile is not parsable due to a syntax error: #{e.message}") + exit 1 + end end - Wordmove::Hook.run(:pull, :after, options) - end + class Pull < Dry::CLI::Command + desc 'Pulls WP data from remote host to the local machine' - desc "push", "Pushes WP data from local machine to remote host" - shared_options.each do |option, args| - method_option option, args - end - def push - ensure_wordpress_options_presence!(options) - begin - deployer = Wordmove::Deployer::Base.deployer_for(options.deep_symbolize_keys) - rescue MovefileNotFound => e - logger.error(e.message) - exit 1 + include Wordmove::CLI::PullPushShared + + def call(**cli_options) + call_pull_organizer_with(**cli_options) + end + + private + + def call_pull_organizer_with(**cli_options) + movefile = movefile_from(cli_options) + + if movefile.options.dig(movefile.environment, :ssh) + call_organizer_with( + klass: Wordmove::Organizers::Ssh::Pull, + movefile:, cli_options: + ) + elsif movefile.options.dig(movefile.environment, :ftp) + call_organizer_with( + klass: Wordmove::Organizers::Ftp::Pull, + movefile:, cli_options: + ) + else + raise NoAdapterFound, 'No valid adapter found. It seems like your movefile.yml lacks ' \ + 'an ssh or ftp section for the current environment. ' \ + 'Run `wordmove doctor` for more info' + end + rescue NoAdapterFound => e + Logger.new($stdout).error(e.message) + exit 1 + end end - Wordmove::Hook.run(:push, :before, options) + class Push < Dry::CLI::Command + desc 'Pulls WP data from remote host to the local machine' - guardian = Wordmove::Guardian.new(options: options, action: :push) + include Wordmove::CLI::PullPushShared - handle_options(options) do |task| - deployer.send("push_#{task}") if guardian.allows(task.to_sym) + def call(**cli_options) + call_push_organizer_with(**cli_options) + end + + private + + def call_push_organizer_with(**cli_options) + movefile = movefile_from(cli_options) + + if movefile.options.dig(movefile.environment, :ssh) + call_organizer_with( + klass: Wordmove::Organizers::Ssh::Push, + movefile:, cli_options: + ) + elsif movefile.options.dig(movefile.environment, :ftp) + call_organizer_with( + klass: Wordmove::Organizers::Ftp::Push, + movefile:, cli_options: + ) + else + raise NoAdapterFound, 'No valid adapter found. It seems like your movefile.yml lacks ' \ + 'an ssh or ftp section for the current environment. ' \ + 'Run `wordmove doctor` for more info' + end + rescue NoAdapterFound => e + Logger.new($stdout).error(e.message) + exit 1 + end end - Wordmove::Hook.run(:push, :after, options) + register 'version', Version, aliases: %w[v -v --version] + register 'init', Init + register 'doctor', Doctor + register 'list', List + register 'pull', Pull + register 'push', Push end end end diff --git a/lib/wordmove/db_paths_config.rb b/lib/wordmove/db_paths_config.rb new file mode 100644 index 00000000..6a34e0ce --- /dev/null +++ b/lib/wordmove/db_paths_config.rb @@ -0,0 +1,44 @@ +class DbPathsConfig + extend Dry::Configurable + + setting :local, reader: true do + setting :path + setting :gzipped_path + setting :adapted_path + setting :gzipped_adapted_path + end + + setting :remote, reader: true do + setting :path + setting :gzipped_path + end + + setting :backup, reader: true do + setting :local do + setting :path + setting :gzipped_path + end + + setting :remote do + setting :path + setting :gzipped_path + end + end + + # FTP settings are intentionally taken apart + setting :ftp, reader: true do + setting :remote, reader: true do + setting :dump_script_path + setting :dump_script_url + setting :dumped_path + setting :import_script_path + setting :import_script_url + end + setting :local, reader: true do + setting :generated_dump_script_path + setting :generated_import_script_path + setting :temp_path + end + setting :token + end +end diff --git a/lib/wordmove/deployer/base.rb b/lib/wordmove/deployer/base.rb deleted file mode 100644 index 6e023a13..00000000 --- a/lib/wordmove/deployer/base.rb +++ /dev/null @@ -1,193 +0,0 @@ -module Wordmove - module Deployer - class Base - attr_reader :options - attr_reader :logger - attr_reader :environment - - class << self - def deployer_for(cli_options) - movefile = Wordmove::Movefile.new(cli_options[:config]) - movefile.load_dotenv(cli_options) - - options = movefile.fetch.merge! cli_options - environment = movefile.environment(cli_options) - - return FTP.new(environment, options) if options[environment][:ftp] - - if options[environment][:ssh] && options[:global][:sql_adapter] == 'wpcli' - return Ssh::WpcliSqlAdapter.new(environment, options) - end - - if options[environment][:ssh] && options[:global][:sql_adapter] == 'default' - return Ssh::DefaultSqlAdapter.new(environment, options) - end - - raise NoAdapterFound, "No valid adapter found." - end - - def current_dir - '.' - end - - def logger(secrets) - Logger.new(STDOUT, secrets).tap { |l| l.level = Logger::DEBUG } - end - end - - def initialize(environment, options = {}) - @environment = environment.to_sym - @options = options - - movefile_secrets = Wordmove::Movefile.new(options[:config]).secrets - @logger = self.class.logger(movefile_secrets) - end - - def push_db - logger.task "Pushing Database" - end - - def pull_db - logger.task "Pulling Database" - end - - def remote_get_directory; end - - def remote_put_directory; end - - def exclude_dir_contents(path) - "#{path}/*" - end - - def push_wordpress - logger.task "Pushing wordpress core" - - local_path = local_options[:wordpress_path] - remote_path = remote_options[:wordpress_path] - exclude_wp_content = exclude_dir_contents(local_wp_content_dir.relative_path) - exclude_paths = paths_to_exclude.push(exclude_wp_content) - - remote_put_directory(local_path, remote_path, exclude_paths) - end - - def pull_wordpress - logger.task "Pulling wordpress core" - - local_path = local_options[:wordpress_path] - remote_path = remote_options[:wordpress_path] - exclude_wp_content = exclude_dir_contents(remote_wp_content_dir.relative_path) - exclude_paths = paths_to_exclude.push(exclude_wp_content) - - remote_get_directory(remote_path, local_path, exclude_paths) - end - - protected - - def paths_to_exclude - remote_options[:exclude] || [] - end - - def run(command) - logger.task_step true, command - return true if simulate? - - system(command) - raise ShellCommandError, "Return code reports an error" unless $CHILD_STATUS.success? - end - - def download(url, local_path) - logger.task_step true, "download #{url} > #{local_path}" - - return true if simulate? - - File.open(local_path, 'w') do |file| - file << URI.open(url).read - end - end - - def simulate? - options[:simulate] - end - - [ - WordpressDirectory::Path::WP_CONTENT, - WordpressDirectory::Path::PLUGINS, - WordpressDirectory::Path::MU_PLUGINS, - WordpressDirectory::Path::THEMES, - WordpressDirectory::Path::UPLOADS, - WordpressDirectory::Path::LANGUAGES - ].each do |type| - %i[remote local].each do |location| - define_method "#{location}_#{type}_dir" do - options = send("#{location}_options") - WordpressDirectory.new(type, options) - end - end - end - - def mysql_dump_command(options, save_to_path) - command = ["mysqldump"] - command << "--host=#{Shellwords.escape(options[:host])}" if options[:host].present? - command << "--port=#{Shellwords.escape(options[:port])}" if options[:port].present? - command << "--user=#{Shellwords.escape(options[:user])}" if options[:user].present? - if options[:password].present? - command << "--password=#{Shellwords.escape(options[:password])}" - end - command << "--result-file=\"#{save_to_path}\"" - if options[:mysqldump_options].present? - command << Shellwords.split(options[:mysqldump_options]) - end - command << Shellwords.escape(options[:name]) - command.join(" ") - end - - def mysql_import_command(dump_path, options) - command = ["mysql"] - command << "--host=#{Shellwords.escape(options[:host])}" if options[:host].present? - command << "--port=#{Shellwords.escape(options[:port])}" if options[:port].present? - command << "--user=#{Shellwords.escape(options[:user])}" if options[:user].present? - if options[:password].present? - command << "--password=#{Shellwords.escape(options[:password])}" - end - command << "--database=#{Shellwords.escape(options[:name])}" - command << Shellwords.split(options[:mysql_options]) if options[:mysql_options].present? - command << "--execute=\"SET autocommit=0;SOURCE #{dump_path};COMMIT\"" - command.join(" ") - end - - def compress_command(path) - command = ["gzip"] - command << "-9" - command << "-f" - command << "\"#{path}\"" - command.join(" ") - end - - def uncompress_command(path) - command = ["gzip"] - command << "-d" - command << "-f" - command << "\"#{path}\"" - command.join(" ") - end - - def local_delete(path) - logger.task_step true, "delete: '#{path}'" - File.delete(path) unless simulate? - end - - def save_local_db(local_dump_path) - # dump local mysql into file - run mysql_dump_command(local_options[:database], local_dump_path) - end - - def remote_options - options[environment].clone - end - - def local_options - options[:local].clone - end - end - end -end diff --git a/lib/wordmove/deployer/ftp.rb b/lib/wordmove/deployer/ftp.rb deleted file mode 100644 index 883d9715..00000000 --- a/lib/wordmove/deployer/ftp.rb +++ /dev/null @@ -1,160 +0,0 @@ -module Wordmove - module Deployer - class FTP < Base - def initialize(environment, options) - super(environment, options) - ftp_options = remote_options[:ftp] - @copier = Photocopier::FTP.new(ftp_options).tap { |c| c.logger = logger } - end - - def push_db - super - - return true if simulate? - - local_dump_path = local_wp_content_dir.path("dump.sql") - remote_dump_path = remote_wp_content_dir.path("dump.sql") - local_backup_path = local_wp_content_dir.path("remote-backup-#{Time.now.to_i}.sql") - - download_remote_db(local_backup_path) - save_local_db(local_dump_path) - - # gsub sql - adapt_sql(local_dump_path, local_options, remote_options) - # upload it - remote_put(local_dump_path, remote_dump_path) - - import_remote_dump - - # remove dump remotely - remote_delete(remote_dump_path) - # and locally - local_delete(local_dump_path) - end - - def pull_db - super - - return true if simulate? - - local_dump_path = local_wp_content_dir.path("dump.sql") - local_backup_path = local_wp_content_dir.path("local-backup-#{Time.now.to_i}.sql") - - save_local_db(local_backup_path) - download_remote_db(local_dump_path) - - # gsub sql - adapt_sql(local_dump_path, remote_options, local_options) - # import locally - run mysql_import_command(local_dump_path, local_options[:database]) - - # and locally - if options[:debug] - logger.debug "Remote dump located at: #{local_dump_path}" - else - local_delete(local_dump_path) - end - end - - private - - %w[uploads themes plugins mu_plugins languages].each do |task| - define_method "push_#{task}" do - logger.task "Pushing #{task.titleize}" - local_path = send("local_#{task}_dir").path - remote_path = send("remote_#{task}_dir").path - remote_put_directory(local_path, remote_path, paths_to_exclude) - end - - define_method "pull_#{task}" do - logger.task "Pulling #{task.titleize}" - local_path = send("local_#{task}_dir").path - remote_path = send("remote_#{task}_dir").path - remote_get_directory(remote_path, local_path, paths_to_exclude) - end - end - - %w[get get_directory put_directory delete].each do |command| - define_method "remote_#{command}" do |*args| - logger.task_step false, "#{command}: #{args.join(' ')}" - @copier.send(command, *args) unless simulate? - end - end - - def remote_put(thing, path) - if File.exist?(thing) - logger.task_step false, "copying #{thing} to #{path}" - else - logger.task_step false, "write #{path}" - end - @copier.put(thing, path) unless simulate? - end - - def escape_php(string) - return '' unless string - - # replaces \ with \\ - # replaces ' with \' - string.gsub('\\', '\\\\\\').gsub(/[']/, '\\\\\'') - end - - def generate_dump_script(db, password) - template = ERB.new File.read(File.join(File.dirname(__FILE__), "../assets/dump.php.erb")) - template.result(binding) - end - - def generate_import_script(db, password) - template = ERB.new File.read(File.join(File.dirname(__FILE__), "../assets/import.php.erb")) - template.result(binding) - end - - def download_remote_db(local_dump_path) - remote_dump_script = remote_wp_content_dir.path("dump.php") - # generate a secure one-time password - one_time_password = SecureRandom.hex(40) - # generate dump script - dump_script = generate_dump_script(remote_options[:database], one_time_password) - # upload the dump script - remote_put(dump_script, remote_dump_script) - # download the resulting dump (using the password) - dump_url = "#{remote_wp_content_dir.url('dump.php')}?shared_key=#{one_time_password}" - download(dump_url, local_dump_path) - # cleanup remotely - remote_delete(remote_dump_script) - remote_delete(remote_wp_content_dir.path("dump.mysql")) - end - - def import_remote_dump - temp_path = local_wp_content_dir.path("log.html") - remote_import_script_path = remote_wp_content_dir.path("import.php") - # generate a secure one-time password - one_time_password = SecureRandom.hex(40) - # generate import script - import_script = generate_import_script(remote_options[:database], one_time_password) - # upload import script - remote_put(import_script, remote_import_script_path) - # run import script - import_url = [ - remote_wp_content_dir.url('import.php').to_s, - "?shared_key=#{one_time_password}", - "&start=1&foffset=0&totalqueries=0&fn=dump.sql" - ].join - download(import_url, temp_path) - if options[:debug] - logger.debug "Operation log located at: #{temp_path}" - else - local_delete(temp_path) - end - # remove script remotely - remote_delete(remote_import_script_path) - end - - def adapt_sql(save_to_path, local, remote) - return if options[:no_adapt] - - logger.task_step true, "Adapt dump" - SqlAdapter::Default.new(save_to_path, local, remote).adapt! unless simulate? - end - end - end -end diff --git a/lib/wordmove/deployer/ssh.rb b/lib/wordmove/deployer/ssh.rb deleted file mode 100644 index 6665ecda..00000000 --- a/lib/wordmove/deployer/ssh.rb +++ /dev/null @@ -1,169 +0,0 @@ -require 'pathname' - -module Wordmove - module Deployer - class SSH < Base - attr_reader :local_dump_path, - :local_backup_path, - :local_gzipped_dump_path, - :local_gzipped_backup_path - - def initialize(environment, options) - super(environment, options) - ssh_options = remote_options[:ssh] - - if simulate? && ssh_options[:rsync_options] - ssh_options[:rsync_options].concat(" --dry-run") - elsif simulate? - ssh_options[:rsync_options] = "--dry-run" - end - - @copier = Photocopier::SSH.new(ssh_options).tap { |c| c.logger = logger } - - @local_dump_path = local_wp_content_dir.path("dump.sql") - @local_backup_path = local_wp_content_dir.path("local-backup-#{Time.now.to_i}.sql") - @local_gzipped_dump_path = local_dump_path + '.gz' - @local_gzipped_backup_path = local_wp_content_dir - .path("#{environment}-backup-#{Time.now.to_i}.sql.gz") - end - - private - - def push_db - super - - return true if simulate? - - backup_remote_db! - adapt_local_db! - after_push_cleanup! - end - - def pull_db - super - - return true if simulate? - - backup_local_db! - adapt_remote_db! - after_pull_cleanup! - end - - # In following commands, we do not guard for simulate? - # because it is handled through --dry-run rsync option. - # @see initialize - %w[get put get_directory put_directory delete].each do |command| - define_method "remote_#{command}" do |*args| - logger.task_step false, "#{command}: #{args.join(' ')}" - @copier.send(command, *args) - end - end - - def remote_run(command) - logger.task_step false, command - return true if simulate? - - _stdout, stderr, exit_code = @copier.exec! command - - return true if exit_code.zero? - - raise( - ShellCommandError, - "Error code #{exit_code} returned by command \"#{command}\": #{stderr}" - ) - end - - def download_remote_db(local_gizipped_dump_path) - remote_dump_path = remote_wp_content_dir.path("dump.sql") - # dump remote db into file - remote_run mysql_dump_command(remote_options[:database], remote_dump_path) - remote_run compress_command(remote_dump_path) - remote_dump_path += '.gz' - # download remote dump - remote_get(remote_dump_path, local_gizipped_dump_path) - remote_delete(remote_dump_path) - end - - def import_remote_dump(local_gizipped_dump_path) - remote_dump_path = remote_wp_content_dir.path("dump.sql") - remote_gizipped_dump_path = remote_dump_path + '.gz' - - remote_put(local_gizipped_dump_path, remote_gizipped_dump_path) - remote_run uncompress_command(remote_gizipped_dump_path) - remote_run mysql_import_command(remote_dump_path, remote_options[:database]) - remote_delete(remote_dump_path) - end - - %w[uploads themes plugins mu_plugins languages].each do |task| - define_method "push_#{task}" do - logger.task "Pushing #{task.titleize}" - local_path = local_options[:wordpress_path] - remote_path = remote_options[:wordpress_path] - - remote_put_directory(local_path, remote_path, - push_exclude_paths(task), push_inlcude_paths(task)) - end - - define_method "pull_#{task}" do - logger.task "Pulling #{task.titleize}" - local_path = local_options[:wordpress_path] - remote_path = remote_options[:wordpress_path] - remote_get_directory(remote_path, local_path, - pull_exclude_paths(task), pull_include_paths(task)) - end - end - - def push_inlcude_paths(task) - Pathname.new(send(:"local_#{task}_dir").relative_path) - .ascend - .each_with_object([]) do |directory, array| - path = directory.to_path - path.prepend('/') unless path.match? %r{^/} - path.concat('/') unless path.match? %r{/$} - array << path - end - end - - def push_exclude_paths(task) - Pathname.new(send(:"local_#{task}_dir").relative_path) - .dirname - .ascend - .each_with_object([]) do |directory, array| - path = directory.to_path - path.prepend('/') unless path.match? %r{^/} - path.concat('/') unless path.match? %r{/$} - path.concat('*') - array << path - end - .concat(paths_to_exclude) - .concat(['/*']) - end - - def pull_include_paths(task) - Pathname.new(send(:"remote_#{task}_dir").relative_path) - .ascend - .each_with_object([]) do |directory, array| - path = directory.to_path - path.prepend('/') unless path.match? %r{^/} - path.concat('/') unless path.match? %r{/$} - array << path - end - end - - def pull_exclude_paths(task) - Pathname.new(send(:"remote_#{task}_dir").relative_path) - .dirname - .ascend - .each_with_object([]) do |directory, array| - path = directory.to_path - path.prepend('/') unless path.match? %r{^/} - path.concat('/') unless path.match? %r{/$} - path.concat('*') - array << path - end - .concat(paths_to_exclude) - .concat(['/*']) - end - end - end -end diff --git a/lib/wordmove/deployer/ssh/default_sql_adapter.rb b/lib/wordmove/deployer/ssh/default_sql_adapter.rb deleted file mode 100644 index 1a018b49..00000000 --- a/lib/wordmove/deployer/ssh/default_sql_adapter.rb +++ /dev/null @@ -1,47 +0,0 @@ -module Wordmove - module Deployer - module Ssh - class DefaultSqlAdapter < SSH - private - - def backup_remote_db! - download_remote_db(local_gzipped_backup_path) - end - - def adapt_local_db! - save_local_db(local_dump_path) - adapt_sql(local_dump_path, local_options, remote_options) - run compress_command(local_dump_path) - import_remote_dump(local_gzipped_dump_path) - end - - def after_push_cleanup! - local_delete(local_gzipped_dump_path) - end - - def backup_local_db! - save_local_db(local_backup_path) - run compress_command(local_backup_path) - end - - def adapt_remote_db! - download_remote_db(local_gzipped_dump_path) - run uncompress_command(local_gzipped_dump_path) - adapt_sql(local_dump_path, remote_options, local_options) - run mysql_import_command(local_dump_path, local_options[:database]) - end - - def after_pull_cleanup! - local_delete(local_dump_path) - end - - def adapt_sql(save_to_path, local, remote) - return if options[:no_adapt] - - logger.task_step true, "Adapt dump" - SqlAdapter::Default.new(save_to_path, local, remote).adapt! unless simulate? - end - end - end - end -end diff --git a/lib/wordmove/deployer/ssh/wpcli_sql_adapter.rb b/lib/wordmove/deployer/ssh/wpcli_sql_adapter.rb deleted file mode 100644 index 3d123f63..00000000 --- a/lib/wordmove/deployer/ssh/wpcli_sql_adapter.rb +++ /dev/null @@ -1,55 +0,0 @@ -module Wordmove - module Deployer - module Ssh - class WpcliSqlAdapter < SSH - def backup_remote_db! - download_remote_db(local_gzipped_backup_path) - end - - def adapt_local_db! - save_local_db(local_dump_path) - run wpcli_search_replace(local_options, remote_options, :vhost) - run wpcli_search_replace(local_options, remote_options, :wordpress_path) - - local_search_replace_dump_path = local_wp_content_dir.path("search_replace_dump.sql") - local_gzipped_search_replace_dump_path = local_search_replace_dump_path + '.gz' - - save_local_db(local_search_replace_dump_path) - run compress_command(local_search_replace_dump_path) - import_remote_dump(local_gzipped_search_replace_dump_path) - local_delete(local_gzipped_search_replace_dump_path) - run mysql_import_command(local_dump_path, local_options[:database]) - end - - def after_push_cleanup! - local_delete(local_dump_path) - end - - def backup_local_db! - save_local_db(local_backup_path) - run compress_command(local_backup_path) - end - - def adapt_remote_db! - download_remote_db(local_gzipped_dump_path) - run uncompress_command(local_gzipped_dump_path) - run mysql_import_command(local_dump_path, local_options[:database]) - run wpcli_search_replace(remote_options, local_options, :vhost) - run wpcli_search_replace(remote_options, local_options, :wordpress_path) - end - - def after_pull_cleanup! - local_delete(local_dump_path) - end - - def wpcli_search_replace(local, remote, config_key) - return if options[:no_adapt] - - logger.task_step true, "adapt dump for #{config_key}" - path = local_options[:wordpress_path] - SqlAdapter::Wpcli.new(local, remote, config_key, path).command unless simulate? - end - end - end - end -end diff --git a/lib/wordmove/doctor/movefile.rb b/lib/wordmove/doctor/movefile.rb index 06bace92..aa472a81 100644 --- a/lib/wordmove/doctor/movefile.rb +++ b/lib/wordmove/doctor/movefile.rb @@ -4,17 +4,17 @@ class Movefile MANDATORY_SECTIONS = %i[global local].freeze attr_reader :movefile, :contents, :root_keys - def initialize(name = nil, dir = '.') - @movefile = Wordmove::Movefile.new(name, dir) + def initialize(cli_options = {}, dir = '.') + @movefile = Wordmove::Movefile.new(cli_options, dir, false) begin - @contents = movefile.fetch + @contents = movefile.options @root_keys = contents.keys rescue Psych::SyntaxError - movefile.logger.error "Your movefile is not parsable due to a syntax error"\ + movefile.logger.error 'Your movefile is not parsable due to a syntax error'\ "so we can't continue to validate it." - movefile.logger.debug "You could try to use https://yamlvalidator.com/ to"\ - "get a clue about the problem." + movefile.logger.debug 'You could try to use https://yamlvalidator.com/ to'\ + 'get a clue about the problem.' end end @@ -40,7 +40,7 @@ def validate_section(key) errors = validator.validate(contents[key].deep_stringify_keys) if errors&.empty? - movefile.logger.success "Formal validation passed" + movefile.logger.success 'Formal validation passed' return true end @@ -69,7 +69,7 @@ def validate_remote_section(key) def validate_protocol_presence(keys) return true if keys.include?(:ssh) || keys.include?(:ftp) - movefile.logger.error "This remote has not ssh nor ftp protocol defined" + movefile.logger.error 'This remote has not ssh nor ftp protocol defined' false end diff --git a/lib/wordmove/doctor/mysql.rb b/lib/wordmove/doctor/mysql.rb index be1e5e41..a8f25dd7 100644 --- a/lib/wordmove/doctor/mysql.rb +++ b/lib/wordmove/doctor/mysql.rb @@ -4,16 +4,18 @@ class Mysql attr_reader :config, :logger def initialize(movefile_name = nil, movefile_dir = '.') - @logger = Logger.new(STDOUT).tap { |l| l.level = Logger::INFO } + @logger = Logger.new($stdout).tap { |l| l.level = Logger::INFO } begin - @config = Wordmove::Movefile.new(movefile_name, movefile_dir).fetch[:local][:database] - rescue Psych::SyntaxError - return + @config = Wordmove::Movefile + .new({ config: movefile_name }, movefile_dir, false) + .options[:local][:database] + rescue Psych::SyntaxError => e + logger.error e.message end end def check! - logger.task "Checking local database commands and connection" + logger.task 'Checking local database commands and connection' return logger.error "Can't connect to mysql using your movefile.yml" if config.nil? @@ -26,18 +28,18 @@ def check! private def mysql_client_doctor - if system("which mysql", out: File::NULL) - logger.success "`mysql` command is in $PATH" + if system('which mysql', out: File::NULL) + logger.success '`mysql` command is in $PATH' else - logger.error "`mysql` command is not in $PATH" + logger.error '`mysql` command is not in $PATH' end end def mysqldump_doctor - if system("which mysqldump", out: File::NULL) - logger.success "`mysqldump` command is in $PATH" + if system('which mysqldump', out: File::NULL) + logger.success '`mysqldump` command is in $PATH"' else - logger.error "`mysqldump` command is not in $PATH" + logger.error '`mysqldump` command is not in $PATH' end end @@ -45,7 +47,7 @@ def mysql_server_doctor command = mysql_command if system(command, out: File::NULL, err: File::NULL) - logger.success "Successfully connected to the MySQL server" + logger.success 'Successfully connected to the MySQL server' else logger.error <<-LONG We can't connect to the MySQL server using credentials @@ -63,7 +65,7 @@ def mysql_database_doctor command = mysql_command(database: config[:name]) if system(command, out: File::NULL, err: File::NULL) - logger.success "Successfully connected to the database" + logger.success 'Successfully connected to the database' else logger.error <<-LONG We can't connect to the database using credentials @@ -79,16 +81,17 @@ def mysql_database_doctor end def mysql_command(database: nil) - command = ["mysql"] + command = ['mysql'] command << "--host=#{Shellwords.escape(config[:host])}" if config[:host].present? command << "--port=#{Shellwords.escape(config[:port])}" if config[:port].present? command << "--user=#{Shellwords.escape(config[:user])}" if config[:user].present? if config[:password].present? command << "--password=#{Shellwords.escape(config[:password])}" end + command << Shellwords.split(config[:mysql_options]) if config[:mysql_options].present? command << database if database.present? command << "-e'QUIT'" - command.join(" ") + command.join(' ') end end end diff --git a/lib/wordmove/doctor/rsync.rb b/lib/wordmove/doctor/rsync.rb index 5665b479..7915dc14 100644 --- a/lib/wordmove/doctor/rsync.rb +++ b/lib/wordmove/doctor/rsync.rb @@ -4,11 +4,11 @@ class Rsync attr_reader :logger def initialize - @logger = Logger.new(STDOUT).tap { |l| l.level = Logger::INFO } + @logger = Logger.new($stdout).tap { |l| l.level = Logger::INFO } end def check! - logger.task "Checking rsync" + logger.task 'Checking rsync' if (version = /\d\.\d.\d/.match(`rsync --version | head -n1`)[0]) logger.success "rsync is installed at version #{version}" diff --git a/lib/wordmove/doctor/ssh.rb b/lib/wordmove/doctor/ssh.rb index 680b8894..5f73c735 100644 --- a/lib/wordmove/doctor/ssh.rb +++ b/lib/wordmove/doctor/ssh.rb @@ -4,14 +4,14 @@ class Ssh attr_reader :logger def initialize - @logger = Logger.new(STDOUT).tap { |l| l.level = Logger::INFO } + @logger = Logger.new($stdout).tap { |l| l.level = Logger::INFO } end def check! - logger.task "Checking SSH client" + logger.task 'Checking SSH client' if system('which ssh', out: File::NULL, err: File::NULL) - logger.success "SSH command found" + logger.success 'SSH command found' else logger.error "SSH command not found. And belive me: it's really strange it's not there." end diff --git a/lib/wordmove/doctor/wpcli.rb b/lib/wordmove/doctor/wpcli.rb index 5d347c91..688edf08 100644 --- a/lib/wordmove/doctor/wpcli.rb +++ b/lib/wordmove/doctor/wpcli.rb @@ -4,17 +4,17 @@ class Wpcli attr_reader :logger def initialize - @logger = Logger.new(STDOUT).tap { |l| l.level = Logger::INFO } + @logger = Logger.new($stdout).tap { |l| l.level = Logger::INFO } end def check! - logger.task "Checking local wp-cli installation" + logger.task 'Checking local wp-cli installation' if in_path? - logger.success "wp-cli is correctly installed" + logger.success 'wp-cli is correctly installed' if up_to_date? - logger.success "wp-cli is up to date" + logger.success 'wp-cli is up to date' else logger.error <<-LONG wp-cli is not up to date. @@ -36,7 +36,7 @@ def in_path? end def up_to_date? - `wp cli check-update --format=json`.empty? + `wp cli check-update --format=json --allow-root`.empty? end end end diff --git a/lib/wordmove/environments_list.rb b/lib/wordmove/environments_list.rb index 6d99ed19..84cb2214 100644 --- a/lib/wordmove/environments_list.rb +++ b/lib/wordmove/environments_list.rb @@ -9,15 +9,15 @@ def print(cli_options) end def initialize(options) - @logger = Logger.new(STDOUT).tap { |l| l.level = Logger::INFO } - @movefile = Wordmove::Movefile.new(options[:config]) + @logger = Logger.new($stdout).tap { |l| l.level = Logger::INFO } + @movefile = Wordmove::Movefile.new(options) @remote_vhosts = [] @local_vhost = [] end def print - contents = parse_movefile(movefile: movefile) - generate_vhost_list(contents: contents) + contents = parse_movefile(movefile:) + generate_vhost_list(contents:) output end @@ -29,7 +29,7 @@ def select_vhost(contents:) end def parse_movefile(movefile:) - movefile.fetch + movefile.options end def output @@ -43,7 +43,7 @@ def output def output_string(vhost_list:) return 'vhost list is empty' if vhost_list.empty? - vhost_list.each_with_object("") do |entry, retval| + vhost_list.each_with_object('') do |entry, retval| retval << "#{entry[:env]}: #{entry[:vhost]}\n" end end @@ -55,7 +55,7 @@ def output_string(vhost_list:) # def generate_vhost_list(contents:) # select object which has 'vhost' only - vhosts = select_vhost(contents: contents) + vhosts = select_vhost(contents:) vhosts.each do |list| if list[:env] == :local @local_vhost << list diff --git a/lib/wordmove/exceptions.rb b/lib/wordmove/exceptions.rb index cfb3b87c..675684b7 100644 --- a/lib/wordmove/exceptions.rb +++ b/lib/wordmove/exceptions.rb @@ -1,10 +1,23 @@ module Wordmove class UndefinedEnvironment < StandardError; end + class NoAdapterFound < StandardError; end + class MovefileNotFound < StandardError; end + class ShellCommandError < StandardError; end + class ImplementInSubclassError < StandardError; end + class UnmetPeerDependencyError < StandardError; end + class RemoteHookException < StandardError; end + class LocalHookException < StandardError; end + + class FtpNotSupportedException < StandardError + def message + 'FTP protocol is no more supported in verison >= 6.0' + end + end end diff --git a/lib/wordmove/generators/movefile.rb b/lib/wordmove/generators/movefile.rb index 0cb39be8..826284f4 100644 --- a/lib/wordmove/generators/movefile.rb +++ b/lib/wordmove/generators/movefile.rb @@ -1,15 +1,16 @@ module Wordmove module Generators - class Movefile < Thor::Group - include Thor::Actions - include MovefileAdapter - - def self.source_root - File.dirname(__FILE__) + class Movefile + def self.generate + copy_movefile end - def copy_movefile - template "movefile.yml" + def self.copy_movefile + wordpress_path = File.expand_path(Dir.pwd) + content = ERB.new(File.read(File.join(__dir__, 'movefile.yml'))).result(binding) + + files = Dry::Files.new + files.write('movefile.yml', content) end end end diff --git a/lib/wordmove/generators/movefile.yml b/lib/wordmove/generators/movefile.yml index 8aadcb74..3207d6fe 100644 --- a/lib/wordmove/generators/movefile.yml +++ b/lib/wordmove/generators/movefile.yml @@ -2,14 +2,15 @@ global: sql_adapter: wpcli local: - vhost: http://vhost.local wordpress_path: <%= wordpress_path %> # use an absolute path here - database: - name: <%= database.name %> - user: <%= database.user %> - password: "<%= database.password %>" # could be blank, so always use quotes around - host: <%= database.host %> + # paths: # you can customize wordpress internal paths + # wp_content: wp-content + # uploads: wp-content/uploads + # plugins: wp-content/plugins + # mu_plugins: wp-content/mu-plugins + # themes: wp-content/themes + # languages: wp-content/languages production: vhost: http://example.com @@ -40,6 +41,7 @@ production: - 'wp-config.php' - 'wp-content/*.sql.gz' - '*.orig' + - 'wp-cli.yml' # paths: # you can customize wordpress internal paths # wp_content: wp-content diff --git a/lib/wordmove/generators/movefile_adapter.rb b/lib/wordmove/generators/movefile_adapter.rb deleted file mode 100644 index 567b5f7c..00000000 --- a/lib/wordmove/generators/movefile_adapter.rb +++ /dev/null @@ -1,83 +0,0 @@ -module Wordmove - module Generators - module MovefileAdapter - def wordpress_path - File.expand_path(Dir.pwd) - end - - def database - DBConfigReader.config - end - end - - class DBConfigReader - def self.config - new.config - end - - def config - OpenStruct.new(database_config) - end - - def database_config - if wp_config_exists? - WordpressDBConfig.config - else - DefaultDBConfig.config - end - end - - def wp_config_exists? - File.exist?(WordpressDirectory.default_path_for(:wp_config)) - end - end - - class DefaultDBConfig - def self.config - { - name: "database_name", - user: "user", - password: "password", - host: "127.0.0.1" - } - end - end - - class WordpressDBConfig - def self.config - new.config - end - - def wp_config - @wp_config ||= File.read( - WordpressDirectory.default_path_for(:wp_config) - ).encode('utf-8', invalid: :replace) - end - - def wp_definitions - { - name: 'DB_NAME', - user: 'DB_USER', - password: 'DB_PASSWORD', - host: 'DB_HOST' - } - end - - def wp_definition_regex(definition) - /['"]#{definition}['"],\s*["'](?.*)['"]/ - end - - def defaults - DefaultDBConfig.config.clone - end - - def config - wp_definitions.each_with_object(defaults) do |(key, definition), result| - wp_config.match(wp_definition_regex(definition)) do |match| - result[key] = match[:value] - end - end - end - end - end -end diff --git a/lib/wordmove/guardian.rb b/lib/wordmove/guardian.rb index bb388577..9148eb72 100644 --- a/lib/wordmove/guardian.rb +++ b/lib/wordmove/guardian.rb @@ -2,11 +2,11 @@ module Wordmove class Guardian attr_reader :movefile, :environment, :action, :logger - def initialize(options: nil, action: nil) - @movefile = Wordmove::Movefile.new(options[:config]) - @environment = @movefile.environment(options).to_sym + def initialize(cli_options: nil, action: nil) + @movefile = Wordmove::Movefile.new(cli_options, nil, false) + @environment = @movefile.environment.to_sym @action = action - @logger = Logger.new(STDOUT).tap { |l| l.level = Logger::DEBUG } + @logger = Logger.new($stdout).tap { |l| l.level = Logger::DEBUG } end def allows(task) @@ -27,7 +27,7 @@ def forbidden?(task) end def forbidden_tasks - environment_options = movefile.fetch(false)[environment] + environment_options = movefile.options[environment] return {} unless environment_options.key?(:forbid) return {} unless environment_options[:forbid].key?(action) diff --git a/lib/wordmove/hook.rb b/lib/wordmove/hook.rb index 0545d7e9..0d9d4882 100644 --- a/lib/wordmove/hook.rb +++ b/lib/wordmove/hook.rb @@ -1,14 +1,13 @@ module Wordmove class Hook def self.logger - Logger.new(STDOUT).tap { |l| l.level = Logger::DEBUG } + Logger.new($stdout).tap { |l| l.level = Logger::DEBUG } end # rubocop:disable Metrics/MethodLength - def self.run(action, step, cli_options) - movefile = Wordmove::Movefile.new(cli_options[:config]) - options = movefile.fetch(false) - environment = movefile.environment(cli_options) + def self.run(action, step, movefile:, simulate: false) + options = movefile.options + environment = movefile.environment hooks = Wordmove::Hook::Config.new( options[environment][:hooks], @@ -21,17 +20,17 @@ def self.run(action, step, cli_options) logger.task "Running #{action}/#{step} hooks" hooks.all_commands.each do |command| - case command[:where] + case command.fetch(:where) when 'local' - Wordmove::Hook::Local.run(command, options[:local], cli_options[:simulate]) + Wordmove::Hook::Local.run(command, options[:local], simulate) when 'remote' if options[environment][:ftp] - logger.debug "You have configured remote hooks to run over "\ - "an FTP connection, but this is not possible. Skipping." + logger.debug 'You have configured remote hooks to run over '\ + 'an FTP connection, but this is not possible. Skipping.' next end - Wordmove::Hook::Remote.run(command, options[environment], cli_options[:simulate]) + Wordmove::Hook::Remote.run(command, options[environment], simulate) else next end @@ -81,7 +80,7 @@ def self.logger Wordmove::Hook.logger end - def self.run(command_hash, options, simulate = false) + def self.run(command_hash, options, simulate = false) # rubocop:disable Style/OptionalBooleanParameter wordpress_path = options[:wordpress_path] logger.task_step true, "Exec command: #{command_hash[:command]}" @@ -91,7 +90,7 @@ def self.run(command_hash, options, simulate = false) logger.task_step true, "Output: #{stdout_return}" if $CHILD_STATUS.exitstatus.zero? - logger.success "" + logger.success '' else logger.error "Error code: #{$CHILD_STATUS.exitstatus}" raise Wordmove::LocalHookException unless command_hash[:raise].eql? false @@ -104,7 +103,7 @@ def self.logger Wordmove::Hook.logger end - def self.run(command_hash, options, simulate = false) + def self.run(command_hash, options, simulate = false) # rubocop:disable Style/OptionalBooleanParameter ssh_options = options[:ssh] wordpress_path = options[:wordpress_path] @@ -118,7 +117,7 @@ def self.run(command_hash, options, simulate = false) if exit_code.zero? logger.task_step false, "Output: #{stdout}" - logger.success "" + logger.success '' else logger.task_step false, "Output: #{stderr}" logger.error "Error code #{exit_code}" diff --git a/lib/wordmove/logger.rb b/lib/wordmove/logger.rb index d66da0ae..d58dd3e9 100644 --- a/lib/wordmove/logger.rb +++ b/lib/wordmove/logger.rb @@ -4,7 +4,8 @@ class Logger < ::Logger def initialize(device, strings_to_hide = []) super(device, formatter: proc { |_severity, _datetime, _progname, message| - formatted_message = if strings_to_hide.empty? + formatted_message = if strings_to_hide.empty? || + ENV.fetch('WORDMOVE_REVEAL_SECRETS', nil).present? message else message.gsub( @@ -20,38 +21,38 @@ def initialize(device, strings_to_hide = []) end def task(title) - prefix = "▬" * 2 + prefix = '▬' * 2 title = " #{title} " - padding = "▬" * padding_length(title) + padding = '▬' * padding_length(title) add(INFO, prefix + title.light_white + padding) end def task_step(local_step, title) if local_step - add(INFO, " local".cyan + " | ".black + title.to_s) + add(INFO, ' local'.cyan + ' | '.black + title.to_s) else - add(INFO, " remote".yellow + " | ".black + title.to_s) + add(INFO, ' remote'.yellow + ' | '.black + title.to_s) end end def error(message) - add(ERROR, " ❌ error".red + " | ".black + message.to_s) + add(ERROR, ' ❌ error'.red + ' | '.black + message.to_s) end def success(message) - add(INFO, " ✅ success".green + " | ".black + message.to_s) + add(INFO, ' ✅ success'.green + ' | '.black + message.to_s) end def debug(message) - add(DEBUG, " 🛠 debug".magenta + " | ".black + message.to_s) + add(DEBUG, ' 🛠 debug'.magenta + ' | '.black + message.to_s) end def warn(message) - add(WARN, " ⚠️ warning".yellow + " | ".black + message.to_s) + add(WARN, ' ⚠️ warning'.yellow + ' | '.black + message.to_s) end def info(message) - add(INFO, " ℹ️ info".yellow + " | ".black + message.to_s) + add(INFO, ' ℹ️ info'.yellow + ' | '.black + message.to_s) end def plain(message) diff --git a/lib/wordmove/movefile.rb b/lib/wordmove/movefile.rb index 633091d9..b10381ab 100644 --- a/lib/wordmove/movefile.rb +++ b/lib/wordmove/movefile.rb @@ -1,74 +1,43 @@ module Wordmove class Movefile - attr_reader :logger, :name, :start_dir - - def initialize(name = nil, start_dir = current_dir) - @logger = Logger.new(STDOUT).tap { |l| l.level = Logger::DEBUG } - @name = name - @start_dir = start_dir + attr_reader :logger, + :config_file_name, + :start_dir, + :options, + :cli_options + + def initialize(cli_options = {}, start_dir = nil, verbose = true) # rubocop:disable Style/OptionalBooleanParameter + @logger = Logger.new($stdout).tap { |l| l.level = Logger::DEBUG } + @cli_options = cli_options.deep_symbolize_keys || {} + @config_file_name = @cli_options.fetch(:config, nil) + @start_dir = start_dir || current_dir + + @options = fetch(verbose) + .deep_symbolize_keys! + .freeze end - def fetch(verbose = true) - entries = if name.nil? - Dir["#{File.join(start_dir, '{M,m}ovefile')}{,.yml,.yaml}"] - else - Dir["#{File.join(start_dir, name)}{,.yml,.yaml}"] - end - - if entries.empty? - if last_dir?(start_dir) - raise MovefileNotFound, "Could not find a valid Movefile. Searched"\ - " for filename \"#{name}\" in folder \"#{start_dir}\"" - end + def environment + available_enviroments = extract_available_envs(options) - @start_dir = upper_dir(start_dir) - return fetch(verbose) + if available_enviroments.size > 1 && cli_options[:environment].nil? + raise( + UndefinedEnvironment, + 'You need to specify an environment with --environment parameter' + ) end - found = entries.first - logger.task("Using Movefile: #{found}") if verbose == true - YAML.safe_load(ERB.new(File.read(found)).result, [], [], true).deep_symbolize_keys! - end - - def load_dotenv(cli_options = {}) - env = environment(cli_options) - env_files = Dir[File.join(start_dir, ".env{.#{env},}")] - - found_env = env_files.first - - return false unless found_env.present? - - logger.info("Using .env file: #{found_env}") - Dotenv.load(found_env) - end - - def environment(cli_options = {}) - options = fetch(false) - available_enviroments = extract_available_envs(options) - options.merge!(cli_options).deep_symbolize_keys! - - if options[:environment] != 'local' - if available_enviroments.size > 1 && options[:environment].nil? - raise( - UndefinedEnvironment, - "You need to specify an environment with --environment parameter" - ) - end - - if options[:environment].present? - unless available_enviroments.include?(options[:environment].to_sym) - raise UndefinedEnvironment, "No environment found for \"#{options[:environment]}\". "\ - "Available Environments: #{available_enviroments.join(' ')}" - end - end + if cli_options[:environment].present? && + !available_enviroments.include?(cli_options[:environment].to_sym) + raise UndefinedEnvironment, "No environment found for \"#{options[:environment]}\". "\ + "Available Environments: #{available_enviroments.join(' ')}" end - (options[:environment] || available_enviroments.first).to_sym + # NOTE: This is Hash#fetch, not self.fetch. + cli_options.fetch(:environment, available_enviroments.first).to_sym end def secrets - options = fetch(false) - secrets = [] options.each_key do |env| secrets << options.dig(env, :database, :password) @@ -86,12 +55,67 @@ def secrets private + def fetch(verbose = true) # rubocop:disable Style/OptionalBooleanParameter + entries = if config_file_name.nil? + Dir["#{File.join(start_dir, '{M,m}ovefile')}{,.yml,.yaml}"] + else + Dir["#{File.join(start_dir, config_file_name)}{,.yml,.yaml}"] + end + + if entries.empty? + if last_dir?(start_dir) + raise MovefileNotFound, 'Could not find a valid Movefile. Searched'\ + " for filename \"#{config_file_name}\" in folder \"#{start_dir}\"" + end + + @start_dir = upper_dir(start_dir) + return fetch(verbose) + end + + found = entries.first + + logger.task("Using Movefile: #{found}") if verbose == true + load_dotenv(verbose) + + options = YAML.safe_load(ERB.new(File.read(found)).result, symbolize_names: true) + + merge_local_options_from_wpcli(options) + end + + def merge_local_options_from_wpcli(options) + config_path = options.dig(:local, :wordpress_path) + + options.merge( + local: { + database: { + password: Wordmove::WpcliHelpers.get_config('DB_PASSWORD', config_path:), + host: Wordmove::WpcliHelpers.get_config('DB_HOST', config_path:), + name: Wordmove::WpcliHelpers.get_config('DB_NAME', config_path:), + user: Wordmove::WpcliHelpers.get_config('DB_USER', config_path:) + }, + vhost: Wordmove::WpcliHelpers.get_option('home', config_path:), + wordpress_path: config_path + } + ) + end + + def load_dotenv(verbose) + env_files = Dir[File.join(start_dir, '.env')] + + found_env = env_files.first + + return false unless found_env.present? + + logger.info("Using .env file: #{found_env}") if verbose + Dotenv.load(found_env) + end + def extract_available_envs(options) - options.keys.map(&:to_sym) - %i[local global] + options.keys - %i[local global] end def last_dir?(directory) - directory == "/" || File.exist?(File.join(directory, 'wp-config.php')) + directory == '/' || File.exist?(File.join(directory, 'wp-config.php')) end def upper_dir(directory) diff --git a/lib/wordmove/organizers/ftp/pull.rb b/lib/wordmove/organizers/ftp/pull.rb new file mode 100644 index 00000000..be74c87e --- /dev/null +++ b/lib/wordmove/organizers/ftp/pull.rb @@ -0,0 +1,55 @@ +module Wordmove + module Organizers + module Ftp + class Pull + extend ::LightService::Organizer + include Wordmove::Actions::Helpers + include Wordmove::Actions::Ftp::Helpers + + # Can't use keyword arguments since LightService still has some problems with modern + # ruby syntax: https://github.com/adomokos/light-service/pull/224 + def self.call(cli_options, movefile) + logger = Logger.new($stdout, movefile.secrets).tap { |l| l.level = Logger::DEBUG } + remote_options = movefile.options[movefile.environment] + ftp_opts = ftp_options(remote_options:) + + LightService::Configuration.logger = ::Logger.new($stdout) if cli_options[:debug] + + with( + cli_options:, + global_options: movefile.options[:global], + local_options: movefile.options[:local], + remote_options:, + movefile:, + guardian: Wordmove::Guardian.new(cli_options:, action: :pull), + logger:, + photocopier: Photocopier::FTP + .new(ftp_opts) + .tap { |c| c.logger = logger } + ).reduce(actions) + end + + def self.actions + [ + Wordmove::Actions::RunBeforePullHook, # Will fail and warn the user + Wordmove::Actions::FilterAndSetupTasksToRun, + reduce_if( + ->(ctx) { ctx.wordpress_task }, + [Wordmove::Actions::Ftp::PullWordpress] + ), + iterate(:folder_tasks, [Wordmove::Actions::Ftp::GetDirectory]), + reduce_if(->(ctx) { ctx.database_task }, + [ + Wordmove::Actions::SetupContextForDb, + Wordmove::Actions::BackupLocalDb, + Wordmove::Actions::Ftp::DownloadRemoteDb, + Wordmove::Actions::AdaptRemoteDb, + Wordmove::Actions::Ftp::CleanupAfterAdapt + ]), + Wordmove::Actions::RunAfterPullHook # Will fail and warn the user + ] + end + end + end + end +end diff --git a/lib/wordmove/organizers/ftp/push.rb b/lib/wordmove/organizers/ftp/push.rb new file mode 100644 index 00000000..41300e96 --- /dev/null +++ b/lib/wordmove/organizers/ftp/push.rb @@ -0,0 +1,56 @@ +module Wordmove + module Organizers + module Ftp + class Push + extend ::LightService::Organizer + include Wordmove::Actions::Helpers + include Wordmove::Actions::Ftp::Helpers + + # Can't use keyword arguments since LightService still has some problems with modern + # ruby syntax: https://github.com/adomokos/light-service/pull/224 + def self.call(cli_options, movefile) + logger = Logger.new($stdout, movefile.secrets).tap { |l| l.level = Logger::DEBUG } + remote_options = movefile.options[movefile.environment] + ftp_opts = ftp_options(remote_options:) + + LightService::Configuration.logger = ::Logger.new($stdout) if cli_options[:debug] + + with( + cli_options:, + global_options: movefile.options[:global], + local_options: movefile.options[:local], + remote_options:, + movefile:, + guardian: Wordmove::Guardian.new(cli_options:, action: :push), + logger:, + photocopier: Photocopier::FTP + .new(ftp_opts) + .tap { |c| c.logger = logger } + ).reduce(actions) + end + + def self.actions + [ + Wordmove::Actions::RunBeforePushHook, # Will fail and warn the user + Wordmove::Actions::FilterAndSetupTasksToRun, + reduce_if( + ->(ctx) { ctx.wordpress_task }, + [Wordmove::Actions::Ftp::PushWordpress] + ), + iterate(:folder_tasks, [Wordmove::Actions::Ftp::PutDirectory]), + reduce_if(->(ctx) { ctx.database_task }, + [ + Wordmove::Actions::SetupContextForDb, + Wordmove::Actions::Ftp::DownloadRemoteDb, + Wordmove::Actions::Ftp::BackupRemoteDb, + Wordmove::Actions::AdaptLocalDb, + Wordmove::Actions::Ftp::PutAndImportDumpRemotely, + Wordmove::Actions::Ftp::CleanupAfterAdapt + ]), + Wordmove::Actions::RunAfterPushHook # Will fail and warn the user + ] + end + end + end + end +end diff --git a/lib/wordmove/organizers/ssh/pull.rb b/lib/wordmove/organizers/ssh/pull.rb new file mode 100644 index 00000000..981eb93a --- /dev/null +++ b/lib/wordmove/organizers/ssh/pull.rb @@ -0,0 +1,55 @@ +module Wordmove + module Organizers + module Ssh + class Pull + extend ::LightService::Organizer + include Wordmove::Actions::Helpers + include Wordmove::Actions::Ssh::Helpers + + # Can't use keyword arguments since LightService still has some problems with modern + # ruby syntax: https://github.com/adomokos/light-service/pull/224 + def self.call(cli_options, movefile) + logger = Logger.new($stdout, movefile.secrets).tap { |l| l.level = Logger::DEBUG } + remote_options = movefile.options[movefile.environment] + ssh_opts = ssh_options(remote_options:, simulate: cli_options[:simulate]) + + LightService::Configuration.logger = ::Logger.new($stdout) if cli_options[:debug] + + with( + cli_options:, + global_options: movefile.options[:global], + local_options: movefile.options[:local], + remote_options:, + movefile:, + guardian: Wordmove::Guardian.new(cli_options:, action: :pull), + logger:, + photocopier: Photocopier::SSH + .new(ssh_opts) + .tap { |c| c.logger = logger } + ).reduce(actions) + end + + def self.actions + [ + Wordmove::Actions::RunBeforePullHook, + Wordmove::Actions::FilterAndSetupTasksToRun, + reduce_if( + ->(ctx) { ctx.wordpress_task }, + [Wordmove::Actions::Ssh::PullWordpress] + ), + iterate(:folder_tasks, [Wordmove::Actions::Ssh::GetDirectory]), + reduce_if(->(ctx) { ctx.database_task }, + [ + Wordmove::Actions::SetupContextForDb, + Wordmove::Actions::BackupLocalDb, + Wordmove::Actions::Ssh::DownloadRemoteDb, + Wordmove::Actions::AdaptRemoteDb, + Wordmove::Actions::Ssh::CleanupAfterAdapt + ]), + Wordmove::Actions::RunAfterPullHook + ] + end + end + end + end +end diff --git a/lib/wordmove/organizers/ssh/push.rb b/lib/wordmove/organizers/ssh/push.rb new file mode 100644 index 00000000..783b2428 --- /dev/null +++ b/lib/wordmove/organizers/ssh/push.rb @@ -0,0 +1,56 @@ +module Wordmove + module Organizers + module Ssh + class Push + extend ::LightService::Organizer + include Wordmove::Actions::Helpers + include Wordmove::Actions::Ssh::Helpers + + # Can't use keyword arguments since LightService still has some problems with modern + # ruby syntax: https://github.com/adomokos/light-service/pull/224 + def self.call(cli_options, movefile) + logger = Logger.new($stdout, movefile.secrets).tap { |l| l.level = Logger::DEBUG } + remote_options = movefile.options[movefile.environment] + ssh_opts = ssh_options(remote_options:, simulate: cli_options[:simulate]) + + LightService::Configuration.logger = ::Logger.new($stdout) if cli_options[:debug] + + with( + cli_options:, + global_options: movefile.options[:global], + local_options: movefile.options[:local], + remote_options:, + movefile:, + guardian: Wordmove::Guardian.new(cli_options:, action: :push), + logger:, + photocopier: Photocopier::SSH + .new(ssh_opts) + .tap { |c| c.logger = logger } + ).reduce(actions) + end + + def self.actions + [ + Wordmove::Actions::RunBeforePushHook, + Wordmove::Actions::FilterAndSetupTasksToRun, + reduce_if( + ->(ctx) { ctx.wordpress_task }, + [Wordmove::Actions::Ssh::PushWordpress] + ), + iterate(:folder_tasks, [Wordmove::Actions::Ssh::PutDirectory]), + reduce_if(->(ctx) { ctx.database_task }, + [ + Wordmove::Actions::SetupContextForDb, + Wordmove::Actions::Ssh::DownloadRemoteDb, + Wordmove::Actions::Ssh::BackupRemoteDb, + Wordmove::Actions::AdaptLocalDb, + Wordmove::Actions::Ssh::PutAndImportDumpRemotely, + Wordmove::Actions::Ssh::CleanupAfterAdapt + ]), + Wordmove::Actions::RunAfterPushHook + ] + end + end + end + end +end diff --git a/lib/wordmove/sql_adapter/default.rb b/lib/wordmove/sql_adapter/default.rb deleted file mode 100644 index 9736ef09..00000000 --- a/lib/wordmove/sql_adapter/default.rb +++ /dev/null @@ -1,68 +0,0 @@ -module Wordmove - module SqlAdapter - class Default - attr_writer :sql_content - attr_reader :sql_path, :source_config, :dest_config - - def initialize(sql_path, source_config, dest_config) - @sql_path = sql_path - @source_config = source_config - @dest_config = dest_config - end - - def sql_content - @sql_content ||= File.open(sql_path).read - end - - def adapt! - replace_vhost! - replace_wordpress_path! - write_sql! - end - - def replace_vhost! - source_vhost = source_config[:vhost] - dest_vhost = dest_config[:vhost] - replace_field!(source_vhost, dest_vhost) - end - - def replace_wordpress_path! - source_path = source_config[:wordpress_absolute_path] || source_config[:wordpress_path] - dest_path = dest_config[:wordpress_absolute_path] || dest_config[:wordpress_path] - replace_field!(source_path, dest_path) - end - - def replace_field!(source_field, dest_field) - return false unless source_field && dest_field - - serialized_replace!(source_field, dest_field) - simple_replace!(source_field, dest_field) - end - - def serialized_replace!(source_field, dest_field) - length_delta = source_field.length - dest_field.length - - sql_content.gsub!(/s:(\d+):([\\]*['"])(.*?)\2;/) do |_| - length = Regexp.last_match(1).to_i - delimiter = Regexp.last_match(2) - string = Regexp.last_match(3) - - string.gsub!(/#{Regexp.escape(source_field)}/) do |_| - length -= length_delta - dest_field - end - - %(s:#{length}:#{delimiter}#{string}#{delimiter};) - end - end - - def simple_replace!(source_field, dest_field) - sql_content.gsub!(source_field, dest_field) - end - - def write_sql! - File.open(sql_path, 'w') { |f| f.write(sql_content) } - end - end - end -end diff --git a/lib/wordmove/sql_adapter/wpcli.rb b/lib/wordmove/sql_adapter/wpcli.rb deleted file mode 100644 index f69ddca4..00000000 --- a/lib/wordmove/sql_adapter/wpcli.rb +++ /dev/null @@ -1,54 +0,0 @@ -module Wordmove - module SqlAdapter - class Wpcli - attr_accessor :sql_content - attr_reader :from, :to, :local_path - - def initialize(source_config, dest_config, config_key, local_path) - @from = source_config[config_key] - @to = dest_config[config_key] - @local_path = local_path - end - - def command - unless wp_in_path? - raise UnmetPeerDependencyError, "WP-CLI is not installed or not in your $PATH" - end - - opts = [ - "--path=#{cli_config_path}", - from, - to, - "--quiet", - "--skip-columns=guid", - "--all-tables", - "--allow-root" - ] - - "wp search-replace #{opts.join(' ')}" - end - - private - - def wp_in_path? - system('which wp > /dev/null 2>&1') - end - - def cli_config_path - load_from_yml || load_from_cli || local_path - end - - def load_from_yml - cli_config_path = File.join(local_path, "wp-cli.yml") - return unless File.exist?(cli_config_path) - - YAML.load_file(cli_config_path).with_indifferent_access["path"] - end - - def load_from_cli - cli_config = JSON.parse(`wp cli param-dump --with-values`, symbolize_names: true) - cli_config.dig(:path, :current) - end - end - end -end diff --git a/lib/wordmove/version.rb b/lib/wordmove/version.rb index 903d04b4..80555e63 100644 --- a/lib/wordmove/version.rb +++ b/lib/wordmove/version.rb @@ -1,3 +1,3 @@ module Wordmove - VERSION = "5.2.2".freeze + VERSION = '6.0.0.alpha.8'.freeze end diff --git a/lib/wordmove/wordpress_directory.rb b/lib/wordmove/wordpress_directory.rb index 4f539e2c..af368c10 100644 --- a/lib/wordmove/wordpress_directory.rb +++ b/lib/wordmove/wordpress_directory.rb @@ -1,13 +1,21 @@ -require 'wordmove/wordpress_directory/path' - class WordpressDirectory - attr_accessor :type, :options + attr_reader :folder, :options - def initialize(type, options) - @type = type + def initialize(folder, options) + @folder = folder @options = options end + module Path + WP_CONTENT = :wp_content + WP_CONFIG = :wp_config + PLUGINS = :plugins + MU_PLUGINS = :mu_plugins + THEMES = :themes + UPLOADS = :uploads + LANGUAGES = :languages + end + DEFAULT_PATHS = { Path::WP_CONTENT => 'wp-content', Path::WP_CONFIG => 'wp-config.php', @@ -31,11 +39,71 @@ def url(*args) end def relative_path(*args) - path = if options[:paths] && options[:paths][type] - options[:paths][type] + path = if options[:paths] && options[:paths][folder] + options[:paths][folder] else - DEFAULT_PATHS[type] + DEFAULT_PATHS[folder] end File.join(path, *args) end + + module RemoteHelperMethods + extend ActiveSupport::Concern + + class_methods do + def remote_wp_content_dir(remote_options:) + WordpressDirectory.new(:wp_content, remote_options) + end + + def remote_plugins_dir(remote_options:) + WordpressDirectory.new(:plugins, remote_options) + end + + def remote_mu_plugins_dir(remote_options:) + WordpressDirectory.new(:mu_plugins, remote_options) + end + + def remote_themes_dir(remote_options:) + WordpressDirectory.new(:themes, remote_options) + end + + def remote_uploads_dir(remote_options:) + WordpressDirectory.new(:uploads, remote_options) + end + + def remote_languages_dir(remote_options:) + WordpressDirectory.new(:languages, remote_options) + end + end + end + + module LocalHelperMethods + extend ActiveSupport::Concern + + class_methods do + def local_wp_content_dir(local_options:) + WordpressDirectory.new(:wp_content, local_options) + end + + def local_plugins_dir(local_options:) + WordpressDirectory.new(:plugins, local_options) + end + + def local_mu_plugins_dir(local_options:) + WordpressDirectory.new(:mu_plugins, local_options) + end + + def local_themes_dir(local_options:) + WordpressDirectory.new(:themes, local_options) + end + + def local_uploads_dir(local_options:) + WordpressDirectory.new(:uploads, local_options) + end + + def local_languages_dir(local_options:) + WordpressDirectory.new(:languages, local_options) + end + end + end end diff --git a/lib/wordmove/wordpress_directory/path.rb b/lib/wordmove/wordpress_directory/path.rb deleted file mode 100644 index 44aa0cc4..00000000 --- a/lib/wordmove/wordpress_directory/path.rb +++ /dev/null @@ -1,11 +0,0 @@ -class WordpressDirectory - module Path - WP_CONTENT = :wp_content - WP_CONFIG = :wp_config - PLUGINS = :plugins - MU_PLUGINS = :mu_plugins - THEMES = :themes - UPLOADS = :uploads - LANGUAGES = :languages - end -end diff --git a/lib/wordmove/wpcli.rb b/lib/wordmove/wpcli.rb new file mode 100644 index 00000000..fc316b45 --- /dev/null +++ b/lib/wordmove/wpcli.rb @@ -0,0 +1,85 @@ +module Wordmove + # This class is a sort of mini-wrapper around the wp-cli executable. + # It's responsible to run or produce wp-cli commands. + module WpcliHelpers + extend ActiveSupport::Concern + + included do + private_class_method :load_from_wpcli, :load_from_yml + end + + class_methods do # rubocop:disable Metrics/BlockLength + # Checks if `wp` command is in your shell `$PATH` + # + # @return [Boolean] + # @!scope class + def wp_in_path? + system('which wp > /dev/null 2>&1') + end + + # Returns the wordpress path from wp-cli (with precedence) or from movefile + # + # It's intended to be used from a +LightService::Action+, but it also supports + # to receive a path as argument. If the argument is not a LightService::Context + # then it will be treated as a path. + # The path passed as argument should be the wordpress installation path, but it's + # not strictly mandatory: the method will try to load a wpcli's YAML config + # from that path, so you can potentially use it with any path + # + # @param context [LightService::Context|String] The context of an action or a path as string + # @return [String] + # @!scope class + def wpcli_config_path(context_or_path) + context = if context_or_path.is_a? LightService::Context + context_or_path + else + # We need to make it quack like a duck in order to be + # backward compatible with previous code + { local_options: { wordpress_path: context_or_path } } + end + + load_from_yml(context) || load_from_wpcli || context.dig(:local_options, :wordpress_path) + end + + # If wordpress installation brings a `wp-cli.yml` file in its root folder, + # reads it and returns the `path` yaml key configured there + # + # @return [String, nil] The `path` configuration or `nil` + # @!scope class + # @!visibility private + def load_from_yml(context) + config_path = context.dig(:local_options, :wordpress_path) || '.' + yml_path = File.join(config_path, 'wp-cli.yml') + + return unless File.exist?(yml_path) + + YAML.load_file(yml_path).with_indifferent_access['path'] + end + + # Returns the wordpress path as per wp-cli configuration. + # A possible scenario is that the used wpcli command could return an empty + # string: we thus rescue parse errors in order to ignore this config source + # + # @return [String, nil] The wordpress path as per wp-cli configuration or nil + # @!scope class + # @!visibility private + def load_from_wpcli + wpcli_config = JSON.parse( + `wp cli param-dump --with-values --allow-root`, + symbolize_names: true + ) + wpcli_config.dig(:path, :current) + rescue JSON::ParserError => _e + nil + end + end + + def self.get_option(option, config_path:) + `wp option get #{option} --allow-root --path=#{config_path}`.chomp + end + + def self.get_config(config, config_path:) + `wp config get #{config} --allow-root --path=#{config_path}`.chomp + end + end +end diff --git a/spec/actions/adapt_local_db_spec.rb b/spec/actions/adapt_local_db_spec.rb new file mode 100644 index 00000000..b3445354 --- /dev/null +++ b/spec/actions/adapt_local_db_spec.rb @@ -0,0 +1,74 @@ +require 'spec_helper' + +# I know these tests are very weak. I don't know how to make them sturdier +# and having them is better than nothing :) +describe Wordmove::Actions::AdaptLocalDb do + let(:context) do + OrganizerContextFactory.make_for(described_class, :push, cli_options: { db: true }) + end + + let(:stubbed_actions) do + [ + Wordmove::Actions::Ssh::DownloadRemoteDb, + Wordmove::Actions::Ssh::BackupRemoteDb + ] + end + + before do + silence_logger! + # Note we're stubbing subsequent actions from organizer. + # This stubs could be useful for using spies on classes. + stubbed_actions.each do |action| + stub_action(action) + end + + allow(described_class) + .to receive(:system) + .and_return(true) + end + + it 'works like it should' do + result = described_class.execute( + context + ) + + expect(result).to be_success + end + + context 'when --no-adapt' do + let(:context) do + OrganizerContextFactory.make_for( + described_class, :push, cli_options: { db: true, no_adapt: true } + ) + end + + it 'works like it should' do + result = described_class.execute( + context + ) + + aggregate_failures 'testing sub-actions' do + expect(result).to be_success + expect(described_class) + .to_not have_received(:system) + .with(/wp search-replace/, exception: true) + end + end + end + + context '.search_replace_command' do + it 'returns the expected command' do + expect(subject.class.search_replace_command(context, :wordpress_path)) + .to eq('wp search-replace --path=~/dev/sites/your_site "\A~/dev/sites/your_site\Z" ' \ + '"/var/www/your_site" --regex-delimiter="|" --regex --precise --quiet ' \ + '--skip-columns=guid --all-tables --allow-root') + end + + context 'when wrong config_key is passed' do + it 'raises an error' do + expect { subject.class.search_replace_command(context, :wrong) } + .to raise_error(ArgumentError) + end + end + end +end diff --git a/spec/actions/backup_local_db_spec.rb b/spec/actions/backup_local_db_spec.rb new file mode 100644 index 00000000..7c02548a --- /dev/null +++ b/spec/actions/backup_local_db_spec.rb @@ -0,0 +1,77 @@ +require 'spec_helper' + +# I know these tests are very weak. I don't know how to make them sturdier +# and having them is better than nothing :) +describe Wordmove::Actions::BackupLocalDb do + let(:context) do + OrganizerContextFactory.make_for(described_class, :pull, cli_options: { db: true }) + end + + let(:stubbed_actions) do + [ + Wordmove::Actions::Ssh::DownloadRemoteDb + ] + end + + before do + silence_logger! + # Note we're stubbing subsequent actions from organizer. + # This stubs could be useful for using spies on classes. + stubbed_actions.each do |action| + stub_action(action) + end + end + + it 'works like it should' do + allow(described_class).to receive(:system).and_return(true) + + result = described_class.execute( + context + ) + + expect(result).to be_success + end + + context 'when system dump command fails' do + before do + allow(described_class) + .to receive(:system) + .with(/wp db export/, exception: true) + .and_raise(RuntimeError.new('Foo')) + end + + it 'fails and reports the error' do + result = described_class.execute( + context + ) + + aggregate_failures do + expect(result).to be_failure + expect(result.message).to match('Foo') + end + end + end + + context 'when system compress command fails' do + before do + allow(described_class) + .to receive(:system) + .with(/wp db export/, exception: true) + + allow(described_class) + .to receive(:system) + .with(/gzip/, exception: true) + .and_raise(RuntimeError.new('Bar')) + end + it 'fails and reports the error' do + result = described_class.execute( + context + ) + + aggregate_failures do + expect(result).to be_failure + expect(result.message).to match('Bar') + end + end + end +end diff --git a/spec/actions/get_file_spec.rb b/spec/actions/get_file_spec.rb new file mode 100644 index 00000000..0692ec6e --- /dev/null +++ b/spec/actions/get_file_spec.rb @@ -0,0 +1,38 @@ +require 'spec_helper' + +describe Wordmove::Actions::GetFile do + let(:context) do + OrganizerContextFactory.make_for(described_class, :push) + end + + before do + silence_logger! + end + + it 'works like it should' do + allow(context[:photocopier]).to receive(:get).and_return(true) + + result = described_class.execute( + photocopier: context.fetch(:photocopier), + logger: context.fetch(:logger), + cli_options: context.fetch(:cli_options), + command_args: %w[foo bar] + ) + expect(result).to be_success + end + + context 'when it fails due to photocopier error' do + it 'set the expected error message into result' do + allow(context[:photocopier]).to receive(:get).and_return(false) + + result = described_class.execute( + photocopier: context.fetch(:photocopier), + logger: context.fetch(:logger), + cli_options: context.fetch(:cli_options), + command_args: %w[foo bar] + ) + expect(result).to be_failure + expect(result.message).to eq('Failed to download file: foo') + end + end +end diff --git a/spec/actions/put_file_spec.rb b/spec/actions/put_file_spec.rb new file mode 100644 index 00000000..a15ec337 --- /dev/null +++ b/spec/actions/put_file_spec.rb @@ -0,0 +1,38 @@ +require 'spec_helper' + +describe Wordmove::Actions::PutFile do + let(:context) do + OrganizerContextFactory.make_for(described_class, :push) + end + + before do + silence_logger! + end + + it 'works like it should' do + allow(context[:photocopier]).to receive(:put).and_return(true) + + result = described_class.execute( + photocopier: context.fetch(:photocopier), + logger: context.fetch(:logger), + cli_options: context.fetch(:cli_options), + command_args: %w[bar foo] + ) + expect(result).to be_success + end + + context 'when it fails due to photocopier error' do + it 'set the expected error message into result' do + allow(context[:photocopier]).to receive(:put).and_return(false) + + result = described_class.execute( + photocopier: context.fetch(:photocopier), + logger: context.fetch(:logger), + cli_options: context.fetch(:cli_options), + command_args: %w[bar foo] + ) + expect(result).to be_failure + expect(result.message).to eq('Failed to upload file: bar') + end + end +end diff --git a/spec/actions/run_after_pull_hook_spec.rb b/spec/actions/run_after_pull_hook_spec.rb new file mode 100644 index 00000000..b91fd0bb --- /dev/null +++ b/spec/actions/run_after_pull_hook_spec.rb @@ -0,0 +1,27 @@ +require 'spec_helper' + +describe Wordmove::Actions::RunAfterPullHook do + let(:context) do + OrganizerContextFactory.make_for(described_class, :pull) + end + + before do + silence_logger! + end + + it 'works like it should' do + expect(Wordmove::Hook).to receive(:run).with( + :pull, + :after, + movefile: context.fetch(:movefile), + simulate: false + ) + + result = described_class.execute( + movefile: context.fetch(:movefile), + cli_options: context.fetch(:cli_options) + ) + + expect(result).to be_success + end +end diff --git a/spec/actions/run_after_push_hook_spec.rb b/spec/actions/run_after_push_hook_spec.rb new file mode 100644 index 00000000..bc0055d2 --- /dev/null +++ b/spec/actions/run_after_push_hook_spec.rb @@ -0,0 +1,27 @@ +require 'spec_helper' + +describe Wordmove::Actions::RunAfterPushHook do + let(:context) do + OrganizerContextFactory.make_for(described_class, :push) + end + + before do + silence_logger! + end + + it 'works like it should' do + expect(Wordmove::Hook).to receive(:run).with( + :push, + :after, + movefile: context.fetch(:movefile), + simulate: false + ) + + result = described_class.execute( + movefile: context.fetch(:movefile), + cli_options: context.fetch(:cli_options) + ) + + expect(result).to be_success + end +end diff --git a/spec/actions/run_before_pull_hook_spec.rb b/spec/actions/run_before_pull_hook_spec.rb new file mode 100644 index 00000000..2332af9c --- /dev/null +++ b/spec/actions/run_before_pull_hook_spec.rb @@ -0,0 +1,27 @@ +require 'spec_helper' + +describe Wordmove::Actions::RunBeforePullHook do + let(:context) do + OrganizerContextFactory.make_for(described_class, :pull) + end + + before do + silence_logger! + end + + it 'works like it should' do + expect(Wordmove::Hook).to receive(:run).with( + :pull, + :before, + movefile: context.fetch(:movefile), + simulate: false + ) + + result = described_class.execute( + movefile: context.fetch(:movefile), + cli_options: context.fetch(:cli_options) + ) + + expect(result).to be_success + end +end diff --git a/spec/actions/run_before_push_hook_spec.rb b/spec/actions/run_before_push_hook_spec.rb new file mode 100644 index 00000000..0be071b4 --- /dev/null +++ b/spec/actions/run_before_push_hook_spec.rb @@ -0,0 +1,27 @@ +require 'spec_helper' + +describe Wordmove::Actions::RunBeforePushHook do + let(:context) do + OrganizerContextFactory.make_for(described_class, :push) + end + + before do + silence_logger! + end + + it 'works like it should' do + expect(Wordmove::Hook).to receive(:run).with( + :push, + :before, + movefile: context.fetch(:movefile), + simulate: false + ) + + result = described_class.execute( + movefile: context.fetch(:movefile), + cli_options: context.fetch(:cli_options) + ) + + expect(result).to be_success + end +end diff --git a/spec/actions/run_local_command_spec.rb b/spec/actions/run_local_command_spec.rb new file mode 100644 index 00000000..94256292 --- /dev/null +++ b/spec/actions/run_local_command_spec.rb @@ -0,0 +1,50 @@ +require 'spec_helper' + +describe Wordmove::Actions::RunLocalCommand do + let(:context) do + OrganizerContextFactory.make_for(described_class, :push) + end + let(:good_command) { 'echo "Test if echo works"' } + let(:bad_command) { 'exit 1' } + + before do + silence_logger! + end + + it 'works like it should' do + result = described_class.execute( + cli_options: context.fetch(:cli_options), + logger: context.fetch(:logger), + command: good_command + ) + + expect(result).to be_success + end + + context 'when it fails' do + it 'sets the expected error message into result' do + result = described_class.execute( + cli_options: context.fetch(:cli_options), + logger: context.fetch(:logger), + command: bad_command + ) + + expect(result).to be_failure + expect(result.message).to match(/Local command status reports an error/) + end + end + + context 'when `--simulate`' do + it 'does not execute the command and result is successful' do + context[:cli_options][:simulate] = true + + result = described_class.execute( + cli_options: context.fetch(:cli_options), + logger: context.fetch(:logger), + command: bad_command + ) + + expect(result).to be_success + end + end +end diff --git a/spec/actions/run_remote_command_spec.rb b/spec/actions/run_remote_command_spec.rb new file mode 100644 index 00000000..4085248d --- /dev/null +++ b/spec/actions/run_remote_command_spec.rb @@ -0,0 +1,59 @@ +require 'spec_helper' + +describe Wordmove::Actions::Ssh::RunRemoteCommand do + let(:context) do + OrganizerContextFactory.make_for(described_class, :push) + end + let(:good_command) { 'echo "Test if echo works"' } + let(:bad_command) { 'exit 1' } + + before do + silence_logger! + allow(context[:photocopier]) + .to receive(:exec!).with(good_command) + .and_return([nil, nil, 0]) + allow(context[:photocopier]) + .to receive(:exec!).with(bad_command) + .and_return([nil, 'Evil error', 666]) + end + + it 'works like it should' do + result = described_class.execute( + photocopier: context.fetch(:photocopier), + cli_options: context.fetch(:cli_options), + logger: context.fetch(:logger), + command: good_command + ) + + expect(result).to be_success + end + + context 'when it fails' do + it 'sets the expected error message into result' do + result = described_class.execute( + photocopier: context.fetch(:photocopier), + cli_options: context.fetch(:cli_options), + logger: context.fetch(:logger), + command: bad_command + ) + + expect(result).to be_failure + expect(result.message).to eq('Error code 666 returned by command exit 1: Evil error') + end + end + + context 'when `--simulate`' do + it 'does not execute the command and result is successful' do + context[:cli_options][:simulate] = true + + result = described_class.execute( + photocopier: context.fetch(:photocopier), + cli_options: context.fetch(:cli_options), + logger: context.fetch(:logger), + command: bad_command + ) + + expect(result).to be_success + end + end +end diff --git a/spec/actions/setup_context_for_db_spec.rb b/spec/actions/setup_context_for_db_spec.rb new file mode 100644 index 00000000..7da67228 --- /dev/null +++ b/spec/actions/setup_context_for_db_spec.rb @@ -0,0 +1,31 @@ +require 'spec_helper' + +describe Wordmove::Actions::SetupContextForDb do + let(:stubbed_actions) do + [ + Wordmove::Actions::Ssh::BackupRemoteDb, + Wordmove::Actions::AdaptLocalDb, + Wordmove::Actions::Ssh::PutAndImportDumpRemotely, + Wordmove::Actions::Ssh::CleanupAfterAdapt + ] + end + + before do + # Note we're stubbing subsequent actions from organizer. + # This stubs could be useful for using spies on classes. + stubbed_actions.each do |action| + stub_action(action) + end + end + + context 'when is required to push/pull db' do + let(:context) do + OrganizerContextFactory.make_for(described_class, :push, cli_options: { db: true }) + end + + it 'execute remaining actions' do + result = described_class.execute(context) + expect(result.db_paths).to be DbPathsConfig + end + end +end diff --git a/spec/cli_spec.rb b/spec/cli_spec.rb index fc142be4..99707123 100644 --- a/spec/cli_spec.rb +++ b/spec/cli_spec.rb @@ -1,57 +1,59 @@ require 'spec_helper' describe Wordmove::CLI do - let(:cli) { described_class.new } - let(:deployer) { double("deployer") } + let(:cli) { Dry::CLI.new(Wordmove::CLI::Commands) } let(:options) { {} } - before do - allow(Wordmove::Deployer::Base).to receive(:deployer_for).with(options).and_return(deployer) - end + context '#init' do + it 'delagates the command to the Movefile Generator' do + expect(Wordmove::Generators::Movefile).to receive(:generate) - context "#init" do - it "delagates the command to the Movefile Generator" do - expect(Wordmove::Generators::Movefile).to receive(:start) - cli.invoke(:init, [], options) + silence_stream($stdout) do + cli.call(arguments: %w[init]) + end end end - context "#doctor" do - it "delagates the command to Doctor class" do + context '#doctor' do + it 'delagates the command to Doctor class' do expect(Wordmove::Doctor).to receive(:start) - cli.invoke(:doctor, [], options) + cli.call(arguments: %w[doctor]) end end - context "#pull" do - context "without a movefile" do - it "it rescues from a MovefileNotFound exception" do - expect { cli.invoke(:pull, []) }.to raise_error SystemExit + context '#pull' do + context 'without a movefile' do + it 'it rescues from a MovefileNotFound exception' do + expect { cli.call(arguments: %w[pull]) }.to raise_error SystemExit end end end - context "#list" do - subject { cli.invoke(:list, []) } + context '#list' do + subject do + silence_stream($stdout) do + cli.call(arguments: %w[list]) + end + end let(:list_class) { Wordmove::EnvironmentsList } - # Werdmove::EnvironmentsList.print should be called - it "delagates the command to EnvironmentsList class" do + + it 'delagates the command to EnvironmentsList class' do expect(list_class).to receive(:print) subject end context 'without a valid movefile' do - context "no movefile" do + context 'no movefile' do it { expect { subject }.to raise_error SystemExit } end - context "syntax error movefile " do + context 'syntax error movefile ' do before do # Ref. https://github.com/ruby/psych/blob/master/lib/psych/syntax_error.rb#L8 # Arguments for initialization: file, line, col, offset, problem, context args = [nil, 1, 5, 0, - "found character that cannot start any token", - "while scanning for the next token"] + 'found character that cannot start any token', + 'while scanning for the next token'] allow(list_class).to receive(:print).and_raise(Psych::SyntaxError.new(*args)) end @@ -59,87 +61,28 @@ end end - context "with a movefile" do - subject { cli.invoke(:list, [], options) } + context 'with a movefile' do let(:options) { { config: movefile_path_for('Movefile') } } - it "invoke list without error" do - expect { subject }.not_to raise_error + subject { cli.call(arguments: ['list', "--config=#{movefile_path_for('Movefile')}"]) } + + it 'invoke list without error' do + silence_stream($stdout) do + expect { subject }.not_to raise_error + end end end end - context "#push" do - context "without a movefile" do - it "it rescues from a MovefileNotFound exception" do - expect { cli.invoke(:pull, []) }.to raise_error SystemExit + context '#push' do + context 'without a movefile' do + it 'it rescues from a MovefileNotFound exception' do + expect { cli.call(arguments: %w[push]) }.to raise_error SystemExit end end end - context "--all" do + context '--all' do let(:options) { { all: true, config: movefile_path_for('Movefile') } } let(:ordered_components) { %i[wordpress uploads themes plugins mu_plugins languages db] } - - context "#pull" do - it "invokes commands in the right order" do - ordered_components.each do |component| - expect(deployer).to receive("pull_#{component}") - end - cli.invoke(:pull, [], options) - end - - context "with forbidden task" do - let(:options) { { all: true, config: movefile_path_for('with_forbidden_tasks') } } - - it "does not pull the forbidden task" do - expected_components = ordered_components - [:db] - - expected_components.each do |component| - expect(deployer).to receive("pull_#{component}") - end - expect(deployer).to_not receive("pull_db") - - silence_stream(STDOUT) { cli.invoke(:pull, [], options) } - end - end - end - - context "#push" do - it "invokes commands in the right order" do - ordered_components.each do |component| - expect(deployer).to receive("push_#{component}") - end - cli.invoke(:push, [], options) - end - - context "with forbidden task" do - let(:options) { { all: true, config: movefile_path_for('with_forbidden_tasks') } } - - it "does not push the forbidden task" do - expected_components = ordered_components - [:db] - - expected_components.each do |component| - expect(deployer).to receive("push_#{component}") - end - expect(deployer).to_not receive("push_db") - - silence_stream(STDOUT) { cli.invoke(:push, [], options) } - end - end - end - - context "excluding one of the components" do - it "does not invoke the escluded component" do - excluded_component = ordered_components.pop - options[excluded_component] = false - - ordered_components.each do |component| - expect(deployer).to receive("push_#{component}") - end - expect(deployer).to_not receive("push_#{excluded_component}") - - cli.invoke(:push, [], options) - end - end end end diff --git a/spec/deployer/base_spec.rb b/spec/deployer/base_spec.rb deleted file mode 100644 index b5170497..00000000 --- a/spec/deployer/base_spec.rb +++ /dev/null @@ -1,153 +0,0 @@ -describe Wordmove::Deployer::Base do - let(:options) do - { config: movefile_path_for("multi_environments") } - end - context ".deployer_for" do - context "with more then one environment, but none chosen" do - it "raises an exception" do - expect { described_class.deployer_for(options) } - .to raise_exception(Wordmove::UndefinedEnvironment) - end - end - - context "with more then one environment, but invalid chosen" do - it "raises an exception" do - options[:environment] = "doesnotexist" - options[:simulate] = true - - expect { described_class.deployer_for(options) } - .to raise_exception(Wordmove::UndefinedEnvironment) - end - end - - context "with ftp remote connection" do - it "returns an instance of FTP deployer" do - options[:environment] = "production" - expect(described_class.deployer_for(options)).to be_a Wordmove::Deployer::FTP - end - end - - context "with ssh remote connection" do - before do - options[:environment] = "staging" - end - - it "returns an instance of Ssh::Default deployer" do - expect(described_class.deployer_for(options)) - .to be_a Wordmove::Deployer::Ssh::DefaultSqlAdapter - end - - context "when Movefile is configured with 'wpcli' sql_adapter" do - it "returns an instance of Ssh::WpcliSqlAdapter deployer" do - options[:config] = movefile_path_for('multi_environments_wpcli_sql_adapter') - - expect(described_class.deployer_for(options)) - .to be_a Wordmove::Deployer::Ssh::WpcliSqlAdapter - end - end - - context "with --simulate" do - it "rsync_options will contain --dry-run" do - options[:environment] = "staging" - options[:simulate] = true - copier = double(:copier) - - allow(copier).to receive(:logger=) - - expect(Photocopier::SSH).to receive(:new) - .with(hash_including(rsync_options: '--dry-run')) - .and_return(copier) - - described_class.deployer_for(options) - end - end - end - - context "with unknown type of connection " do - it "raises an exception" do - options[:environment] = "missing_protocol" - expect { described_class.deployer_for(options) }.to raise_error(Wordmove::NoAdapterFound) - end - end - end - - context "#mysql_dump_command" do - let(:deployer) { described_class.new(:dummy_env, options) } - - it "creates a valid mysqldump command" do - command = deployer.send( - :mysql_dump_command, - { - host: "localhost", - port: "8888", - user: "root", - password: "'\"$ciao", - name: "database_name", - mysqldump_options: "--max_allowed_packet=1G --no-create-db" - }, - "./mysql dump.sql" - ) - - expect(command).to eq( - [ - "mysqldump --host=localhost", - "--port=8888 --user=root --password=\\'\\\"\\$ciao", - "--result-file=\"./mysql dump.sql\"", - "--max_allowed_packet=1G --no-create-db database_name" - ].join(' ') - ) - end - end - - context "#mysql_import_command" do - let(:deployer) { described_class.new(:dummy_env, options) } - - it "creates a valid mysql import command" do - command = deployer.send( - :mysql_import_command, - "./my dump.sql", - host: "localhost", - port: "8888", - user: "root", - password: "'\"$ciao", - name: "database_name", - mysql_options: "--protocol=TCP" - ) - expect(command).to eq( - [ - "mysql --host=localhost --port=8888 --user=root", - "--password=\\'\\\"\\$ciao", - "--database=database_name", - "--protocol=TCP", - "--execute=\"SET autocommit=0;SOURCE ./my dump.sql;COMMIT\"" - ].join(" ") - ) - end - end - - context "#compress_command" do - let(:deployer) { described_class.new(:dummy_env, options) } - - it "cerates a valid gzip command" do - command = deployer.send( - :compress_command, - "dummy file.sql" - ) - - expect(command).to eq("gzip -9 -f \"dummy file.sql\"") - end - end - - context "#uncompress_command" do - let(:deployer) { described_class.new(:dummy_env, options) } - - it "creates a valid gunzip command" do - command = deployer.send( - :uncompress_command, - "dummy file.sql" - ) - - expect(command).to eq("gzip -d -f \"dummy file.sql\"") - end - end -end diff --git a/spec/doctor/movefile_spec.rb b/spec/doctor/movefile_spec.rb index e247ea88..86fda7d3 100644 --- a/spec/doctor/movefile_spec.rb +++ b/spec/doctor/movefile_spec.rb @@ -1,15 +1,15 @@ describe Wordmove::Doctor::Movefile do let(:movefile_name) { 'multi_environments' } - let(:movefile_dir) { "spec/fixtures/movefiles" } - let(:doctor) { Wordmove::Doctor::Movefile.new(movefile_name, movefile_dir) } + let(:movefile_dir) { 'spec/fixtures/movefiles' } + let(:doctor) { Wordmove::Doctor::Movefile.new({ config: movefile_name }, movefile_dir) } - context ".new" do - it "create an Hash representing the Movefile content" do + context '.new' do + it 'create an Hash representing the Movefile content' do expect(doctor.contents).to be_a(Hash) end end - context ".root_keys" do + context '.root_keys' do it "returns all the yml's root keys" do expected_root_keys = %i[ global @@ -22,12 +22,12 @@ expect(doctor.root_keys).to eq(expected_root_keys) end - context ".validate!" do - it "calls validation on each section of the actual movefile" do + context '.validate!' do + it 'calls validation on each section of the actual movefile' do expect(doctor).to receive(:validate_section).exactly(4).times expect_any_instance_of(Wordmove::Logger).to receive(:task).exactly(5).times - silence_stream(STDOUT) { doctor.validate! } + silence_stream($stdout) { doctor.validate! } end end end diff --git a/spec/doctor/mysql_spec.rb b/spec/doctor/mysql_spec.rb index cc2df569..889ce300 100644 --- a/spec/doctor/mysql_spec.rb +++ b/spec/doctor/mysql_spec.rb @@ -1,37 +1,41 @@ describe Wordmove::Doctor::Mysql do let(:movefile_name) { 'multi_environments' } - let(:movefile_dir) { "spec/fixtures/movefiles" } + let(:movefile_dir) { 'spec/fixtures/movefiles' } let(:doctor) { described_class.new(movefile_name, movefile_dir) } - context ".new" do - it "implements #check! method" do - expect_any_instance_of(described_class).to receive(:check!) + context '.new' do + before do + allow(doctor).to receive(:mysql_server_doctor).and_return true + allow(doctor).to receive(:mysql_database_doctor).and_return true + end + it 'implements #check! method' do + expect(doctor).to receive(:check!) - silence_stream(STDOUT) { doctor.check! } + silence_stream($stdout) { doctor.check! } end - it "calls mysql client check" do + it 'calls mysql client check' do expect(doctor).to receive(:mysql_client_doctor) - silence_stream(STDOUT) { doctor.check! } + silence_stream($stdout) { doctor.check! } end - it "calls mysqldump check" do + it 'calls mysqldump check' do expect(doctor).to receive(:mysqldump_doctor) - silence_stream(STDOUT) { doctor.check! } + silence_stream($stdout) { doctor.check! } end - it "calls mysql server check" do + it 'calls mysql server check' do expect(doctor).to receive(:mysql_server_doctor) - silence_stream(STDOUT) { doctor.check! } + silence_stream($stdout) { doctor.check! } end - it "calls mysql database check" do - # expect(doctor).to receive(:mysql_database_doctor) + it 'calls mysql database check' do + expect(doctor).to receive(:mysql_database_doctor) - silence_stream(STDOUT) { doctor.check! } + silence_stream($stdout) { doctor.check! } end end end diff --git a/spec/doctor/rsync_spec.rb b/spec/doctor/rsync_spec.rb index bc9034e3..59177ac8 100644 --- a/spec/doctor/rsync_spec.rb +++ b/spec/doctor/rsync_spec.rb @@ -1,11 +1,11 @@ describe Wordmove::Doctor::Rsync do let(:doctor) { described_class.new } - context ".new" do - it "implements #check! method" do + context '.new' do + it 'implements #check! method' do expect_any_instance_of(described_class).to receive(:check!) - silence_stream(STDOUT) { doctor.check! } + silence_stream($stdout) { doctor.check! } end end end diff --git a/spec/doctor/ssh_spec.rb b/spec/doctor/ssh_spec.rb index 1dc0fab4..4fc49938 100644 --- a/spec/doctor/ssh_spec.rb +++ b/spec/doctor/ssh_spec.rb @@ -1,11 +1,11 @@ describe Wordmove::Doctor::Ssh do let(:doctor) { described_class.new } - context ".new" do - it "implements #check! method" do + context '.new' do + it 'implements #check! method' do expect_any_instance_of(described_class).to receive(:check!) - silence_stream(STDOUT) { doctor.check! } + silence_stream($stdout) { doctor.check! } end end end diff --git a/spec/doctor/wpcli_spec.rb b/spec/doctor/wpcli_spec.rb index 90d4388f..2bdda4b2 100644 --- a/spec/doctor/wpcli_spec.rb +++ b/spec/doctor/wpcli_spec.rb @@ -1,11 +1,11 @@ describe Wordmove::Doctor::Wpcli do let(:doctor) { described_class.new } - context ".new" do - it "implements #check! method" do + context '.new' do + it 'implements #check! method' do expect_any_instance_of(described_class).to receive(:check!) - silence_stream(STDOUT) { doctor.check! } + silence_stream($stdout) { doctor.check! } end end end diff --git a/spec/docotor_spec.rb b/spec/doctor_spec.rb similarity index 94% rename from spec/docotor_spec.rb rename to spec/doctor_spec.rb index 82d2751e..5ca61145 100644 --- a/spec/docotor_spec.rb +++ b/spec/doctor_spec.rb @@ -1,6 +1,6 @@ describe Wordmove::Doctor do - context "#start" do - it "calls all movefile doctors" do + context '#start' do + it 'calls all movefile doctors' do movefile_doctor = double(:movefile_doctor) allow(Wordmove::Doctor::Movefile).to receive(:new).and_return(movefile_doctor) expect(movefile_doctor).to receive(:validate!).exactly(1).times diff --git a/spec/environments_list_spec.rb b/spec/environments_list_spec.rb index 85dbf46f..a67d381c 100644 --- a/spec/environments_list_spec.rb +++ b/spec/environments_list_spec.rb @@ -1,89 +1,86 @@ describe Wordmove::EnvironmentsList do let(:instance) { described_class.new(options) } - let(:options) { {} } + let(:options) { { config: movefile_path_for('multi_environments') } } - describe ".print" do + describe '.print' do subject { described_class.print(options) } - it "create new instance and call its #print" do + it 'create new instance and call its #print' do expect_any_instance_of(described_class).to receive(:print).once subject end end - describe ".new" do + describe '.new' do subject { instance } - it "created instance has logger" do + it 'created instance has logger' do expect(subject.respond_to?(:logger, true)).to be_truthy end - it "created instance has movefile" do + it 'created instance has movefile' do expect(subject.respond_to?(:movefile, true)).to be_truthy end end - describe "#print" do + describe '#print' do subject { instance.print } - context "non exist movefile" do + context 'non exist movefile' do let(:options) { { config: 'non_exists_path' } } - it "call parse_content" do - expect(instance).to receive(:parse_movefile).and_call_original + it 'call parse_content' do expect { subject }.to raise_error Wordmove::MovefileNotFound end end - context "valid movefile" do - let(:options) { { config: movefile_path_for('multi_environments') } } - - it "call parse_content" do + context 'valid movefile' do + it 'call parse_content' do expect(instance).to receive(:parse_movefile).and_call_original subject end end end - describe "private #output_string" do - subject { instance.send(:output_string, vhost_list: vhost_list) } + describe 'private #output_string' do + subject { instance.send(:output_string, vhost_list:) } let(:vhost_list) do [ - { env: :staging, vhost: "https://staging.mysite.example.com" }, - { env: :development, vhost: "http://development.mysite.example.com" } + { env: :staging, vhost: 'https://staging.mysite.example.com' }, + { env: :development, vhost: 'http://development.mysite.example.com' } ] end - it "return expected output" do + it 'return expected output' do result = subject expect(result).to match('staging: https://staging.mysite.example.com') expect(result).to match('development: http://development.mysite.example.com') end end - describe "private #select_vhost" do - subject { instance.send(:select_vhost, contents: contents) } + describe 'private #select_vhost' do + subject { instance.send(:select_vhost, contents:) } let(:contents) do { local: { - vhost: "http://localhost:8080", - wordpress_path: "/home/welaika/sites/your_site", + vhost: 'http://localhost:8080', + wordpress_path: '/home/welaika/sites/your_site', database: { - name: "database_name", - user: "user", - password: "password", - host: "host" + name: 'database_name', + user: 'user', + password: 'password', + host: 'host' } }, development: { - vhost: "http://development.mysite.example.com", - wordpress_path: "/var/www/your_site", + vhost: 'http://development.mysite.example.com', + wordpress_path: '/var/www/your_site', database: { - name: "database_name", - user: "user", - password: "password", - host: "host" + name: 'database_name', + user: 'user', + password: 'password', + host: 'host' } } } @@ -91,12 +88,12 @@ let(:result_list) do [ - { env: :local, vhost: "http://localhost:8080" }, - { env: :development, vhost: "http://development.mysite.example.com" } + { env: :local, vhost: 'http://localhost:8080' }, + { env: :development, vhost: 'http://development.mysite.example.com' } ] end - it "return expected vhost list" do + it 'return expected vhost list' do expect(subject).to match(result_list) end end diff --git a/spec/features/movefile_spec.rb b/spec/features/movefile_spec.rb index cba497fa..78cddb05 100644 --- a/spec/features/movefile_spec.rb +++ b/spec/features/movefile_spec.rb @@ -1,6 +1,6 @@ describe Wordmove::Generators::Movefile do let(:movefile) { 'movefile.yml' } - let(:tmpdir) { "/tmp/wordmove" } + let(:tmpdir) { '/tmp/wordmove' } before do @pwd = Dir.pwd @@ -13,9 +13,9 @@ FileUtils.rm_rf(tmpdir) end - context "::start" do + context '::start' do before do - silence_stream(STDOUT) { Wordmove::Generators::Movefile.start } + silence_stream($stdout) { Wordmove::Generators::Movefile.generate } end it 'creates a Movefile' do @@ -27,14 +27,6 @@ expect(yaml['local']['wordpress_path']).to eq(Dir.pwd) end - it 'fills database configuration defaults' do - yaml = YAML.safe_load(ERB.new(File.read(movefile)).result) - expect(yaml['local']['database']['name']).to eq('database_name') - expect(yaml['local']['database']['user']).to eq('user') - expect(yaml['local']['database']['password']).to eq('password') - expect(yaml['local']['database']['host']).to eq('127.0.0.1') - end - it 'creates a Movifile having a "global.sql_adapter" key' do yaml = YAML.safe_load(ERB.new(File.read(movefile)).result) expect(yaml['global']).to be_present @@ -42,21 +34,4 @@ expect(yaml['global']['sql_adapter']).to eq('wpcli') end end - - context "database configuration" do - let(:wp_config) { File.join(File.dirname(__FILE__), "../fixtures/wp-config.php") } - - before do - FileUtils.cp(wp_config, ".") - silence_stream(STDOUT) { Wordmove::Generators::Movefile.start } - end - - it 'fills database configuration from wp-config' do - yaml = YAML.safe_load(ERB.new(File.read(movefile)).result) - expect(yaml['local']['database']['name']).to eq('wordmove_db') - expect(yaml['local']['database']['user']).to eq('wordmove_user') - expect(yaml['local']['database']['password']).to eq('wordmove_password') - expect(yaml['local']['database']['host']).to eq('wordmove_host') - end - end end diff --git a/spec/fixtures/movefiles/Movefile b/spec/fixtures/movefiles/Movefile index 12dd4d68..d24f1592 100644 --- a/spec/fixtures/movefiles/Movefile +++ b/spec/fixtures/movefiles/Movefile @@ -1,13 +1,8 @@ global: - sql_adapter: "default" + sql_adapter: "wpcli" local: vhost: "http://vhost.local" wordpress_path: "~/dev/sites/your_site" - database: - name: "database_name" - user: "user" - password: "password" - host: "host" remote: vhost: "http://example.com" wordpress_path: "/var/www/your_site" diff --git a/spec/fixtures/movefiles/custom_paths b/spec/fixtures/movefiles/custom_paths new file mode 100644 index 00000000..8fef9cb6 --- /dev/null +++ b/spec/fixtures/movefiles/custom_paths @@ -0,0 +1,21 @@ +global: + sql_adapter: "wpcli" +local: + wordpress_path: "~/dev/sites/your_site" + paths: + uploads: 'wp-content/pirate' +remote: + vhost: "http://example.com" + wordpress_path: "/var/www/your_site" + paths: + uploads: 'wp-content/pirate' + database: + name: "database_name" + user: "user" + password: "password" + host: "host" + ssh: + user: "user" + password: "password" + host: "host" + port: 30000 diff --git a/spec/fixtures/movefiles/multi_environments b/spec/fixtures/movefiles/multi_environments index 2b029ce4..db8daa01 100644 --- a/spec/fixtures/movefiles/multi_environments +++ b/spec/fixtures/movefiles/multi_environments @@ -1,16 +1,9 @@ global: - sql_adapter: "default" + sql_adapter: "wpcli" local: - vhost: "http://localhost:8080" wordpress_path: "/home/welaika/sites/your_site" - database: - name: "database_name" - user: "user" - password: "password" - host: "host" - staging: vhost: "http://staging.mysite.example.com" wordpress_path: "/var/www/your_site" # use an absolute path here diff --git a/spec/fixtures/movefiles/multi_environments_wpcli_sql_adapter b/spec/fixtures/movefiles/multi_environments_wpcli_sql_adapter index 343b217b..db8daa01 100644 --- a/spec/fixtures/movefiles/multi_environments_wpcli_sql_adapter +++ b/spec/fixtures/movefiles/multi_environments_wpcli_sql_adapter @@ -2,15 +2,8 @@ global: sql_adapter: "wpcli" local: - vhost: "http://localhost:8080" wordpress_path: "/home/welaika/sites/your_site" - database: - name: "database_name" - user: "user" - password: "password" - host: "host" - staging: vhost: "http://staging.mysite.example.com" wordpress_path: "/var/www/your_site" # use an absolute path here diff --git a/spec/fixtures/movefiles/with_forbidden_tasks b/spec/fixtures/movefiles/with_forbidden_tasks index 3a0eecb4..2e0399fd 100644 --- a/spec/fixtures/movefiles/with_forbidden_tasks +++ b/spec/fixtures/movefiles/with_forbidden_tasks @@ -1,13 +1,7 @@ global: - sql_adapter: "default" + sql_adapter: "wpcli" local: - vhost: "http://vhost.local" wordpress_path: "~/dev/sites/your_site" - database: - name: "database_name" - user: "user" - password: "password" - host: "host" remote: vhost: "http://example.com" wordpress_path: "/var/www/your_site" diff --git a/spec/fixtures/movefiles/with_hooks b/spec/fixtures/movefiles/with_hooks index a7478bd3..b9569332 100644 --- a/spec/fixtures/movefiles/with_hooks +++ b/spec/fixtures/movefiles/with_hooks @@ -1,18 +1,11 @@ <% require 'tmpdir' %> global: - sql_adapter: "default" + sql_adapter: "wpcli" local: - vhost: "http://localhost:8080" wordpress_path: "<%= Dir.tmpdir %>" - database: - name: "database_name" - user: "user" - password: "password" - host: "host" - ssh_with_hooks: vhost: "http://staging.mysite.example.com" wordpress_path: "/var/www/your_site" # use an absolute path here diff --git a/spec/fixtures/movefiles/with_secrets b/spec/fixtures/movefiles/with_secrets index c6b8aba0..aa72f987 100644 --- a/spec/fixtures/movefiles/with_secrets +++ b/spec/fixtures/movefiles/with_secrets @@ -1,13 +1,7 @@ global: - sql_adapter: "default" + sql_adapter: "wpcli" local: - vhost: "http://secrets.local" wordpress_path: "~/dev/sites/your_site" - database: - name: "database_name" - user: "user" - password: "local_database_password" - host: "local_database_host" remote: vhost: "http://secrets.example.com" wordpress_path: "/var/www/your_site" diff --git a/spec/fixtures/movefiles/with_secrets_with_empty_local_db_password b/spec/fixtures/movefiles/with_secrets_with_empty_local_db_password index 61ef7467..0236e8f0 100644 --- a/spec/fixtures/movefiles/with_secrets_with_empty_local_db_password +++ b/spec/fixtures/movefiles/with_secrets_with_empty_local_db_password @@ -1,13 +1,7 @@ global: - sql_adapter: "default" + sql_adapter: "wpcli" local: - vhost: "http://secrets.local" wordpress_path: "~/dev/sites/your_site" - database: - name: "database_name" - user: "user" - password: "" - host: "local_database_host" remote: vhost: "http://secrets.example.com" wordpress_path: "/var/www/your_site" diff --git a/spec/hook_spec.rb b/spec/hook_spec.rb index baf9a314..426ff2ff 100644 --- a/spec/hook_spec.rb +++ b/spec/hook_spec.rb @@ -2,11 +2,51 @@ require 'tmpdir' describe Wordmove::Hook do - let(:common_options) { { "wordpress" => true, "config" => movefile_path_for('with_hooks') } } - let(:cli) { Wordmove::CLI.new } + let(:common_options) { { wordpress: true, config: movefile_path_for('with_hooks') } } + # +options+ is meant to be defined into every single spec +context+ (I mean rspec's context + # not service objects' context ;) ) + let(:context) do + { + cli_options: options, + movefile: Wordmove::Movefile.new(options) + } + end + + let(:stubbed_actions) do + [ + Wordmove::Actions::Ssh::PushWordpress, + Wordmove::Actions::Ftp::PushWordpress, + Wordmove::Actions::Ssh::PullWordpress, + Wordmove::Actions::Ftp::PullWordpress, + Wordmove::Actions::Ssh::PutDirectory, + Wordmove::Actions::Ftp::PutDirectory, + Wordmove::Actions::Ssh::GetDirectory, + Wordmove::Actions::Ftp::GetDirectory, + Wordmove::Actions::SetupContextForDb, + Wordmove::Actions::Ssh::DownloadRemoteDb, + Wordmove::Actions::Ftp::DownloadRemoteDb, + Wordmove::Actions::Ssh::BackupRemoteDb, + Wordmove::Actions::Ftp::BackupRemoteDb, + Wordmove::Actions::AdaptLocalDb, + Wordmove::Actions::Ssh::PutAndImportDumpRemotely, + Wordmove::Actions::Ftp::PutAndImportDumpRemotely, + Wordmove::Actions::BackupLocalDb, + Wordmove::Actions::AdaptRemoteDb, + Wordmove::Actions::Ssh::CleanupAfterAdapt, + Wordmove::Actions::Ftp::CleanupAfterAdapt + ] + end + + before do + # Note we're stubbing actions from organizers others than ones + # calling the hooks. I consider this approach to be affordable enough. + stubbed_actions.each do |action| + stub_action(action) + end + end - context 'testing comand order' do - let(:options) { common_options.merge("environment" => 'ssh_with_hooks') } + context 'testing command order' do + let(:options) { common_options.merge(environment: 'ssh_with_hooks') } before do allow(Wordmove::Hook::Local).to receive(:run) @@ -14,27 +54,27 @@ end it 'checks the order' do - cli.invoke(:push, [], options) + Wordmove::Organizers::Ssh::Push.call(context[:cli_options], context[:movefile]) expect(Wordmove::Hook::Local).to( have_received(:run).with( { command: 'echo "Calling hook push before local"', where: 'local' }, an_instance_of(Hash), - nil + false ).ordered ) expect(Wordmove::Hook::Local).to( have_received(:run).with( { command: 'pwd', where: 'local' }, an_instance_of(Hash), - nil + false ).ordered ) expect(Wordmove::Hook::Remote).to( have_received(:run).with( { command: 'echo "Calling hook push before remote"', where: 'remote' }, an_instance_of(Hash), - nil + false ).ordered ) @@ -42,31 +82,21 @@ have_received(:run).with( { command: 'echo "Calling hook push after local"', where: 'local' }, an_instance_of(Hash), - nil + false ).ordered ) expect(Wordmove::Hook::Remote).to( have_received(:run).with( { command: 'echo "Calling hook push after remote"', where: 'remote' }, an_instance_of(Hash), - nil + false ).ordered ) end end - context "#run" do - before do - allow_any_instance_of(Wordmove::Deployer::Base) - .to receive(:pull_wordpress) - .and_return(true) - - allow_any_instance_of(Wordmove::Deployer::Base) - .to receive(:push_wordpress) - .and_return(true) - end - - context "when pushing to a remote with ssh" do + context '#run' do + context 'when pushing to a remote with ssh' do before do allow_any_instance_of(Photocopier::SSH) .to receive(:exec!) @@ -74,80 +104,79 @@ .and_return(['Stubbed remote stdout', nil, 0]) end - let(:options) { common_options.merge("environment" => 'ssh_with_hooks') } + let(:options) { common_options.merge(environment: 'ssh_with_hooks') } - it "runs registered before local hooks" do - expect { cli.invoke(:push, [], options) } + it 'runs registered before local hooks' do + expect { Wordmove::Organizers::Ssh::Push.call(context[:cli_options], context[:movefile]) } .to output(/Calling hook push before local/) - .to_stdout_from_any_process + .to_stdout end - it "runs registered before local hooks in the wordpress folder" do - expect { cli.invoke(:push, [], options) } + it 'runs registered before local hooks in the wordpress folder' do + expect { Wordmove::Organizers::Ssh::Push.call(context[:cli_options], context[:movefile]) } .to output(/#{Dir.tmpdir}/) - .to_stdout_from_any_process + .to_stdout end - it "runs registered before remote hooks" do - expect { cli.invoke(:push, [], options) } + it 'runs registered before remote hooks' do + expect { Wordmove::Organizers::Ssh::Push.call(context[:cli_options], context[:movefile]) } .to output(/Calling hook push before remote/) - .to_stdout_from_any_process + .to_stdout end - it "runs registered after local hooks" do - expect { cli.invoke(:push, [], options) } + it 'runs registered after local hooks' do + expect { Wordmove::Organizers::Ssh::Push.call(context[:cli_options], context[:movefile]) } .to output(/Calling hook push after local/) - .to_stdout_from_any_process + .to_stdout end - it "runs registered after remote hooks" do - expect { cli.invoke(:push, [], options) } + it 'runs registered after remote hooks' do + expect { Wordmove::Organizers::Ssh::Push.call(context[:cli_options], context[:movefile]) } .to output(/Calling hook push after remote/) - .to_stdout_from_any_process + .to_stdout end - context "if --similate was passed by user on cli" do + context 'if --similate was passed by user on cli' do let(:options) do - common_options.merge("environment" => 'ssh_with_hooks', "simulate" => true) + common_options.merge(environment: 'ssh_with_hooks', simulate: true) end - it "does not really run any commands" do - expect { cli.invoke(:push, [], options) } + it 'does not really run any commands' do + expect { Wordmove::Organizers::Ssh::Push.call(context[:cli_options], context[:movefile]) } .not_to output(/Output:/) - .to_stdout_from_any_process + .to_stdout end end - context "with local hook errored" do - let(:options) { common_options.merge("environment" => 'ssh_with_hooks_which_return_error') } + context 'with local hook errored' do + let(:options) { common_options.merge(environment: 'ssh_with_hooks_which_return_error') } - it "logs an error and raises a LocalHookException" do + it 'logs an error and raises a LocalHookException' do expect do expect do - cli.invoke(:push, [], options) + Wordmove::Organizers::Ssh::Push.call(context[:cli_options], context[:movefile]) end.to raise_exception(Wordmove::LocalHookException) - end.to output(/Error code: 127/) - .to_stdout_from_any_process + end.to output(/Error code: 127/).to_stdout end - context "with raise set to `false`" do + context 'with raise set to `false`' do let(:options) do - common_options.merge("environment" => 'ssh_with_hooks_which_return_error_raise_false') + common_options.merge(environment: 'ssh_with_hooks_which_return_error_raise_false') end - it "logs an error without raising an exeption" do + it 'logs an error without raising an exeption' do expect do expect do - cli.invoke(:push, [], options) + Wordmove::Organizers::Ssh::Push.call(context[:cli_options], context[:movefile]) end.to_not raise_exception end.to output(/Error code: 127/) - .to_stdout_from_any_process + .to_stdout end end end end - context "when pulling from a remote with ssh" do + context 'when pulling from a remote with ssh' do before do allow_any_instance_of(Photocopier::SSH) .to receive(:exec!) @@ -155,39 +184,39 @@ .and_return(['Stubbed remote stdout', nil, 0]) end - let(:options) { common_options.merge("environment" => 'ssh_with_hooks') } + let(:options) { common_options.merge(environment: 'ssh_with_hooks') } - it "runs registered before local hooks" do - expect { cli.invoke(:pull, [], options) } + it 'runs registered before local hooks' do + expect { Wordmove::Organizers::Ssh::Pull.call(context[:cli_options], context[:movefile]) } .to output(/Calling hook pull before local/) - .to_stdout_from_any_process + .to_stdout end - it "runs registered before remote hooks" do - expect { cli.invoke(:pull, [], options) } + it 'runs registered before remote hooks' do + expect { Wordmove::Organizers::Ssh::Pull.call(context[:cli_options], context[:movefile]) } .to output(/Calling hook pull before remote/) - .to_stdout_from_any_process + .to_stdout end - it "runs registered after local hooks" do - expect { cli.invoke(:pull, [], options) } + it 'runs registered after local hooks' do + expect { Wordmove::Organizers::Ssh::Pull.call(context[:cli_options], context[:movefile]) } .to output(/Calling hook pull after local/) - .to_stdout_from_any_process + .to_stdout end - it "runs registered after remote hooks" do - expect { cli.invoke(:pull, [], options) } + it 'runs registered after remote hooks' do + expect { Wordmove::Organizers::Ssh::Pull.call(context[:cli_options], context[:movefile]) } .to output(/Calling hook pull after remote/) - .to_stdout_from_any_process + .to_stdout end - it "return remote stdout" do - expect { cli.invoke(:pull, [], options) } + it 'return remote stdout' do + expect { Wordmove::Organizers::Ssh::Pull.call(context[:cli_options], context[:movefile]) } .to output(/Stubbed remote stdout/) - .to_stdout_from_any_process + .to_stdout end - context "with remote hook errored" do + context 'with remote hook errored' do before do allow_any_instance_of(Photocopier::SSH) .to receive(:exec!) @@ -195,91 +224,92 @@ .and_return(['Stubbed remote stdout', 'Stubbed remote stderr', 1]) end - it "returns remote stdout and raise an exception" do + it 'returns remote stdout and raise an exception' do expect do expect do - cli.invoke(:pull, [], options) + Wordmove::Organizers::Ssh::Pull.call(context[:cli_options], context[:movefile]) end.to raise_exception(Wordmove::RemoteHookException) end.to output(/Stubbed remote stderr/) - .to_stdout_from_any_process + .to_stdout end - it "raises a RemoteHookException" do + it 'raises a RemoteHookException' do expect do - silence_stream(STDOUT) do - cli.invoke(:pull, [], options) + silence_stream($stdout) do + Wordmove::Organizers::Ssh::Pull.call(context[:cli_options], context[:movefile]) end end.to raise_exception(Wordmove::RemoteHookException) end end end - context "when pushing to a remote with ftp" do - let(:options) { common_options.merge("environment" => 'ftp_with_hooks') } + context 'when pushing to a remote with ftp' do + let(:options) { common_options.merge(environment: 'ftp_with_hooks') } - context "having remote hooks" do - it "does not run the remote hooks" do + context 'having remote hooks' do + it 'does not run the remote hooks and alert the user' do expect(Wordmove::Hook::Remote) .to_not receive(:run) - silence_stream(STDOUT) do - cli.invoke(:push, [], options) - end + expect { Wordmove::Organizers::Ftp::Push.call(context[:cli_options], context[:movefile]) } + .to output( + /You have configured remote hooks to run over an FTP connection, but this is not possible/ # rubocop:disable Layout/LineLength + ).to_stdout end end end - context "with hooks partially filled" do - let(:options) { common_options.merge("environment" => 'ssh_with_hooks_partially_filled') } + context 'with hooks partially filled' do + let(:options) { common_options.merge(environment: 'ssh_with_hooks_partially_filled') } - it "works silently ignoring push hooks are not present" do + it 'works silently ignoring push hooks are not present' do expect(Wordmove::Hook::Remote) .to_not receive(:run) expect(Wordmove::Hook::Local) .to_not receive(:run) - silence_stream(STDOUT) do - cli.invoke(:push, [], options) + silence_stream($stdout) do + Wordmove::Organizers::Ssh::Push.call(context[:cli_options], context[:movefile]) end end it "works silently ignoring 'before' step is not present" do - expect { cli.invoke(:pull, [], options) } + expect { Wordmove::Organizers::Ssh::Pull.call(context[:cli_options], context[:movefile]) } .to output(/I've partially configured my hooks/) - .to_stdout_from_any_process + .to_stdout end end end end describe Wordmove::Hook::Config do - let(:movefile) { Wordmove::Movefile.new(movefile_path_for('with_hooks')) } - let(:options) { movefile.fetch(false)[:ssh_with_hooks][:hooks] } + let(:movefile) { Wordmove::Movefile.new({ config: movefile_path_for('with_hooks') }, nil, false) } + let(:options) { movefile.options[:ssh_with_hooks][:hooks] } let(:config) { described_class.new(options, :push, :before) } - context "#local_commands" do - it "returns all the local hooks" do + context '#local_commands' do + it 'returns all the local hooks' do expect(config.remote_commands).to be_kind_of(Array) expect(config.local_commands.first[:command]).to eq 'echo "Calling hook push before local"' expect(config.local_commands.second[:command]).to eq 'pwd' end end - context "#remote_commands" do - it "returns all the remote hooks" do + context '#remote_commands' do + it 'returns all the remote hooks' do expect(config.remote_commands).to be_kind_of(Array) expect(config.remote_commands.first[:command]).to eq 'echo "Calling hook push before remote"' end end - context "#empty?" do - it "returns true if `all_commands` array is empty" do + context '#empty?' do + it 'returns true if `all_commands` array is empty' do allow(config).to receive(:all_commands).and_return([]) expect(config.empty?).to be true end - it "returns false if there is at least one hook registered" do + it 'returns false if there is at least one hook registered' do allow(config).to receive(:local_commands).and_return([]) expect(config.empty?).to be false diff --git a/spec/logger/logger_spec.rb b/spec/logger/logger_spec.rb index f5e4b0b1..0a790ae6 100644 --- a/spec/logger/logger_spec.rb +++ b/spec/logger/logger_spec.rb @@ -1,19 +1,19 @@ describe Wordmove::Logger do - context "#info" do - context "having some string to filter" do - let(:logger) { described_class.new(STDOUT, ['hidden']) } + context '#info' do + context 'having some string to filter' do + let(:logger) { described_class.new($stdout, ['hidden']) } - it "will hide the passed strings" do + it 'will hide the passed strings' do expect { logger.info('What I write is hidden') } .to output(/What I write is \[secret\]/) .to_stdout_from_any_process end end - context "having a string with regexp special characters" do - let(:logger) { described_class.new(STDOUT, ['comp/3xPa((w0r]']) } + context 'having a string with regexp special characters' do + let(:logger) { described_class.new($stdout, ['comp/3xPa((w0r]']) } - it "will hide the passed strings" do + it 'will hide the passed strings' do expect { logger.info('What I write is comp/3xPa((w0r]') } .to output(/What I write is \[secret\]/) .to_stdout_from_any_process diff --git a/spec/movefile_spec.rb b/spec/movefile_spec.rb index fe81211e..264a72a4 100644 --- a/spec/movefile_spec.rb +++ b/spec/movefile_spec.rb @@ -1,124 +1,134 @@ describe Wordmove::Movefile do - let(:movefile) { described_class.new } + let(:path) { File.join(tmpdir, 'movefile.yml') } + let(:movefile) { described_class.new(config: movefile_path_for('Movefile')) } - context ".initialize" do - it "instantiate a logger instance" do + context '.initialize' do + it 'instantiate a logger instance' do expect(movefile.logger).to be_an_instance_of(Wordmove::Logger) end end - context ".load_env" do - TMPDIR = "/tmp/wordmove".freeze - - let(:path) { File.join(TMPDIR, 'movefile.yml') } - let(:dotenv_path) { File.join(TMPDIR, '.env') } + context '#load_env' do + let(:tmpdir) { '/tmp/wordmove'.freeze } + let(:path) { File.join(tmpdir, 'movefile.yml') } + let(:dotenv_path) { File.join(tmpdir, '.env') } let(:yaml) { "name: Waldo\njob: Hider" } - let(:dotenv) { "OBIWAN=KENOBI" } - let(:movefile) { described_class.new(nil, path) } + let(:dotenv) { 'OBIWAN=KENOBI' } + let(:movefile) { described_class.new({ config: 'movefile.yml' }, path) } before do - FileUtils.mkdir(TMPDIR) - allow(movefile).to receive(:current_dir).and_return(TMPDIR) - allow(movefile).to receive(:logger).and_return(double('logger').as_null_object) - File.open(path, 'w') { |f| f.write(yaml) } + FileUtils.mkdir(tmpdir) + File.write(path, yaml) + File.write(dotenv_path, dotenv) + allow_any_instance_of(described_class) + .to receive(:current_dir) + .and_return(tmpdir) + allow_any_instance_of(described_class) + .to receive(:logger) + .and_return(double('logger').as_null_object) end after do - FileUtils.rm_rf(TMPDIR) + FileUtils.rm_rf(tmpdir) end - context "when .env is present" do - before do - File.open(dotenv_path, 'w') { |f| f.write(dotenv) } + context 'when .env is present' do + let!(:movefile) do + described_class.new( + { + config: 'movefile.yml', + environment: 'local' + }, + path + ) end - it "loads environment variables" do - movefile.load_dotenv(environment: 'local') - + it 'loads environment variables' do expect(ENV['OBIWAN']).to eq('KENOBI') end end end - context ".fetch" do - TMPDIR = "/tmp/wordmove".freeze + context '#fetch' do + let(:tmpdir) { '/tmp/wordmove'.freeze } - let(:path) { File.join(TMPDIR, 'movefile.yml') } + let(:path) { File.join(tmpdir, 'movefile.yml') } let(:yaml) { "name: Waldo\njob: Hider" } - let(:movefile) { described_class.new(nil, path) } + let(:movefile) { described_class.new({}, path) } before do - FileUtils.mkdir(TMPDIR) - allow(movefile).to receive(:current_dir).and_return(TMPDIR) - allow(movefile).to receive(:logger).and_return(double('logger').as_null_object) + FileUtils.mkdir(tmpdir) + File.write(path, yaml) + allow_any_instance_of(described_class) + .to receive(:current_dir) + .and_return(tmpdir) + allow_any_instance_of(described_class) + .to receive(:logger) + .and_return(double('logger').as_null_object) end after do - FileUtils.rm_rf(TMPDIR) + FileUtils.rm_rf(tmpdir) end - context "when Movefile is missing" do + context 'when Movefile is missing' do it 'raises an exception' do - expect { movefile.fetch }.to raise_error(Wordmove::MovefileNotFound) + expect { described_class.new({}, '/tmp') }.to raise_error(Wordmove::MovefileNotFound) end end - context "when Movefile is present" do - before do - File.open(path, 'w') { |f| f.write(yaml) } - end - + context 'when Movefile is present' do it 'finds a Movefile in current dir' do - result = movefile.fetch + result = movefile.options expect(result[:name]).to eq('Waldo') expect(result[:job]).to eq('Hider') end - context "when movefile has no extensions" do - let(:path) { File.join(TMPDIR, 'movefile') } + context 'when movefile has no extensions' do + let(:path) { File.join(tmpdir, 'movefile') } it 'finds it aswell' do - result = movefile.fetch + result = movefile.options expect(result[:name]).to eq('Waldo') expect(result[:job]).to eq('Hider') end end - context "when Movefile has no extensions and has first capital" do - let(:path) { File.join(TMPDIR, 'Movefile') } + context 'when Movefile has no extensions and has first capital' do + let(:path) { File.join(tmpdir, 'Movefile') } it 'finds it aswell' do - result = movefile.fetch + result = movefile.options expect(result[:name]).to eq('Waldo') expect(result[:job]).to eq('Hider') end end - context "when movefile.yaml has long extension" do - let(:path) { File.join(TMPDIR, 'movefile.yaml') } + context 'when movefile.yaml has long extension' do + let(:path) { File.join(tmpdir, 'movefile.yaml') } it 'finds it aswell' do - result = movefile.fetch + result = movefile.options expect(result[:name]).to eq('Waldo') expect(result[:job]).to eq('Hider') end end - context "directories traversal" do + context 'directories traversal' do before do - @test_dir = File.join(TMPDIR, "test") + @test_dir = File.join(tmpdir, 'test') FileUtils.mkdir(@test_dir) end it 'goes up through the directory tree and finds it' do - movefile = described_class.new(nil, @test_dir) - result = movefile.fetch + movefile = described_class.new({}, @test_dir) + result = movefile.options expect(result[:name]).to eq('Waldo') expect(result[:job]).to eq('Hider') end context 'Movefile not found, met root node' do - let(:movefile) { described_class.new(nil, '/tmp') } + let(:movefile) { described_class.new({}, '/tmp') } it 'raises an exception' do expect { movefile.fetch }.to raise_error(Wordmove::MovefileNotFound) @@ -126,10 +136,10 @@ end context 'Movefile not found, found wp-config.php' do - let(:movefile) { described_class.new(nil, '/tmp') } + let(:movefile) { described_class.new({}, '/tmp') } before do - FileUtils.touch(File.join(@test_dir, "wp-config.php")) + FileUtils.touch(File.join(@test_dir, 'wp-config.php')) end it 'raises an exception' do @@ -140,16 +150,16 @@ end end - context ".secrets" do + context '#secrets' do let(:path) { movefile_path_for('with_secrets') } - it "returns all the secrets found in movefile" do - movefile = described_class.new('with_secrets', path) + it 'returns all the secrets found in movefile' do + movefile = described_class.new(config: path) expect(movefile.secrets).to eq( %w[ local_database_password local_database_host - http://secrets.local + http://example.com ~/dev/sites/your_site remote_database_password remote_database_host @@ -164,12 +174,18 @@ ) end - it "returns all the secrets found in movefile excluding empty string values" do - movefile = described_class.new('with_secrets_with_empty_local_db_password', path) + it 'returns all the secrets found in movefile excluding empty string values' do + allow(Wordmove::WpcliHelpers) + .to receive(:get_config) + .with('DB_PASSWORD', config_path: instance_of(String)) + .and_return('') + + path = movefile_path_for('with_secrets_with_empty_local_db_password') + movefile = described_class.new(config: path) expect(movefile.secrets).to eq( %w[ local_database_host - http://secrets.local + http://example.com ~/dev/sites/your_site remote_database_password remote_database_host @@ -183,4 +199,33 @@ ) end end + + context '#environment' do + let!(:movefile) do + described_class.new( + config: movefile_path_for('multi_environments'), + environment: nil + ) + end + + context 'with more than one environment, but none chosen' do + it 'raises an exception' do + expect { movefile.environment } + .to raise_exception(Wordmove::UndefinedEnvironment) + end + end + + context 'with more than one environment, but invalid chosen' do + let!(:movefile) do + described_class.new( + config: movefile_path_for('multi_environments'), + environment: 'doesnotexist' + ) + end + it 'raises an exception' do + expect { movefile.environment } + .to raise_exception(Wordmove::UndefinedEnvironment) + end + end + end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 56a3bad0..a7ac0291 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,17 +1,16 @@ $LOAD_PATH.unshift File.expand_path('../lib', __dir__) -require "tempfile" -require "pry-byebug" -require "priscilla" +require 'tempfile' +require 'debug' -require "simplecov" +require 'simplecov' SimpleCov.start do - add_filter "/spec/" + add_filter '/spec/' end -require "wordmove" +require 'wordmove' -Dir[File.expand_path("support/**/*.rb", __dir__)].sort.each { |f| require f } +Dir[File.expand_path('support/**/*.rb', __dir__)].each { |f| require f } # I don't know from where this method was imported, # but since last updates it was lost. I looked about @@ -29,16 +28,47 @@ def silence_stream(stream) old_stream.close end -RSpec.configure do |config| +RSpec.configure do |config| # rubocop:disable Metrics/BlockLength config.expect_with :rspec do |expectations| expectations.include_chain_clauses_in_custom_matcher_descriptions = true end config.mock_with :rspec do |mocks| mocks.verify_partial_doubles = true + mocks.verify_doubled_constant_names = true end - config.example_status_persistence_file_path = "./spec/examples.txt" + config.example_status_persistence_file_path = './spec/examples.txt' config.formatter = :documentation + + config.before :each do + allow(Wordmove::WpcliHelpers) + .to receive(:get_option) + .and_return('an option') + + allow(Wordmove::WpcliHelpers) + .to receive(:get_option) + .with('home', config_path: instance_of(String)) + .and_return('http://example.com') + + allow(Wordmove::WpcliHelpers) + .to receive(:get_config) + .and_return('a config') + + allow(Wordmove::WpcliHelpers) + .to receive(:get_config) + .with('DB_PASSWORD', config_path: instance_of(String)) + .and_return('local_database_password') + + allow(Wordmove::WpcliHelpers) + .to receive(:get_config) + .with('DB_HOST', config_path: instance_of(String)) + .and_return('local_database_host') + + allow(Wordmove::WpcliHelpers) + .to receive(:get_config) + .with('DB_NAME', config_path: instance_of(String)) + .and_return('local_database_name') + end end diff --git a/spec/sql_adapter/default_spec.rb b/spec/sql_adapter/default_spec.rb deleted file mode 100644 index b5e6a014..00000000 --- a/spec/sql_adapter/default_spec.rb +++ /dev/null @@ -1,232 +0,0 @@ -describe Wordmove::SqlAdapter::Default do - let(:sql_path) { double } - let(:source_config) { double } - let(:dest_config) { double } - let(:adapter) do - Wordmove::SqlAdapter::Default.new( - sql_path, - source_config, - dest_config - ) - end - - context ".initialize" do - it "should assign variables correctly on initialization" do - expect(adapter.sql_path).to eq(sql_path) - expect(adapter.source_config).to eq(source_config) - expect(adapter.dest_config).to eq(dest_config) - end - end - - context ".sql_content" do - let(:sql) do - Tempfile.new('sql').tap do |d| - d.write('DUMP') - d.close - end - end - let(:sql_path) { sql.path } - - it "should read the sql file content" do - expect(adapter.sql_content).to eq('DUMP') - end - end - - context ".adapt!" do - it "should replace host, path and write to sql" do - expect(adapter).to receive(:replace_vhost!).and_return(true) - expect(adapter).to receive(:replace_wordpress_path!).and_return(true) - expect(adapter).to receive(:write_sql!).and_return(true) - adapter.adapt! - end - end - - context ".replace_vhost!" do - let(:sql) do - Tempfile.new('sql').tap do |d| - d.write(File.read(fixture_path_for('dump.sql'))) - d.close - end - end - let(:sql_path) { sql.path } - - context "with port" do - let(:source_config) { { vhost: 'localhost:8080' } } - let(:dest_config) { { vhost: 'foo.bar:8181' } } - - it "should replace domain and port" do - adapter.replace_vhost! - adapter.write_sql! - - expect(File.read(sql)).to match('foo.bar:8181') - expect(File.read(sql)).to_not match('localhost:8080') - end - end - - context "without port" do - let(:source_config) { { vhost: 'localhost' } } - let(:dest_config) { { vhost: 'foo.bar' } } - - it "should replace domain leving port unaltered" do - adapter.replace_vhost! - adapter.write_sql! - - expect(File.read(sql)).to match('foo.bar:8080') - expect(File.read(sql)).to_not match('localhost:8080') - end - end - end - - describe "replace single fields" do - context ".replace_vhost!" do - let(:source_config) { { vhost: "DUMP" } } - let(:dest_config) { { vhost: "FUNK" } } - - it "should replace source vhost with dest vhost" do - expect(adapter).to receive(:replace_field!).with("DUMP", "FUNK").and_return(true) - adapter.replace_vhost! - end - end - - context ".replace_wordpress_path!" do - let(:source_config) { { wordpress_path: "DUMP" } } - let(:dest_config) { { wordpress_path: "FUNK" } } - - it "should replace source vhost with dest wordpress paths" do - expect(adapter).to receive(:replace_field!).with("DUMP", "FUNK").and_return(true) - adapter.replace_wordpress_path! - end - - context "given an absolute path" do - let(:source_config) { { wordpress_absolute_path: "ABSOLUTE_DUMP", wordpress_path: "DUMP" } } - - it "should replace the absolute path instead" do - expect(adapter).to receive(:replace_field!).with("ABSOLUTE_DUMP", "FUNK").and_return(true) - adapter.replace_wordpress_path! - end - end - end - end - - context ".replace_field!" do - it "should replace source vhost with dest vhost" do - expect(adapter).to receive(:serialized_replace!).ordered.with("DUMP", "FUNK").and_return(true) - expect(adapter).to receive(:simple_replace!).ordered.with("DUMP", "FUNK").and_return(true) - adapter.replace_field!("DUMP", "FUNK") - end - end - - context ".serialized_replace!" do - let(:content) do - 'a:3:{i:0;s:20:"http://dump.com/spam";i:1;s:6:"foobar";i:2;s:22:"http://dump.com/foobar";}' - end - let(:sql) do - Tempfile.new('sql').tap do |d| - d.write(content) - d.close - end - end - let(:sql_path) { sql.path } - - it "should replace source vhost with dest vhost" do - adapter.serialized_replace!('http://dump.com', 'http://shrubbery.com') - expect(adapter.sql_content).to eq( - [ - 'a:3:{i:0;s:25:"http://shrubbery.com/spam";i:1;s:6:"foobar";', - 'i:2;s:27:"http://shrubbery.com/foobar";}' - ].join - ) - end - - context "given empty strings" do - let(:content) { 's:0:"";s:3:"foo";s:0:"";' } - - it "should leave them untouched" do - adapter.serialized_replace!('foo', 'sausage') - expect(adapter.sql_content).to eq('s:0:"";s:7:"sausage";s:0:"";') - end - - context "considering escaping" do - let(:content) { 's:0:\"\";s:3:\"foo\";s:0:\"\";' } - - it "should leave them untouched" do - adapter.serialized_replace!('foo', 'sausage') - expect(adapter.sql_content).to eq('s:0:\"\";s:7:\"sausage\";s:0:\"\";') - end - end - end - - context "given strings with escaped content" do - let(:content) { 's:6:"dump\"\"";' } - - it "should calculate the correct final length" do - adapter.serialized_replace!('dump', 'sausage') - expect(adapter.sql_content).to eq('s:9:"sausage\"\"";') - end - end - - context "given multiple types of string quoting" do - let(:content) do - [ - "a:3:{s:20:\\\"http://dump.com/spam\\\";s:6:'foobar';", - "s:22:'http://dump.com/foobar';s:8:'sausages';}" - ].join - end - - it "should handle replacing just as well" do - adapter.serialized_replace!('http://dump.com', 'http://shrubbery.com') - expect(adapter.sql_content).to eq( - [ - "a:3:{s:25:\\\"http://shrubbery.com/spam\\\";s:6:'foobar';", - "s:27:'http://shrubbery.com/foobar';s:8:'sausages';}" - ].join - ) - end - end - - context "given multiple occurences in the same string" do - let(:content) { 'a:1:{i:0;s:52:"ni http://dump.com/spam ni http://dump.com/foobar ni";}' } - - it "should replace all occurences" do - adapter.serialized_replace!('http://dump.com', 'http://shrubbery.com') - expect(adapter.sql_content).to eq( - 'a:1:{i:0;s:62:"ni http://shrubbery.com/spam ni http://shrubbery.com/foobar ni";}' - ) - end - end - end - - context ".simple_replace!" do - let(:content) { "THE DUMP!" } - let(:sql) do - Tempfile.new('sql').tap do |d| - d.write(content) - d.close - end - end - let(:sql_path) { sql.path } - - it "should replace source vhost with dest vhost" do - adapter.simple_replace!("DUMP", "FUNK") - expect(adapter.sql_content).to eq("THE FUNK!") - end - end - - context ".write_sql!" do - let(:content) { "THE DUMP!" } - let(:sql) do - Tempfile.new('sql').tap do |d| - d.write(content) - d.close - end - end - let(:sql_path) { sql.path } - let(:the_funk) { "THE FUNK THE FUNK THE FUNK" } - - it "should write content to file" do - adapter.sql_content = the_funk - adapter.write_sql! - File.open(sql_path).read == the_funk - end - end -end diff --git a/spec/sql_adapter/wpcli_spec.rb b/spec/sql_adapter/wpcli_spec.rb deleted file mode 100644 index d16f337a..00000000 --- a/spec/sql_adapter/wpcli_spec.rb +++ /dev/null @@ -1,60 +0,0 @@ -require 'spec_helper' - -describe Wordmove::SqlAdapter::Wpcli do - let(:config_key) { :vhost } - let(:source_config) { { vhost: 'sausage' } } - let(:dest_config) { { vhost: 'bacon' } } - let(:local_path) { '/path/to/ham' } - let(:adapter) do - Wordmove::SqlAdapter::Wpcli.new( - source_config, - dest_config, - config_key, - local_path - ) - end - - before do - allow(adapter).to receive(:wp_in_path?).and_return(true) - allow(adapter) - .to receive(:`) - .with('wp cli param-dump --with-values') - .and_return("{}") - end - - context "#command" do - context "having wp-cli.yml in local_path" do - let(:local_path) { fixture_folder_root_relative_path } - - it "returns the right command as a string" do - expect(adapter.command) - .to eq("wp search-replace --path=/path/to/steak sausage bacon --quiet "\ - "--skip-columns=guid --all-tables --allow-root") - end - end - - context "without wp-cli.yml in local_path" do - before do - allow(adapter) - .to receive(:`) - .with('wp cli param-dump --with-values') - .and_return("{\"path\":{\"current\":\"\/path\/to\/pudding\"}}") - end - context "but still reachable by wp-cli" do - it "returns the right command as a string" do - expect(adapter.command) - .to eq("wp search-replace --path=/path/to/pudding sausage bacon --quiet "\ - "--skip-columns=guid --all-tables --allow-root") - end - end - end - - context "without any wp-cli configuration" do - it "returns the right command with '--path' flag set to local_path" do - expect(adapter.command) - .to eq("wp search-replace --path=/path/to/ham sausage bacon --quiet "\ - "--skip-columns=guid --all-tables --allow-root") - end - end - end -end diff --git a/spec/support/action_helpers.rb b/spec/support/action_helpers.rb new file mode 100644 index 00000000..543472ca --- /dev/null +++ b/spec/support/action_helpers.rb @@ -0,0 +1,72 @@ +require 'support/fixture_helpers' +require 'light-service/testing' + +# Test helper class to build the context for Push and Pull organizers. +class OrganizerContextFactory + extend ::FixtureHelpers + include Wordmove::Actions::Ssh::Helpers + + DEFAULT_OPTIONS = { + wordpress: false, + uploads: false, + themes: false, + plugins: false, + mu_plugins: false, + languages: false, + db: false, + verbose: false, + simulate: false, + # environment is not set neither `nil`, + config: movefile_path_for('Movefile'), + debug: false, + no_adapt: false, + all: false + }.freeze + + # Build the context for Push or Pull organizer. + # @param [String] action + # @param [String|Symbol] wordmove_action. Atm only :push or :pull exist + # @param [Hash] cli_options + # While we have +DEFAULT_OPTIONS+, any option passed into this Hash + # will overwrite the default one. It is really useful to build context + # based on different fixturized movefiles + # @example + # OrganizerContextFactory.make_for( + # described_class, + # :push, + # cli_options: { config: movefile_path_for('multi_environments')} + # ) + def self.make_for(action, wordmove_action, cli_options: {}) + cli_options = DEFAULT_OPTIONS.merge(cli_options) + movefile = Wordmove::Movefile.new(cli_options, nil, false) + + LightService::Configuration.logger = ::Logger.new($stdout) if cli_options[:debug] + + LightService::Testing::ContextFactory + .make_from("Wordmove::Organizers::Ssh::#{wordmove_action.to_s.camelize}".constantize) + .for(action) + .with(cli_options, movefile) + end +end + +module ActionHelpers + # Calling this method inside an example or inside a `before` block + # will silence the logger using `/dev/null` as target device. + # Note that you have to call this mocking method before `context` is + # initialized, in order to have the mocked logger right into the context. + def silence_logger! + allow(Wordmove::Logger).to receive(:new).and_return(Wordmove::Logger.new(File::NULL)) + end + + def stub_action(action) + allow(action).to receive(:execute).and_return(stubbed_success_result) + end + + def stubbed_success_result + Struct.new(:failure?, :success?).new(false, true) + end +end + +RSpec.configure do |config| + config.include ActionHelpers +end diff --git a/spec/wordpress_directory_spec.rb b/spec/wordpress_directory_spec.rb new file mode 100644 index 00000000..bfb4b6af --- /dev/null +++ b/spec/wordpress_directory_spec.rb @@ -0,0 +1,80 @@ +describe WordpressDirectory do + it 'is defined' do + expect(Object.const_defined?('WordpressDirectory')).to be true + end + + it 'has DEAFAULT_PATHS defined' do + expect(described_class::DEFAULT_PATHS).to be_an_instance_of Hash + expect(described_class::DEFAULT_PATHS).to eq( + WordpressDirectory::Path::WP_CONTENT => 'wp-content', + WordpressDirectory::Path::WP_CONFIG => 'wp-config.php', + WordpressDirectory::Path::PLUGINS => 'wp-content/plugins', + WordpressDirectory::Path::MU_PLUGINS => 'wp-content/mu-plugins', + WordpressDirectory::Path::THEMES => 'wp-content/themes', + WordpressDirectory::Path::UPLOADS => 'wp-content/uploads', + WordpressDirectory::Path::LANGUAGES => 'wp-content/languages' + ) + end + + context '.default_path_for' do + context 'given a symbol :wp_config' do + it 'returns the wp-config.php default path' do + expect(described_class.default_path_for(:wp_config)).to eq 'wp-config.php' + end + end + end + + context '.path' do + let(:movefile) { Wordmove::Movefile.new({ config: movefile_path_for('Movefile') }, nil, false) } + let(:options) { movefile.options[:local] } + + context 'given an additional path as a string' do + it 'returns the absolute path of the folder joined with the additional one' do + wd = described_class.new(:uploads, options) + expect(wd.path('pirate')).to eq('~/dev/sites/your_site/wp-content/uploads/pirate') + end + end + + context 'without arguments' do + it 'returns the absolute path for the required folder' do + wd = described_class.new(:uploads, options) + expect(wd.path).to eq('~/dev/sites/your_site/wp-content/uploads') + end + end + end + + context '.url' do + let(:movefile) { Wordmove::Movefile.new({ config: movefile_path_for('Movefile') }, nil, false) } + let(:options) { movefile.options[:local] } + + context 'given an additional path as a string' do + it 'returns the URL of the folder joined with the additional path' do + wd = described_class.new(:uploads, options) + expect(wd.url('pirate.png')).to eq('http://example.com/wp-content/uploads/pirate.png') + end + end + + context 'without arguments' do + it 'returns the URL for the required folder' do + wd = described_class.new(:uploads, options) + expect(wd.url).to eq('http://example.com/wp-content/uploads') + end + end + end + + context '.relative_path' do + context 'given a movefile with custom paths defined' do + let(:movefile) do + Wordmove::Movefile.new({ config: movefile_path_for('custom_paths') }, nil, false) + end + let(:options) { movefile.options[:remote] } + + context 'given addional path as argument' do + it 'returns the customized relative path joined with the additional one' do + wd = described_class.new(:uploads, options) + expect(wd.relative_path('additional')).to eq('wp-content/pirate/additional') + end + end + end + end +end diff --git a/spec/wpcli_spec.rb b/spec/wpcli_spec.rb new file mode 100644 index 00000000..200bd74b --- /dev/null +++ b/spec/wpcli_spec.rb @@ -0,0 +1,64 @@ +require 'spec_helper' + +describe Wordmove::WpcliHelpers do + subject do + Class.new do + include Wordmove::WpcliHelpers + end + end + + let(:a_context) do + OrganizerContextFactory.make_for(Wordmove::Actions::AdaptLocalDb, :pull) + end + + context '.wpcli_config_path' do + context 'when having wp-cli.yml in wordpress root directory' do + it 'returns the path configured in YAML file' do + a_context[:local_options][:wordpress_path] = fixture_folder_root_relative_path + + expect(subject.wpcli_config_path(a_context)).to eq('/path/to/steak') + end + end + + context 'when called with a path instead of a config' do + it 'not finding any path using wpcli it will return the path passed as argument' do + expect(subject.wpcli_config_path('/path/to/biscuit')).to eq('/path/to/biscuit') + end + end + + context 'when there is not wp-cli.yml in wordpress root directory' do + context 'if wp-cli is configured someway with a custom path' do + before do + allow(subject) + .to receive(:`) + .with('wp cli param-dump --with-values --allow-root') + .and_return("{\"path\":{\"current\":\"\/path\/to\/pudding\"}}") + end + + it 'returns the configured path' do + expect(subject.wpcli_config_path(a_context)).to eq('/path/to/pudding') + end + + context 'when called with a path instead of a config' do + it 'returns the configured path anyway' do + expect(subject.wpcli_config_path('/path/to/biscuit')).to eq('/path/to/pudding') + end + end + end + + context 'when wp-cli param-dump returns empty string' do + before do + allow(subject) + .to receive(:`) + .with('wp cli param-dump --with-values --allow-root') + .and_return('') + end + + it 'will fallback to movefile config without raising errors' do + expect { subject.wpcli_config_path(a_context) }.to_not raise_error + # Would have been JSON::ParserError + end + end + end + end +end diff --git a/wordmove.gemspec b/wordmove.gemspec index 7fef0e21..0fc6999d 100644 --- a/wordmove.gemspec +++ b/wordmove.gemspec @@ -3,48 +3,55 @@ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) require 'wordmove/version' Gem::Specification.new do |spec| - spec.name = "wordmove" + spec.name = 'wordmove' spec.version = Wordmove::VERSION + spec.metadata = { 'rubygems_mfa_required' => 'true' } spec.authors = [ - "Stefano Verna", "Ju Liu", "Fabrizio Monti", "Alessandro Fazzi", "Filippo Gangi Dino" + 'Stefano Verna', 'Ju Liu', 'Fabrizio Monti', 'Alessandro Fazzi', 'Filippo Gangi Dino' ] spec.email = [ - "stefano.verna@welaika.com", - "ju.liu@welaika.com", - "fabrizio.monti@welaika.com", - "alessandro.fazzi@welaika.com", - "filippo.gangidino@welaika.com" + 'stefano.verna@welaika.com', + 'ju.liu@welaika.com', + 'fabrizio.monti@welaika.com', + 'alessandro.fazzi@welaika.com', + 'filippo.gangidino@welaika.com' ] - spec.summary = "Wordmove, Capistrano for Wordpress" - spec.description = "Wordmove deploys your WordPress websites at the speed of light." - spec.homepage = "https://github.com/welaika/wordmove" - spec.license = "MIT" + spec.summary = 'Wordmove, Capistrano for Wordpress' + spec.description = 'Wordmove deploys your WordPress websites at the speed of light.' + spec.homepage = 'https://github.com/welaika/wordmove' + spec.license = 'MIT' spec.files = `git ls-files -z` .split("\x0") .reject { |f| f.match(%r{^(test|spec|features)/}) } - spec.bindir = "exe" + spec.bindir = 'exe' spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } - spec.require_paths = ["lib"] - - spec.add_runtime_dependency "activesupport", '~> 6.1' - spec.add_runtime_dependency "colorize", "~> 0.8.1" - spec.add_runtime_dependency "dotenv", "~> 2.7.5" - spec.add_runtime_dependency "kwalify", "~> 0" - spec.add_runtime_dependency "photocopier", "~> 1.4", ">= 1.4.0" - spec.add_runtime_dependency "thor", "~> 0.20.3" - - spec.required_ruby_version = ">= 2.6.0" - - spec.add_development_dependency "bundler", "~> 2.0" - spec.add_development_dependency "priscilla", "~> 1.0" - spec.add_development_dependency "pry-byebug", "~> 3.1" - spec.add_development_dependency "rake", "~> 13.0.1" - spec.add_development_dependency "rspec", "~> 3.9" - spec.add_development_dependency "rubocop", "~> 0.76.0" - spec.add_development_dependency "simplecov", "~> 0.17.1" + spec.require_paths = ['lib'] + + spec.add_runtime_dependency 'activesupport', '~> 6.1' + spec.add_runtime_dependency 'colorize', '~> 0.8.1' + spec.add_runtime_dependency 'dotenv', '~> 2.7.5' + spec.add_runtime_dependency 'dry-configurable', '~> 0.13.0' + spec.add_runtime_dependency 'kwalify', '~> 0.7.2' + spec.add_runtime_dependency 'light-service', '~> 0.17.0' + spec.add_runtime_dependency 'photocopier', '~> 1.4', '>= 1.4.1' + # spec.add_runtime_dependency 'thor', '~> 0.20.3' + spec.add_runtime_dependency 'dry-cli', '~> 0.7.0' + spec.add_runtime_dependency 'dry-files', '~> 0.1.0' + + spec.required_ruby_version = '>= 3.1.0' + + spec.add_development_dependency 'bundler', '~> 2.3.3' + spec.add_development_dependency 'debug', '~> 1.4.0' + spec.add_development_dependency 'rake', '~> 13.0.1' + spec.add_development_dependency 'rspec', '~> 3.9' + spec.add_development_dependency 'rubocop', '~> 1.24.0' + spec.add_development_dependency 'rubocop-rspec', '~> 2.6.0' + spec.add_development_dependency 'simplecov', '~> 0.21.2' + spec.add_development_dependency 'yard' + spec.add_development_dependency 'yard-activesupport-concern' spec.post_install_message = <<-RAINBOW Starting from version 3.0.0 `database.charset` option is no longer accepted.