wordmove-ng 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.github/ISSUE_TEMPLATE/bug_report.md +40 -0
- data/.github/ISSUE_TEMPLATE/config.yml +11 -0
- data/.github/stale.yml +18 -0
- data/.github/workflows/release.yml +100 -0
- data/.github/workflows/ruby.yml +41 -0
- data/.gitignore +21 -0
- data/.release-please-manifest.json +3 -0
- data/.rspec +2 -0
- data/.rubocop.yml +50 -0
- data/.rubocop_todo.yml +89 -0
- data/.ruby-gemset +1 -0
- data/.ruby-version +1 -0
- data/.vscode/launch.json +73 -0
- data/CHANGELOG.md +78 -0
- data/CLAUDE.md +114 -0
- data/CONTRIBUTING.md +114 -0
- data/Gemfile +14 -0
- data/LICENSE +22 -0
- data/README.md +340 -0
- data/Rakefile +7 -0
- data/assets/images/wordmove-ng.png +0 -0
- data/bin/bundle +105 -0
- data/bin/bundler +17 -0
- data/bin/byebug +29 -0
- data/bin/coderay +29 -0
- data/bin/console +16 -0
- data/bin/htmldiff +29 -0
- data/bin/kwalify +29 -0
- data/bin/ldiff +29 -0
- data/bin/pry +29 -0
- data/bin/rake +29 -0
- data/bin/rspec +29 -0
- data/bin/rubocop +29 -0
- data/bin/ruby-parse +29 -0
- data/bin/ruby-rewrite +29 -0
- data/bin/rumoji +29 -0
- data/bin/setup +7 -0
- data/bin/thor +29 -0
- data/bin/wordmove-ng +10 -0
- data/deploy/deploy.sh +3 -0
- data/exe/wordmove-ng +6 -0
- data/lib/wordmove/assets/wordmove_schema_global.yml +16 -0
- data/lib/wordmove/assets/wordmove_schema_local.yml +31 -0
- data/lib/wordmove/assets/wordmove_schema_remote.yml +167 -0
- data/lib/wordmove/cli.rb +134 -0
- data/lib/wordmove/deployer/base.rb +356 -0
- data/lib/wordmove/deployer/ssh.rb +339 -0
- data/lib/wordmove/doctor/movefile.rb +112 -0
- data/lib/wordmove/doctor/mysql.rb +136 -0
- data/lib/wordmove/doctor/rsync.rb +27 -0
- data/lib/wordmove/doctor/ssh.rb +90 -0
- data/lib/wordmove/doctor/wpcli.rb +43 -0
- data/lib/wordmove/doctor.rb +67 -0
- data/lib/wordmove/environments_list.rb +68 -0
- data/lib/wordmove/exceptions.rb +10 -0
- data/lib/wordmove/generators/movefile.rb +50 -0
- data/lib/wordmove/generators/movefile.yml +104 -0
- data/lib/wordmove/generators/movefile_adapter.rb +188 -0
- data/lib/wordmove/guardian.rb +37 -0
- data/lib/wordmove/hook.rb +128 -0
- data/lib/wordmove/logger.rb +165 -0
- data/lib/wordmove/movefile.rb +129 -0
- data/lib/wordmove/prerequisites.rb +57 -0
- data/lib/wordmove/sql_adapter/wpcli.rb +72 -0
- data/lib/wordmove/ssh_runner.rb +110 -0
- data/lib/wordmove/version.rb +3 -0
- data/lib/wordmove/wordpress_directory/path.rb +11 -0
- data/lib/wordmove/wordpress_directory.rb +41 -0
- data/lib/wordmove-ng.rb +4 -0
- data/lib/wordmove.rb +51 -0
- data/release-please-config.json +25 -0
- data/wordmove-ng.gemspec +62 -0
- metadata +318 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
require 'shellwords'
|
|
2
|
+
|
|
3
|
+
module Wordmove
|
|
4
|
+
class Hook
|
|
5
|
+
class << self
|
|
6
|
+
attr_writer :logger
|
|
7
|
+
|
|
8
|
+
def logger
|
|
9
|
+
@logger ||= Logger.new(STDOUT).tap { |l| l.level = Logger::DEBUG }
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# rubocop:disable-next Metrics/MethodLength
|
|
14
|
+
def self.run(action, step, cli_options)
|
|
15
|
+
movefile = Wordmove::Movefile.new(cli_options[:config])
|
|
16
|
+
options = movefile.fetch(false)
|
|
17
|
+
environment = movefile.environment(cli_options)
|
|
18
|
+
self.logger = Logger.new(STDOUT, movefile.secrets).tap { |l| l.level = Logger::DEBUG }
|
|
19
|
+
|
|
20
|
+
hooks = Wordmove::Hook::Config.new(
|
|
21
|
+
options[environment][:hooks],
|
|
22
|
+
action,
|
|
23
|
+
step
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
return if hooks.empty?
|
|
27
|
+
|
|
28
|
+
logger.task "Running #{action}/#{step} hooks"
|
|
29
|
+
|
|
30
|
+
hooks.all_commands.each do |command|
|
|
31
|
+
case command[:where]
|
|
32
|
+
when 'local'
|
|
33
|
+
Wordmove::Hook::Local.run(command, options[:local], cli_options[:simulate])
|
|
34
|
+
when 'remote'
|
|
35
|
+
Wordmove::Hook::Remote.run(command, options[environment], cli_options[:simulate])
|
|
36
|
+
else
|
|
37
|
+
next
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
Config = Struct.new(:options, :action, :step) do
|
|
43
|
+
def empty?
|
|
44
|
+
all_commands.empty?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def all_commands
|
|
48
|
+
return [] if empty_step?
|
|
49
|
+
|
|
50
|
+
options[action][step] || []
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def local_commands
|
|
54
|
+
return [] if empty_step?
|
|
55
|
+
|
|
56
|
+
options[action][step]
|
|
57
|
+
.select { |hook| hook[:where] == 'local' } || []
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def remote_commands
|
|
61
|
+
return [] if empty_step?
|
|
62
|
+
|
|
63
|
+
options[action][step]
|
|
64
|
+
.select { |hook| hook[:where] == 'remote' } || []
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def empty_step?
|
|
70
|
+
return true unless options
|
|
71
|
+
return true if options[action].nil?
|
|
72
|
+
return true if options[action][step].nil?
|
|
73
|
+
return true if options[action][step].empty?
|
|
74
|
+
|
|
75
|
+
false
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
class Local
|
|
80
|
+
def self.logger
|
|
81
|
+
Wordmove::Hook.logger
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def self.run(command_hash, options, simulate = false)
|
|
85
|
+
wordpress_path = Shellwords.escape(options[:wordpress_path].to_s)
|
|
86
|
+
logger.task_step true, "Exec command: #{command_hash[:command]}"
|
|
87
|
+
|
|
88
|
+
return true if simulate
|
|
89
|
+
|
|
90
|
+
stdout_return = `cd #{wordpress_path} && #{command_hash[:command]} 2>&1`
|
|
91
|
+
logger.task_step true, "Output: #{stdout_return}"
|
|
92
|
+
|
|
93
|
+
if $CHILD_STATUS.exitstatus.zero?
|
|
94
|
+
logger.success ""
|
|
95
|
+
else
|
|
96
|
+
logger.error "Error code: #{$CHILD_STATUS.exitstatus}"
|
|
97
|
+
raise Wordmove::LocalHookException unless command_hash[:raise].eql? false
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
class Remote
|
|
103
|
+
def self.logger
|
|
104
|
+
Wordmove::Hook.logger
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def self.run(command_hash, options, simulate = false)
|
|
108
|
+
wordpress_path = Shellwords.escape(options[:wordpress_path].to_s)
|
|
109
|
+
logger.task_step false, "Exec command: #{command_hash[:command]}"
|
|
110
|
+
|
|
111
|
+
return true if simulate
|
|
112
|
+
|
|
113
|
+
runner = Wordmove::SshRunner.new(options[:ssh])
|
|
114
|
+
stdout, stderr, exit_code =
|
|
115
|
+
runner.run("cd #{wordpress_path} && #{command_hash[:command]}")
|
|
116
|
+
|
|
117
|
+
if exit_code.zero?
|
|
118
|
+
logger.task_step false, "Output: #{stdout}"
|
|
119
|
+
logger.success ""
|
|
120
|
+
else
|
|
121
|
+
logger.task_step false, "Output: #{stderr}"
|
|
122
|
+
logger.error "Error code #{exit_code}"
|
|
123
|
+
raise Wordmove::RemoteHookException unless command_hash[:raise].eql? false
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
require 'shellwords'
|
|
2
|
+
|
|
3
|
+
module Wordmove
|
|
4
|
+
class Logger < ::Logger
|
|
5
|
+
MAX_LINE = 70
|
|
6
|
+
|
|
7
|
+
def initialize(device, strings_to_hide = [])
|
|
8
|
+
super(device, formatter: proc { |_severity, _datetime, _progname, message|
|
|
9
|
+
formatted_message = if strings_to_hide.empty?
|
|
10
|
+
message
|
|
11
|
+
else
|
|
12
|
+
message.gsub(
|
|
13
|
+
Regexp.new(
|
|
14
|
+
strings_to_hide.map { |string| Regexp.escape(string) }.join('|')
|
|
15
|
+
),
|
|
16
|
+
'[secret]'
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
"\n#{formatted_message}\n"
|
|
21
|
+
})
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def task(title)
|
|
25
|
+
prefix = "▬" * 2
|
|
26
|
+
title = " #{title} "
|
|
27
|
+
padding = "▬" * padding_length(title)
|
|
28
|
+
add(INFO, prefix + title.light_white + padding)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def task_step(local_step, title)
|
|
32
|
+
if local_step
|
|
33
|
+
add(INFO, " local".cyan + " | ".black + format_task_step(title))
|
|
34
|
+
else
|
|
35
|
+
add(INFO, " remote".yellow + " | ".black + format_task_step(title))
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def error(message)
|
|
40
|
+
add(ERROR, " ❌ error".red + " | ".black + message.to_s)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def success(message)
|
|
44
|
+
add(INFO, " ✅ success".green + " | ".black + message.to_s)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def debug(message)
|
|
48
|
+
add(DEBUG, " 🛠 debug".magenta + " | ".black + message.to_s)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def warn(message)
|
|
52
|
+
add(WARN, " ⚠️ warning".yellow + " | ".black + message.to_s)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def info(message)
|
|
56
|
+
add(INFO, " ℹ️ info".yellow + " | ".black + message.to_s)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def plain(message)
|
|
60
|
+
add(INFO, message.to_s)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def padding_length(line)
|
|
66
|
+
result = MAX_LINE - line.length
|
|
67
|
+
result.positive? ? result : 0
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def format_task_step(title)
|
|
71
|
+
message = title.to_s
|
|
72
|
+
return message if passthrough_task_step?(message)
|
|
73
|
+
|
|
74
|
+
lines = message.lines(chomp: true).reject(&:empty?)
|
|
75
|
+
return summarized_single_line_command(message) if lines.size <= 1
|
|
76
|
+
return mysql_import_summary(message) if mysql_import_script?(message)
|
|
77
|
+
|
|
78
|
+
"run shell script"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def passthrough_task_step?(message)
|
|
82
|
+
prefixes = [
|
|
83
|
+
"Exec command:",
|
|
84
|
+
"Output:",
|
|
85
|
+
"download ",
|
|
86
|
+
"delete:",
|
|
87
|
+
"get:",
|
|
88
|
+
"put:",
|
|
89
|
+
"get_directory:",
|
|
90
|
+
"put_directory:"
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
return true if prefixes.any? { |prefix| message.start_with?(prefix) }
|
|
94
|
+
return false if message.include?("\n")
|
|
95
|
+
|
|
96
|
+
summarized_single_line_command(message) == message
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def mysql_import_script?(message)
|
|
100
|
+
message.include?('tmp_dump="$(mktemp)"') &&
|
|
101
|
+
message.include?('COMMIT;') &&
|
|
102
|
+
message.include?('--init-command=')
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def mysql_import_summary(message)
|
|
106
|
+
dump_path = message[%r{head -n 1 (.+?) 2>/dev/null \|\| true}, 1]
|
|
107
|
+
database = message[/--database=([^\s]+)/, 1]
|
|
108
|
+
|
|
109
|
+
summary = +"import SQL dump"
|
|
110
|
+
summary << " #{dump_path}" if dump_path
|
|
111
|
+
summary << " into database #{database}" if database
|
|
112
|
+
summary << " (strip sandbox header, append COMMIT)"
|
|
113
|
+
summary
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def summarized_single_line_command(message)
|
|
117
|
+
mysql_dump_summary(message) ||
|
|
118
|
+
gzip_summary(message) ||
|
|
119
|
+
wp_search_replace_summary(message) ||
|
|
120
|
+
message
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def mysql_dump_summary(message)
|
|
124
|
+
return unless message.include?('--result-file=')
|
|
125
|
+
return unless message.include?('mariadb-dump') || message.include?('mysqldump')
|
|
126
|
+
|
|
127
|
+
args = Shellwords.split(message)
|
|
128
|
+
dump_path = args.find { |arg| arg.start_with?('--result-file=') }&.split('=', 2)&.last
|
|
129
|
+
database = args.last
|
|
130
|
+
return unless dump_path && database
|
|
131
|
+
|
|
132
|
+
"dump database #{database} to #{dump_path}"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def gzip_summary(message)
|
|
136
|
+
return unless message.start_with?('gzip ')
|
|
137
|
+
|
|
138
|
+
args = Shellwords.split(message)
|
|
139
|
+
return unless args.length == 4 && args[2] == '-f'
|
|
140
|
+
|
|
141
|
+
case args[1]
|
|
142
|
+
when '-9' then "compress #{args[3]}"
|
|
143
|
+
when '-d' then "decompress #{args[3]}"
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def wp_search_replace_summary(message)
|
|
148
|
+
return unless message.start_with?('wp search-replace ')
|
|
149
|
+
|
|
150
|
+
args = Shellwords.split(message)
|
|
151
|
+
return if args.length < 4
|
|
152
|
+
|
|
153
|
+
path = args.find { |arg| arg.start_with?('--path=') }&.split('=', 2)&.last
|
|
154
|
+
positional = args.drop(2).reject { |arg| arg.start_with?('--') }
|
|
155
|
+
return if positional.length < 2
|
|
156
|
+
|
|
157
|
+
search = positional[0]
|
|
158
|
+
replace = positional[1]
|
|
159
|
+
|
|
160
|
+
summary = "wp search-replace #{search} -> #{replace}"
|
|
161
|
+
summary << " in #{path}" if path
|
|
162
|
+
summary
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
module Wordmove
|
|
2
|
+
class Movefile
|
|
3
|
+
attr_reader :logger, :name, :start_dir
|
|
4
|
+
|
|
5
|
+
def initialize(name = nil, start_dir = current_dir)
|
|
6
|
+
@logger = Logger.new(STDOUT).tap { |l| l.level = Logger::DEBUG }
|
|
7
|
+
@name = name
|
|
8
|
+
@start_dir = start_dir
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def fetch(verbose = true)
|
|
12
|
+
entries = if name.nil?
|
|
13
|
+
Dir["#{File.join(start_dir, '{M,m}ovefile')}{,.yml,.yaml}"]
|
|
14
|
+
else
|
|
15
|
+
Dir["#{File.join(start_dir, name)}{,.yml,.yaml}"]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
if entries.empty?
|
|
19
|
+
if last_dir?(start_dir)
|
|
20
|
+
raise MovefileNotFound, "Could not find a valid Movefile. Searched " \
|
|
21
|
+
"for filename \"#{name}\" in folder \"#{start_dir}\""
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
@start_dir = upper_dir(start_dir)
|
|
25
|
+
return fetch(verbose)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
found = entries.first
|
|
29
|
+
logger.task("Using Movefile: #{found}") if verbose == true
|
|
30
|
+
safe_load_yaml(ERB.new(File.read(found)).result).deep_symbolize_keys!
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def load_dotenv(cli_options = {})
|
|
34
|
+
env = environment(cli_options)
|
|
35
|
+
env_files = Dir[File.join(start_dir, ".env{.#{env},}")]
|
|
36
|
+
|
|
37
|
+
found_env = env_files.first
|
|
38
|
+
|
|
39
|
+
return false unless found_env.present?
|
|
40
|
+
|
|
41
|
+
logger.info("Using .env file: #{found_env}")
|
|
42
|
+
Dotenv.load(found_env)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def environment(cli_options = {})
|
|
46
|
+
options = fetch(false)
|
|
47
|
+
available_enviroments = extract_available_envs(options)
|
|
48
|
+
options.merge!(cli_options).deep_symbolize_keys!
|
|
49
|
+
|
|
50
|
+
if options[:environment] != 'local'
|
|
51
|
+
if available_enviroments.size > 1 && options[:environment].nil?
|
|
52
|
+
raise(
|
|
53
|
+
UndefinedEnvironment,
|
|
54
|
+
"You need to specify an environment with --environment parameter"
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
if options[:environment].present? && !available_enviroments.include?(options[:environment].to_sym)
|
|
59
|
+
raise UndefinedEnvironment, "No environment found for \"#{options[:environment]}\". " \
|
|
60
|
+
"Available Environments: #{available_enviroments.join(' ')}"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
(options[:environment] || available_enviroments.first).to_sym
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The four values wp search-replace rewrites when syncing +environment+.
|
|
68
|
+
# If one of them is a proper prefix of another, the replacement of the
|
|
69
|
+
# shorter one also rewrites every occurrence of the longer one, e.g.
|
|
70
|
+
# replacing "https://site.test" turns "https://site.test.backup" into
|
|
71
|
+
# "https://example.com.backup". Returns [[short, long], ...] pairs.
|
|
72
|
+
def prefix_collisions(environment)
|
|
73
|
+
options = fetch(false)
|
|
74
|
+
terms = %i[vhost wordpress_path].flat_map do |key|
|
|
75
|
+
[options.dig(:local, key), options.dig(environment.to_sym, key)]
|
|
76
|
+
end
|
|
77
|
+
terms = terms.compact.map(&:to_s).reject(&:empty?).uniq
|
|
78
|
+
|
|
79
|
+
terms.product(terms).select do |short, long|
|
|
80
|
+
short != long && long.start_with?(short)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def secrets
|
|
85
|
+
options = fetch(false)
|
|
86
|
+
|
|
87
|
+
secrets = []
|
|
88
|
+
options.each_key do |env|
|
|
89
|
+
secrets << options.dig(env, :database, :password)
|
|
90
|
+
secrets << options.dig(env, :database, :host)
|
|
91
|
+
secrets << options.dig(env, :vhost)
|
|
92
|
+
secrets << options.dig(env, :ssh, :password)
|
|
93
|
+
secrets << options.dig(env, :ssh, :host)
|
|
94
|
+
secrets << options.dig(env, :wordpress_path)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
secrets.compact.delete_if(&:empty?)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
def extract_available_envs(options)
|
|
103
|
+
options.keys.map(&:to_sym) - %i[local global]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def last_dir?(directory)
|
|
107
|
+
directory == "/" || File.exist?(File.join(directory, 'wp-config.php'))
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def upper_dir(directory)
|
|
111
|
+
File.expand_path(File.join(directory, '..'))
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def current_dir
|
|
115
|
+
'.'
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def safe_load_yaml(content)
|
|
119
|
+
YAML.safe_load(
|
|
120
|
+
content,
|
|
121
|
+
permitted_classes: [],
|
|
122
|
+
permitted_symbols: [],
|
|
123
|
+
aliases: true
|
|
124
|
+
)
|
|
125
|
+
rescue ArgumentError
|
|
126
|
+
YAML.safe_load(content, [], [], true)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
require 'open3'
|
|
2
|
+
require 'shellwords'
|
|
3
|
+
|
|
4
|
+
module Wordmove
|
|
5
|
+
# Checks that the external binaries a database sync relies on exist, either
|
|
6
|
+
# on this machine or on a remote host reached through an SshRunner.
|
|
7
|
+
#
|
|
8
|
+
# A requirement is a binary name, or an array of alternatives of which at
|
|
9
|
+
# least one must exist (e.g. mysql or mariadb).
|
|
10
|
+
module Prerequisites
|
|
11
|
+
# Needed where the dump is produced.
|
|
12
|
+
DB_SOURCE = ['gzip', %w[mysqldump mariadb-dump]].freeze
|
|
13
|
+
# Needed where the dump is imported and adapted.
|
|
14
|
+
DB_TARGET = ['gzip', %w[mysql mariadb], 'wp'].freeze
|
|
15
|
+
# Everything a remote may be asked for, used by the doctor.
|
|
16
|
+
REMOTE_ALL = ['rsync', 'gzip', %w[mysql mariadb], %w[mysqldump mariadb-dump], 'wp'].freeze
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
# POSIX sh snippet printing, one per line, the requirements that are not
|
|
20
|
+
# satisfied. Alternatives are printed joined with "|".
|
|
21
|
+
def probe_command(requirements)
|
|
22
|
+
requirements.map do |requirement|
|
|
23
|
+
names = Array(requirement)
|
|
24
|
+
test = names.map { |n| "command -v #{Shellwords.escape(n)} >/dev/null 2>&1" }.join(' || ')
|
|
25
|
+
"#{test} || echo #{Shellwords.escape(names.join('|'))}"
|
|
26
|
+
end.join('; ')
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def missing_locally(requirements)
|
|
30
|
+
stdout, _stderr, _status = Open3.capture3('sh', '-c', probe_command(requirements))
|
|
31
|
+
parse(stdout)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Returns the missing requirements, or raises ShellCommandError when the
|
|
35
|
+
# probe itself could not run (e.g. authentication failed).
|
|
36
|
+
def missing_remotely(runner, requirements)
|
|
37
|
+
stdout, stderr, exit_code = runner.run(probe_command(requirements))
|
|
38
|
+
unless exit_code.zero?
|
|
39
|
+
raise ShellCommandError,
|
|
40
|
+
"Could not check the remote prerequisites (exit code #{exit_code}): #{stderr}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
parse(stdout)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def describe(missing)
|
|
47
|
+
missing.map { |names| names.join(' or ') }.join(', ')
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def parse(stdout)
|
|
53
|
+
stdout.lines.map(&:strip).reject(&:empty?).map { |line| line.split('|') }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
require 'shellwords'
|
|
2
|
+
|
|
3
|
+
module Wordmove
|
|
4
|
+
module SqlAdapter
|
|
5
|
+
class Wpcli
|
|
6
|
+
attr_accessor :sql_content
|
|
7
|
+
attr_reader :from, :to, :local_path, :remote
|
|
8
|
+
|
|
9
|
+
# When +remote+ is true the command is meant to be executed on the remote
|
|
10
|
+
# host: +local_path+ is then the remote wordpress_path and is used as-is,
|
|
11
|
+
# and no local wp-cli availability or configuration probing is done.
|
|
12
|
+
def initialize(source_config, dest_config, config_key, local_path, remote: false)
|
|
13
|
+
@from = source_config[config_key]
|
|
14
|
+
@to = dest_config[config_key]
|
|
15
|
+
@local_path = local_path
|
|
16
|
+
@remote = remote
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# `wp maintenance-mode activate|deactivate` for the install at +path+.
|
|
20
|
+
def self.maintenance_mode_command(action, path)
|
|
21
|
+
unless %i[activate deactivate].include?(action)
|
|
22
|
+
raise ArgumentError, "unknown maintenance mode action #{action.inspect}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
"wp maintenance-mode #{action} --path=#{Shellwords.escape(path)} --allow-root"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def command
|
|
29
|
+
unless remote || wp_in_path?
|
|
30
|
+
raise UnmetPeerDependencyError, "WP-CLI is not installed or not in your $PATH"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
opts = [
|
|
34
|
+
"--path=#{Shellwords.escape(cli_config_path)}",
|
|
35
|
+
Shellwords.escape(from.to_s),
|
|
36
|
+
Shellwords.escape(to.to_s),
|
|
37
|
+
"--quiet",
|
|
38
|
+
"--skip-columns=guid",
|
|
39
|
+
"--all-tables",
|
|
40
|
+
"--allow-root"
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
"wp search-replace #{opts.join(' ')}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def wp_in_path?
|
|
49
|
+
system('which wp > /dev/null 2>&1')
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def cli_config_path
|
|
53
|
+
return local_path if remote
|
|
54
|
+
|
|
55
|
+
load_from_yml || load_from_cli || local_path
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def load_from_yml
|
|
59
|
+
cli_config_path = File.join(local_path, "wp-cli.yml")
|
|
60
|
+
return unless File.exist?(cli_config_path)
|
|
61
|
+
|
|
62
|
+
YAML.load_file(cli_config_path).with_indifferent_access["path"]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def load_from_cli
|
|
66
|
+
raw = `wp cli param-dump --allow-root --with-values`
|
|
67
|
+
cli_config = JSON.parse(raw, symbolize_names: true)
|
|
68
|
+
cli_config.dig(:path, :current)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
require 'open3'
|
|
2
|
+
require 'shellwords'
|
|
3
|
+
|
|
4
|
+
module Wordmove
|
|
5
|
+
# Runs remote commands and file transfers through the system `ssh` and `scp`
|
|
6
|
+
# binaries, so that authentication behaves exactly like the rsync transfers:
|
|
7
|
+
# ssh-agent, ~/.ssh/config, ProxyJump and modern signature algorithms all work
|
|
8
|
+
# without any Ruby-side SSH implementation.
|
|
9
|
+
#
|
|
10
|
+
# When no password is configured the connection runs in BatchMode, so a failed
|
|
11
|
+
# key authentication is reported as an error instead of an interactive prompt.
|
|
12
|
+
class SshRunner
|
|
13
|
+
attr_reader :options
|
|
14
|
+
|
|
15
|
+
def initialize(ssh_options)
|
|
16
|
+
@options = (ssh_options || {}).dup
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Runs +command+ on the remote host with a POSIX sh, regardless of the
|
|
20
|
+
# remote user's login shell, and returns [stdout, stderr, exit_code].
|
|
21
|
+
def run(command)
|
|
22
|
+
execute(ssh_argv + [self.class.shell_wrap(command)])
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Every command wordmove generates assumes POSIX sh syntax ($(...), &&,
|
|
26
|
+
# multi-line scripts), which a fish or csh login shell would mangle.
|
|
27
|
+
def self.shell_wrap(command)
|
|
28
|
+
# Block form on purpose: in a replacement string \' means "post-match".
|
|
29
|
+
quoted = command.gsub("'") { %q('\'') }
|
|
30
|
+
"sh -c '#{quoted}'"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def get(remote_path, local_path)
|
|
34
|
+
execute(scp_argv + [remote_file(remote_path), local_path])
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def put(local_path, remote_path)
|
|
38
|
+
execute(scp_argv + [local_path, remote_file(remote_path)])
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def delete(remote_path)
|
|
42
|
+
run("rm -rf #{Shellwords.escape(remote_path)}")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def ssh_argv
|
|
46
|
+
wrap_password(['ssh'] + common_arguments + port_arguments('-p') + [target])
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def scp_argv
|
|
50
|
+
wrap_password(['scp', '-q'] + common_arguments + port_arguments('-P'))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def password?
|
|
54
|
+
options[:password].present?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def execute(argv)
|
|
60
|
+
stdout, stderr, status = Open3.capture3(*argv)
|
|
61
|
+
[stdout, stderr, status.exitstatus]
|
|
62
|
+
rescue Errno::ENOENT
|
|
63
|
+
binary = argv.first
|
|
64
|
+
raise UnmetPeerDependencyError,
|
|
65
|
+
"`#{binary}` is not installed or not in your $PATH; it is required for SSH " \
|
|
66
|
+
"database operations and remote hooks"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def target
|
|
70
|
+
user = options[:user]
|
|
71
|
+
user.present? ? "#{user}@#{options[:host]}" : options[:host].to_s
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def remote_file(path)
|
|
75
|
+
# scp hands the remote path to the remote shell, so it needs shell escaping.
|
|
76
|
+
"#{target}:#{Shellwords.escape(path)}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def common_arguments
|
|
80
|
+
arguments = []
|
|
81
|
+
arguments.push("-o", "BatchMode=yes") unless password?
|
|
82
|
+
arguments.push('-J', jump_host) if gateway?
|
|
83
|
+
arguments
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def port_arguments(flag)
|
|
87
|
+
return [] unless options[:port].present?
|
|
88
|
+
|
|
89
|
+
[flag, options[:port].to_s]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def wrap_password(argv)
|
|
93
|
+
return argv unless password?
|
|
94
|
+
|
|
95
|
+
['sshpass', '-p', options[:password].to_s] + argv
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def gateway?
|
|
99
|
+
options[:gateway].is_a?(Hash) && options[:gateway][:host].present?
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def jump_host
|
|
103
|
+
gateway = options[:gateway]
|
|
104
|
+
spec = gateway[:host].to_s
|
|
105
|
+
spec = "#{gateway[:user]}@#{spec}" if gateway[:user].present?
|
|
106
|
+
spec = "#{spec}:#{gateway[:port]}" if gateway[:port].present?
|
|
107
|
+
spec
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|