ey_cloud_server 1.5.0 → 1.5.1

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 CHANGED
@@ -1,15 +1,7 @@
1
1
  ---
2
- !binary "U0hBMQ==":
3
- metadata.gz: !binary |-
4
- ZmFkNjBkYjY1OTNhZjQwNjMxNjUxMmNmOTRmOWQyZDMxMDJlNTUzYQ==
5
- data.tar.gz: !binary |-
6
- YTA0NTBmMTAxYzk5ZmE5YjQ0MzViYjE4NWE2YjRjZGM5YWYxZDNiNQ==
2
+ SHA256:
3
+ metadata.gz: bfb2b6fa4059f5c796ba0cedbb4784c6a564a22adf1cd91dcc8efac32d44a1f6
4
+ data.tar.gz: e2dab140765f7438ccb743c40169a186e23f6b72d5acd36d5be5aaf759ca50a0
7
5
  SHA512:
8
- metadata.gz: !binary |-
9
- ZmRlZTQ1YThjMWU0N2RkMzVlNmFkNGY3YThmMDJhMzRjZTE4MzU4ZGJjOTAx
10
- NzM5MDc4OGZmNTMxNzM1OGI1ZTk4NDgxZjY5MTIwYmZmMTI1NmNjNjc0NmFl
11
- OTI3OWFmMjdiZGE0N2QwZmEyNjljYzQ0NTg0MjBmZWI3NTBiOWU=
12
- data.tar.gz: !binary |-
13
- MTdlZDkwYmQ1ZmNiYTJmY2U0YWMzMmQ2YjZmNzA5NWEzYTVlNTA4ODM1ZGZj
14
- ZTBkN2EwZmVlODczYTAxMjYwNzA3OTk3ODcxODlkOTQwZjYwZDNhNjE4Mjk2
15
- YThjMTZiNjBmMmY3NTE5Yjg0NjI2YjRkOGQwMDg0YmQ4OTY3ZGM=
6
+ metadata.gz: e0f3941d55b673e5fbe7c3fea6e41ad72447e556cdfcaa7e29ea7226929042ea8e1806bc566bab07f245ad23302b4ad244c4aedde0780c1e3101cda41c4a39e3
7
+ data.tar.gz: 0f9a2d0689d4ba021eef298a7df7786abe731d13aa8aba68a59b9ea0e0e5d6e8dc568723ed97e886c07d9fd875513260d3a44950c93cd054b84e81e7c66348c1
data/bin/eybackup CHANGED
@@ -16,5 +16,6 @@ rescue => e
16
16
  puts "******* Trace *******"
17
17
  puts e.inspect
18
18
  EY.notify_backup_error(e) unless STDOUT.isatty
19
+ exit 1
19
20
  end
20
21
 
@@ -1,3 +1,5 @@
1
+ require 'shellwords'
2
+
1
3
  module EY
2
4
  module Backup
3
5
  class MysqlEngine < Engine
@@ -45,10 +47,10 @@ module EY
45
47
 
46
48
  def check_if_replica
47
49
  err_msg="ERROR: Target host: '#{host}' is currently: "
48
- err_msg=err_msg + "a replica based on 'show slave status', " if db_replicating?
50
+ err_msg=err_msg + "a replica, " if db_replicating?
49
51
  err_msg=err_msg + "in read_only mode, " if read_only_on?
50
52
  err_msg=err_msg + "restores should be processed against the master."
51
-
53
+
52
54
  EY::Backup.logger.fatal(%Q{#{err_msg}}) if db_replicating? or read_only_on?
53
55
  end
54
56
 
@@ -90,10 +92,50 @@ module EY
90
92
  end
91
93
 
92
94
  def db_replicating?
93
- stdout = %x{mysql #{username_option} #{host_option} -BN -e"show slave status"|wc -l}
94
- stdout.to_i > 0
95
+ statement = replica_status_statement
96
+ stdout = %x(mysql #{username_option} #{host_option} -BN -e #{Shellwords.escape(statement)})
97
+ probe_status = $?
98
+
99
+ unless probe_status.success?
100
+ # The replication-status probe itself failed -- e.g. the statement
101
+ # is unsupported on this server version, or a connection/permission
102
+ # problem. We cannot prove the host is NOT a replica, so fail
103
+ # closed and treat it as a possible replica rather than silently
104
+ # letting the restore proceed (this is how MySQL 8.4's removal of
105
+ # `SHOW SLAVE STATUS` used to defeat this check entirely).
106
+ warn("Unable to determine replication status on '#{host}' via #{statement.inspect}: #{stdout.strip}; treating host as a possible replica.")
107
+ return true
108
+ end
109
+
110
+ !stdout.strip.empty?
95
111
  end
96
-
112
+
113
+ # `SHOW SLAVE STATUS` was deprecated in MySQL 8.0.22 and removed in
114
+ # 8.4, replaced by `SHOW REPLICA STATUS`.
115
+ def replica_status_statement
116
+ mysql_8_4_or_newer? ? "show replica status" : "show slave status"
117
+ end
118
+
119
+ def mysql_8_4_or_newer?
120
+ stdout = %x(mysql #{username_option} #{host_option} -BN -e #{Shellwords.escape('select @@version')})
121
+ return false unless $?.success?
122
+
123
+ version = stdout.strip
124
+ # MariaDB keeps its own version numbering (10.x, 11.x) and retains
125
+ # `SHOW SLAVE STATUS` across all versions, so we must not compare
126
+ # its major.minor against MySQL's 8.4 boundary.
127
+ return false if version =~ /mariadb/i
128
+
129
+ # Anchor at the start of the string so we only inspect the leading
130
+ # version tuple (e.g. "8.4.11-11" → "8.4"), not any later digit-dot-digit
131
+ # pattern in suffixes or build metadata.
132
+ numeric = version[/\A\d+\.\d+/]
133
+ return false unless numeric
134
+
135
+ major, minor = numeric.split('.').map(&:to_i)
136
+ major > 8 || (major == 8 && minor >= 4)
137
+ end
138
+
97
139
  def read_only_on?
98
140
  stdout = %x{mysql #{username_option} #{host_option} -BN -e"select @@global.read_only"}
99
141
  stdout.to_i == 1
@@ -105,13 +147,57 @@ module EY
105
147
  end
106
148
 
107
149
  def cycle_database(database_name)
108
- query = "show create database #{database_name} \\G"
109
- create_cmd = %x(mysql #{username_option} #{host_option} -NB -e "#{query}"|tail -n 1)
110
- create_cmd="Create database #{database_name}" if create_cmd==""
111
-
112
- %x(mysql #{username_option} #{host_option} -e 'DROP DATABASE IF EXISTS #{database_name}')
113
- %x(mysql #{username_option} #{host_option} -e '#{create_cmd}')
114
-
150
+ create_cmd = capture_create_database_statement(database_name)
151
+
152
+ unless create_cmd =~ /\ACREATE\s+DATABASE\b/i
153
+ raise "Refusing to restore '#{database_name}': could not obtain a valid CREATE DATABASE statement (got #{create_cmd.inspect}). The existing database has NOT been touched."
154
+ end
155
+
156
+ drop_database!(database_name)
157
+ recreate_database!(create_cmd, database_name)
158
+ end
159
+
160
+ def capture_create_database_statement(database_name)
161
+ query = "show create database `#{database_name}` \\G"
162
+ raw = %x(mysql #{username_option} #{host_option} -NB -e #{Shellwords.escape(query)})
163
+ capture_status = $?
164
+
165
+ # `-N` strips the field-name labels that `\G` would otherwise print,
166
+ # so the last non-blank line is the CREATE DATABASE statement text.
167
+ create_cmd = raw.to_s.split("\n").map(&:strip).reject(&:empty?).last.to_s
168
+
169
+ if create_cmd.empty? || !capture_status.success?
170
+ # No existing database to read from (e.g. this is the first-ever
171
+ # restore against a fresh target), or the probe itself failed.
172
+ # Fall back to a bare CREATE DATABASE so the restore can still
173
+ # proceed.
174
+ "CREATE DATABASE `#{database_name}`"
175
+ else
176
+ create_cmd
177
+ end
178
+ end
179
+
180
+ def drop_database!(database_name)
181
+ %x(mysql #{username_option} #{host_option} -e #{Shellwords.escape("DROP DATABASE IF EXISTS `#{database_name}`")})
182
+ raise "Failed to drop database '#{database_name}' prior to restore; the existing database was left intact." unless $?.success?
183
+ end
184
+
185
+ def recreate_database!(create_cmd, database_name)
186
+ # Feed the CREATE DATABASE statement to `mysql` on STDIN rather than
187
+ # interpolating it into a quoted shell `-e` argument. From MySQL
188
+ # 8.0.16 onward, `SHOW CREATE DATABASE` output can contain a
189
+ # single-quoted clause (e.g. `/*!80016 DEFAULT ENCRYPTION='N' */`)
190
+ # whose quote would otherwise terminate a single-quoted shell string
191
+ # and truncate the statement mid-SQL -- which is exactly what turned
192
+ # a syntax error into a dropped, never-recreated database.
193
+ IO.popen(['mysql', username_option, host_option], 'w') do |io|
194
+ io.write(create_cmd)
195
+ io.write(";\n")
196
+ end
197
+
198
+ unless $?.success?
199
+ raise "Failed to recreate database '#{database_name}' after DROP -- the database has been dropped and NOT recreated. Statement attempted: #{create_cmd.inspect}"
200
+ end
115
201
  end
116
202
 
117
203
  def suffix
@@ -127,10 +127,27 @@ module EY
127
127
  create_cmd = create_command(database_name).chomp
128
128
  if create_cmd == ''
129
129
  create_database(database_name)
130
+ unless $?.success?
131
+ raise "Failed to create database '#{database_name}' before restore."
132
+ end
130
133
  else
131
134
  check_connections(database_name)
132
135
  drop_database(database_name)
136
+ unless $?.success?
137
+ raise "Failed to drop database '#{database_name}' prior to restore; the existing database was left intact."
138
+ end
139
+ # Note: create_cmd is embedded inside a double-quoted shell argument
140
+ # rather than single-quoted, so the single-quote chars that appear
141
+ # in PostgreSQL CREATE DATABASE output (e.g. ENCODING 'UTF8') are
142
+ # not shell-special within double quotes and do not cause the
143
+ # statement-termination break seen in the MySQL path. The risk class
144
+ # is different but we still check the exit status so that a failed
145
+ # create (permissions, malformed content, etc.) is caught before
146
+ # the missing database causes a load failure.
133
147
  %x{PGPASSWORD='#{password}' psql -U#{username} -h #{host} -t postgres -c "#{create_cmd}"}
148
+ unless $?.success?
149
+ raise "Failed to recreate database '#{database_name}' after DROP -- the database has been dropped and NOT recreated."
150
+ end
134
151
  end
135
152
  end
136
153
 
@@ -1,5 +1,5 @@
1
1
  module EY
2
2
  module CloudServer
3
- VERSION = '1.5.0'
3
+ VERSION = '1.5.1'
4
4
  end
5
5
  end
@@ -0,0 +1,346 @@
1
+ # Unit tests for the two defects fixed in mysql_engine.rb (ey-all#204):
2
+ #
3
+ # A — cycle_database drops the database and never recreates it when MySQL
4
+ # 8.0.16+ injects a `DEFAULT ENCRYPTION='N'` single-quoted clause into
5
+ # `SHOW CREATE DATABASE` output, which broke out of the original
6
+ # single-quoted `-e` shell argument and truncated the SQL mid-statement.
7
+ #
8
+ # B — db_replicating? silently returned false on MySQL 8.4+ because
9
+ # `SHOW SLAVE STATUS` was removed there; it must fail closed (return
10
+ # true) whenever the probe itself errors, on any MySQL version.
11
+ #
12
+ # (Defect C — eybackup exiting 0 after a failed restore — is a one-line fix
13
+ # in bin/eybackup's top-level rescue block; the CLI entrypoint requires the
14
+ # full ey-flex / ey_enzyme dependency chain from the private NextGem gem
15
+ # server, which isn't available in this environment, so it isn't covered by
16
+ # an executable spec here. See the PR description.)
17
+ #
18
+ # Strategy: rather than re-implementing the engine's logic inline (which
19
+ # would test the test, not the code), these specs put a small fake `mysql`
20
+ # executable on PATH and let the REAL, unmodified engine methods run against
21
+ # it. The fake script's behavior (output, exit status) is controlled per
22
+ # example via environment variables, and it echoes back what it was given so
23
+ # we can assert on exactly what the engine sent it (in particular: whether
24
+ # the single-quote-containing CREATE DATABASE statement survives intact).
25
+ #
26
+ # This file intentionally does NOT require lib/ey_backup.rb (which pulls in
27
+ # ey-flex / ey_enzyme from NextGem); it loads only the engine class and its
28
+ # stdlib dependencies, so it runs on any modern Ruby + rspec (verified here
29
+ # under Ruby 3 / rspec 3 via Docker, since the project's pinned Ruby 1.9.3
30
+ # cannot run on this host). It therefore uses modern `expect`/`allow` syntax
31
+ # rather than the legacy suite's `.should`/`stub`, since it is not part of
32
+ # the (currently non-executable-in-this-environment) bundled spec run.
33
+
34
+ require 'forwardable'
35
+ require 'stringio'
36
+ require 'shellwords'
37
+ require 'fileutils'
38
+ require 'tmpdir'
39
+
40
+ # ---- Minimal EY::Backup bootstrap (avoids requiring ey-flex) -------------
41
+
42
+ module EY
43
+ module Backup
44
+ class << self
45
+ attr_accessor :logger, :tmp_dir, :log_dir
46
+ end
47
+
48
+ class Logger
49
+ extend Forwardable
50
+
51
+ attr_reader :stdout, :stderr
52
+
53
+ alias_method :fatal, :abort
54
+ public :fatal
55
+
56
+ def_delegator :stdout, :puts, :puts
57
+
58
+ def initialize(stdout = $stdout, stderr = $stderr)
59
+ @stdout, @stderr = stdout, stderr
60
+ end
61
+
62
+ def info(msg); stdout.puts("#{Time.now} #{msg}"); end
63
+ def warn(msg, db = nil); stdout.puts("#{Time.now} WARNING: #{msg}"); end
64
+ def error(msg, db = nil); stdout.puts("#{Time.now} ERROR: #{msg}"); end
65
+ def verbose(msg); end
66
+ def debug(msg); stdout.puts("#{Time.now} DEBUG: #{msg}"); end
67
+ def say(msg, newline = true); newline ? info(msg) : stdout.print(msg); end
68
+ end
69
+
70
+ module Logging
71
+ extend Forwardable
72
+ def_delegator EY::Backup, :logger
73
+ def_delegators :logger, :fatal, :error, :warn, :info, :verbose, :debug, :say
74
+ end
75
+ end
76
+ end
77
+
78
+ EY::Backup.logger = EY::Backup::Logger.new(StringIO.new, StringIO.new)
79
+
80
+ lib_dir = File.expand_path(File.dirname(__FILE__) + '/../../lib/ey_backup')
81
+ require lib_dir + '/spawner'
82
+ require lib_dir + '/base'
83
+ require lib_dir + '/engine'
84
+ require lib_dir + '/engines/mysql_engine'
85
+
86
+ # ---- Fake `mysql` binary --------------------------------------------------
87
+ #
88
+ # Routes on the `-e` query text (or, absent `-e`, treats the invocation as
89
+ # stdin-fed — exactly what recreate_database! does). Controlled entirely via
90
+ # environment variables so each example can script a distinct scenario.
91
+
92
+ FAKE_MYSQL_SCRIPT = <<~'SCRIPT'
93
+ #!/usr/bin/env bash
94
+ if [ -n "$FAKE_MYSQL_CALL_LOG" ]; then
95
+ printf '%s\n' "$*" >> "$FAKE_MYSQL_CALL_LOG"
96
+ fi
97
+
98
+ query=""
99
+ has_e=0
100
+ for arg in "$@"; do
101
+ if [ "$has_e" = "1" ]; then
102
+ query="$arg"
103
+ break
104
+ fi
105
+ if [ "$arg" = "-e" ]; then
106
+ has_e=1
107
+ fi
108
+ done
109
+
110
+ if [ "$has_e" = "0" ]; then
111
+ # No -e flag => stdin-fed invocation (recreate_database!).
112
+ if [ -n "$FAKE_MYSQL_STDIN_CAPTURE_FILE" ]; then
113
+ cat > "$FAKE_MYSQL_STDIN_CAPTURE_FILE"
114
+ else
115
+ cat > /dev/null
116
+ fi
117
+ exit "${FAKE_MYSQL_STDIN_EXIT:-0}"
118
+ fi
119
+
120
+ case "$query" in
121
+ "select @@version")
122
+ printf '%s' "$FAKE_MYSQL_VERSION"
123
+ exit "${FAKE_MYSQL_VERSION_EXIT:-0}"
124
+ ;;
125
+ "show replica status")
126
+ printf '%s' "$FAKE_MYSQL_REPLICA_OUTPUT"
127
+ exit "${FAKE_MYSQL_REPLICA_EXIT:-0}"
128
+ ;;
129
+ "show slave status")
130
+ printf '%s' "$FAKE_MYSQL_SLAVE_OUTPUT"
131
+ exit "${FAKE_MYSQL_SLAVE_EXIT:-0}"
132
+ ;;
133
+ show\ create\ database*)
134
+ printf '%s' "$FAKE_MYSQL_CREATE_OUTPUT"
135
+ exit "${FAKE_MYSQL_CREATE_EXIT:-0}"
136
+ ;;
137
+ DROP\ DATABASE*)
138
+ exit "${FAKE_MYSQL_DROP_EXIT:-0}"
139
+ ;;
140
+ *)
141
+ exit 0
142
+ ;;
143
+ esac
144
+ SCRIPT
145
+
146
+ RSpec.describe EY::Backup::MysqlEngine do
147
+ around(:each) do |example|
148
+ @fake_bin_dir = Dir.mktmpdir('fake-mysql-bin')
149
+ script_path = File.join(@fake_bin_dir, 'mysql')
150
+ File.write(script_path, FAKE_MYSQL_SCRIPT)
151
+ FileUtils.chmod('+x', script_path)
152
+
153
+ @call_log = File.join(@fake_bin_dir, 'calls.log')
154
+ ENV['FAKE_MYSQL_CALL_LOG'] = @call_log
155
+
156
+ original_path = ENV['PATH']
157
+ ENV['PATH'] = "#{@fake_bin_dir}:#{original_path}"
158
+ begin
159
+ example.run
160
+ ensure
161
+ ENV['PATH'] = original_path
162
+ %w[
163
+ FAKE_MYSQL_CALL_LOG FAKE_MYSQL_VERSION FAKE_MYSQL_VERSION_EXIT
164
+ FAKE_MYSQL_REPLICA_OUTPUT FAKE_MYSQL_REPLICA_EXIT
165
+ FAKE_MYSQL_SLAVE_OUTPUT FAKE_MYSQL_SLAVE_EXIT
166
+ FAKE_MYSQL_CREATE_OUTPUT FAKE_MYSQL_CREATE_EXIT
167
+ FAKE_MYSQL_DROP_EXIT FAKE_MYSQL_STDIN_EXIT
168
+ FAKE_MYSQL_STDIN_CAPTURE_FILE
169
+ ].each { |k| ENV.delete(k) }
170
+ FileUtils.remove_entry(@fake_bin_dir)
171
+ end
172
+ end
173
+
174
+ let(:engine) do
175
+ described_class.new('root', '', 'localhost', nil, true, false, false, false)
176
+ end
177
+
178
+ def call_log
179
+ File.exist?(@call_log) ? File.readlines(@call_log) : []
180
+ end
181
+
182
+ # =========================================================================
183
+ # B — replication-status detection on MySQL 8.4+ (and fail-closed on error)
184
+ # =========================================================================
185
+
186
+ describe '#db_replicating?' do
187
+ context 'on MySQL 8.4+, host is a replica' do
188
+ it 'uses SHOW REPLICA STATUS and returns true' do
189
+ ENV['FAKE_MYSQL_VERSION'] = '8.4.11-11'
190
+ ENV['FAKE_MYSQL_REPLICA_OUTPUT'] = "Source_Host\treplica-source\tSource_Port\t3306\n"
191
+ ENV['FAKE_MYSQL_REPLICA_EXIT'] = '0'
192
+
193
+ expect(engine.db_replicating?).to eq(true)
194
+ expect(call_log.any? { |l| l.include?('show replica status') }).to eq(true)
195
+ expect(call_log.any? { |l| l.include?('show slave status') }).to eq(false)
196
+ end
197
+ end
198
+
199
+ context 'on MySQL 8.4+, host is NOT a replica (plain master)' do
200
+ it 'uses SHOW REPLICA STATUS and returns false' do
201
+ ENV['FAKE_MYSQL_VERSION'] = '8.4.11-11'
202
+ ENV['FAKE_MYSQL_REPLICA_OUTPUT'] = ''
203
+ ENV['FAKE_MYSQL_REPLICA_EXIT'] = '0'
204
+
205
+ expect(engine.db_replicating?).to eq(false)
206
+ end
207
+ end
208
+
209
+ context 'on MySQL 8.0 (below the 8.4 cutover), host is a replica' do
210
+ it 'uses SHOW SLAVE STATUS and returns true' do
211
+ ENV['FAKE_MYSQL_VERSION'] = '8.0.16'
212
+ ENV['FAKE_MYSQL_SLAVE_OUTPUT'] = "Slave_IO_State\tWaiting\n"
213
+ ENV['FAKE_MYSQL_SLAVE_EXIT'] = '0'
214
+
215
+ expect(engine.db_replicating?).to eq(true)
216
+ expect(call_log.any? { |l| l.include?('show slave status') }).to eq(true)
217
+ end
218
+ end
219
+
220
+ context 'the pre-fix bug scenario: MySQL 8.4 but the probe uses the removed statement' do
221
+ it 'fails closed (treats as a possible replica) rather than silently returning false' do
222
+ ENV['FAKE_MYSQL_VERSION'] = '8.4.11-11'
223
+ # Simulate SHOW REPLICA STATUS itself erroring (e.g. permissions, or
224
+ # some future removal) -- the probe fails outright.
225
+ ENV['FAKE_MYSQL_REPLICA_OUTPUT'] = "ERROR 1064 (42000): syntax error"
226
+ ENV['FAKE_MYSQL_REPLICA_EXIT'] = '1'
227
+
228
+ expect(engine.db_replicating?).to eq(true)
229
+ end
230
+ end
231
+
232
+ context 'probe fails for a connectivity reason' do
233
+ it 'fails closed (treats as a possible replica)' do
234
+ ENV['FAKE_MYSQL_VERSION'] = '8.4.0'
235
+ ENV['FAKE_MYSQL_REPLICA_OUTPUT'] = "ERROR 2003 (HY000): Can't connect"
236
+ ENV['FAKE_MYSQL_REPLICA_EXIT'] = '1'
237
+
238
+ expect(engine.db_replicating?).to eq(true)
239
+ end
240
+ end
241
+ end
242
+
243
+ describe '#mysql_8_4_or_newer?' do
244
+ [
245
+ ['8.4.0', true],
246
+ ['8.4.11-11', true],
247
+ ['9.0.0', true],
248
+ ['8.3.9', false],
249
+ ['8.0.16', false],
250
+ ['5.7.36', false],
251
+ ['10.6.12-MariaDB', false],
252
+ ].each do |version, expected|
253
+ it "returns #{expected} for server version #{version}" do
254
+ ENV['FAKE_MYSQL_VERSION'] = version
255
+ ENV['FAKE_MYSQL_VERSION_EXIT'] = '0'
256
+ expect(engine.mysql_8_4_or_newer?).to eq(expected)
257
+ end
258
+ end
259
+
260
+ it 'returns false (safe default) when the version probe itself fails' do
261
+ ENV['FAKE_MYSQL_VERSION'] = ''
262
+ ENV['FAKE_MYSQL_VERSION_EXIT'] = '1'
263
+ expect(engine.mysql_8_4_or_newer?).to eq(false)
264
+ end
265
+ end
266
+
267
+ # =========================================================================
268
+ # A — cycle_database no longer destroys the database on a quoting break
269
+ # =========================================================================
270
+
271
+ describe '#capture_create_database_statement' do
272
+ it 'returns the SHOW CREATE DATABASE output verbatim, including an embedded single quote' do
273
+ stmt = "CREATE DATABASE `myapp` /*!40100 DEFAULT CHARACTER SET utf8mb3 */ /*!80016 DEFAULT ENCRYPTION='N' */"
274
+ ENV['FAKE_MYSQL_CREATE_OUTPUT'] = stmt
275
+ ENV['FAKE_MYSQL_CREATE_EXIT'] = '0'
276
+
277
+ result = engine.capture_create_database_statement('myapp')
278
+ expect(result).to eq(stmt)
279
+ expect(result).to include("ENCRYPTION='N'")
280
+ end
281
+
282
+ it 'falls back to a bare CREATE DATABASE when the probe fails (fresh target, no existing db)' do
283
+ ENV['FAKE_MYSQL_CREATE_OUTPUT'] = ''
284
+ ENV['FAKE_MYSQL_CREATE_EXIT'] = '1'
285
+
286
+ expect(engine.capture_create_database_statement('newdb')).to eq('CREATE DATABASE `newdb`')
287
+ end
288
+
289
+ it 'falls back to a bare CREATE DATABASE when the probe succeeds but returns nothing' do
290
+ ENV['FAKE_MYSQL_CREATE_OUTPUT'] = ''
291
+ ENV['FAKE_MYSQL_CREATE_EXIT'] = '0'
292
+
293
+ expect(engine.capture_create_database_statement('newdb')).to eq('CREATE DATABASE `newdb`')
294
+ end
295
+ end
296
+
297
+ describe '#cycle_database' do
298
+ it 'drops then recreates, delivering the FULL statement (with embedded quote) to mysql via stdin intact' do
299
+ stmt = "CREATE DATABASE `myapp` /*!80016 DEFAULT ENCRYPTION='N' */"
300
+ ENV['FAKE_MYSQL_CREATE_OUTPUT'] = stmt
301
+ ENV['FAKE_MYSQL_CREATE_EXIT'] = '0'
302
+ ENV['FAKE_MYSQL_DROP_EXIT'] = '0'
303
+ ENV['FAKE_MYSQL_STDIN_EXIT'] = '0'
304
+ stdin_capture = File.join(@fake_bin_dir, 'stdin_capture.sql')
305
+ ENV['FAKE_MYSQL_STDIN_CAPTURE_FILE'] = stdin_capture
306
+
307
+ expect { engine.cycle_database('myapp') }.not_to raise_error
308
+
309
+ # The database WAS dropped...
310
+ expect(call_log.any? { |l| l.start_with?('DROP DATABASE') || l.include?('DROP DATABASE') }).to eq(true)
311
+ # ...and the recreate step received the statement UNTRUNCATED, quote and all.
312
+ # This is the crux of the fix: under the old `-e '#{create_cmd}'` code,
313
+ # this content would never have reached mysql intact (the shell would
314
+ # have truncated it at the embedded quote).
315
+ received = File.read(stdin_capture)
316
+ expect(received).to include(stmt)
317
+ expect(received).to include("ENCRYPTION='N'")
318
+ end
319
+
320
+ it 'never drops the database if the captured statement is not a valid CREATE DATABASE' do
321
+ ENV['FAKE_MYSQL_CREATE_OUTPUT'] = 'garbage: mysql client misconfiguration warning text'
322
+ ENV['FAKE_MYSQL_CREATE_EXIT'] = '0'
323
+ ENV['FAKE_MYSQL_DROP_EXIT'] = '0'
324
+
325
+ expect { engine.cycle_database('myapp') }.to raise_error(/Refusing to restore/)
326
+ expect(call_log.any? { |l| l.include?('DROP DATABASE') }).to eq(false)
327
+ end
328
+
329
+ it 'raises (does not swallow the failure) if DROP DATABASE itself fails' do
330
+ ENV['FAKE_MYSQL_CREATE_OUTPUT'] = 'CREATE DATABASE `myapp`'
331
+ ENV['FAKE_MYSQL_CREATE_EXIT'] = '0'
332
+ ENV['FAKE_MYSQL_DROP_EXIT'] = '1'
333
+
334
+ expect { engine.cycle_database('myapp') }.to raise_error(/Failed to drop database/)
335
+ end
336
+
337
+ it 'raises clearly (dropped-but-not-recreated) if the recreate step fails after a successful DROP' do
338
+ ENV['FAKE_MYSQL_CREATE_OUTPUT'] = 'CREATE DATABASE `myapp`'
339
+ ENV['FAKE_MYSQL_CREATE_EXIT'] = '0'
340
+ ENV['FAKE_MYSQL_DROP_EXIT'] = '0'
341
+ ENV['FAKE_MYSQL_STDIN_EXIT'] = '1'
342
+
343
+ expect { engine.cycle_database('myapp') }.to raise_error(/dropped and NOT recreated/)
344
+ end
345
+ end
346
+ end
@@ -0,0 +1,188 @@
1
+ # Regression coverage for postgresql_engine.rb's #cycle_database, added
2
+ # alongside the ey-all#204 MySQL fix (see mysql_engine_spec.rb).
3
+ #
4
+ # postgresql_engine.rb's #cycle_database was audited for the same shell-
5
+ # quoting break as MySQL's (defect A): it also captures a statement
6
+ # (create_command) and interpolates it into a shell -c argument. It is NOT
7
+ # vulnerable to the same trigger, because the captured statement is embedded
8
+ # in a DOUBLE-quoted shell argument while its own internal quoting uses
9
+ # single quotes (`ENCODING 'UTF8'`) -- single quotes are not shell-special
10
+ # inside double quotes, so they cannot terminate the argument early the way
11
+ # they did for MySQL's single-quoted `-e` argument. See the PR description
12
+ # for the full analysis.
13
+ #
14
+ # It DID share the other half of defect A's pattern, though: neither
15
+ # drop_database nor the final recreate call checked $? at all, so a failed
16
+ # recreate after a successful drop would silently leave the database
17
+ # dropped. These specs cover the exit-status hardening added for that.
18
+ #
19
+ # Same loading strategy as mysql_engine_spec.rb: avoids lib/ey_backup.rb's
20
+ # ey-flex/NextGem dependency chain, uses a fake `psql`/`dropdb`/`createdb`
21
+ # on PATH instead of a real PostgreSQL server, and uses modern rspec syntax.
22
+
23
+ require 'forwardable'
24
+ require 'stringio'
25
+ require 'fileutils'
26
+ require 'tmpdir'
27
+
28
+ module EY
29
+ module Backup
30
+ class << self
31
+ attr_accessor :logger, :tmp_dir, :log_dir
32
+ end
33
+
34
+ class Logger
35
+ extend Forwardable
36
+ attr_reader :stdout, :stderr
37
+ alias_method :fatal, :abort
38
+ public :fatal
39
+ def_delegator :stdout, :puts, :puts
40
+
41
+ def initialize(stdout = $stdout, stderr = $stderr)
42
+ @stdout, @stderr = stdout, stderr
43
+ end
44
+
45
+ def info(msg); stdout.puts("#{Time.now} #{msg}"); end
46
+ def warn(msg, db = nil); stdout.puts("#{Time.now} WARNING: #{msg}"); end
47
+ def error(msg, db = nil); stdout.puts("#{Time.now} ERROR: #{msg}"); end
48
+ def verbose(msg); end
49
+ def debug(msg); stdout.puts("#{Time.now} DEBUG: #{msg}"); end
50
+ def say(msg, newline = true); newline ? info(msg) : stdout.print(msg); end
51
+ end
52
+
53
+ module Logging
54
+ extend Forwardable
55
+ def_delegator EY::Backup, :logger
56
+ def_delegators :logger, :fatal, :error, :warn, :info, :verbose, :debug, :say
57
+ end
58
+ end
59
+ end
60
+
61
+ EY::Backup.logger = EY::Backup::Logger.new(StringIO.new, StringIO.new)
62
+
63
+ lib_dir = File.expand_path(File.dirname(__FILE__) + '/../../lib/ey_backup')
64
+ require lib_dir + '/spawner'
65
+ require lib_dir + '/base'
66
+ require lib_dir + '/engine'
67
+ require lib_dir + '/engines/postgresql_engine'
68
+
69
+ # ---- Fake psql / dropdb / createdb ---------------------------------------
70
+
71
+ FAKE_PSQL_SCRIPT = <<~'SCRIPT'
72
+ #!/usr/bin/env bash
73
+ # Route by the -c argument content:
74
+ # - contains "SELECT 'CREATE DATABASE" => the create_command probe
75
+ # - contains "select count(*)" => check_connections probe
76
+ # - otherwise => the final recreate DDL execution
77
+ cflag=""
78
+ has_c=0
79
+ for arg in "$@"; do
80
+ if [ "$has_c" = "1" ]; then
81
+ cflag="$arg"
82
+ break
83
+ fi
84
+ if [ "$arg" = "-c" ]; then
85
+ has_c=1
86
+ fi
87
+ done
88
+
89
+ case "$cflag" in
90
+ *"select count(*)"*)
91
+ printf '%s' "${FAKE_PSQL_CONN_COUNT:-0}"
92
+ exit 0
93
+ ;;
94
+ *"SELECT 'CREATE DATABASE"*)
95
+ printf '%s' "$FAKE_PSQL_CREATE_COMMAND_OUTPUT"
96
+ exit "${FAKE_PSQL_CREATE_COMMAND_EXIT:-0}"
97
+ ;;
98
+ *)
99
+ # Final CREATE DATABASE execution
100
+ exit "${FAKE_PSQL_RECREATE_EXIT:-0}"
101
+ ;;
102
+ esac
103
+ SCRIPT
104
+
105
+ FAKE_DROPDB_SCRIPT = <<~'SCRIPT'
106
+ #!/usr/bin/env bash
107
+ exit "${FAKE_DROPDB_EXIT:-0}"
108
+ SCRIPT
109
+
110
+ FAKE_CREATEDB_SCRIPT = <<~'SCRIPT'
111
+ #!/usr/bin/env bash
112
+ exit "${FAKE_CREATEDB_EXIT:-0}"
113
+ SCRIPT
114
+
115
+ RSpec.describe EY::Backup::Postgresql do
116
+ around(:each) do |example|
117
+ @fake_bin_dir = Dir.mktmpdir('fake-psql-bin')
118
+ { 'psql' => FAKE_PSQL_SCRIPT, 'dropdb' => FAKE_DROPDB_SCRIPT, 'createdb' => FAKE_CREATEDB_SCRIPT }.each do |name, script|
119
+ path = File.join(@fake_bin_dir, name)
120
+ File.write(path, script)
121
+ FileUtils.chmod('+x', path)
122
+ end
123
+
124
+ original_path = ENV['PATH']
125
+ ENV['PATH'] = "#{@fake_bin_dir}:#{original_path}"
126
+ begin
127
+ example.run
128
+ ensure
129
+ ENV['PATH'] = original_path
130
+ %w[
131
+ FAKE_PSQL_CONN_COUNT FAKE_PSQL_CREATE_COMMAND_OUTPUT
132
+ FAKE_PSQL_CREATE_COMMAND_EXIT FAKE_PSQL_RECREATE_EXIT
133
+ FAKE_DROPDB_EXIT FAKE_CREATEDB_EXIT
134
+ ].each { |k| ENV.delete(k) }
135
+ FileUtils.remove_entry(@fake_bin_dir)
136
+ end
137
+ end
138
+
139
+ let(:engine) do
140
+ described_class.new('postgres', 'pw', 'localhost', nil, true, false, false, false)
141
+ end
142
+
143
+ describe '#cycle_database' do
144
+ context 'database does not yet exist (create_command returns empty)' do
145
+ it 'creates it and does not raise when createdb succeeds' do
146
+ ENV['FAKE_PSQL_CREATE_COMMAND_OUTPUT'] = ''
147
+ ENV['FAKE_CREATEDB_EXIT'] = '0'
148
+
149
+ expect { engine.cycle_database('newdb') }.not_to raise_error
150
+ end
151
+
152
+ it 'raises if createdb fails' do
153
+ ENV['FAKE_PSQL_CREATE_COMMAND_OUTPUT'] = ''
154
+ ENV['FAKE_CREATEDB_EXIT'] = '1'
155
+
156
+ expect { engine.cycle_database('newdb') }.to raise_error(/Failed to create database/)
157
+ end
158
+ end
159
+
160
+ context 'database exists (normal restore path)' do
161
+ it 'drops then recreates without raising on success' do
162
+ ENV['FAKE_PSQL_CONN_COUNT'] = '0'
163
+ ENV['FAKE_PSQL_CREATE_COMMAND_OUTPUT'] = "CREATE DATABASE myapp WITH OWNER deploy ENCODING 'UTF8' LC_COLLATE 'en_US.UTF-8' LC_CTYPE 'en_US.UTF-8' CONNECTION LIMIT -1;"
164
+ ENV['FAKE_DROPDB_EXIT'] = '0'
165
+ ENV['FAKE_PSQL_RECREATE_EXIT'] = '0'
166
+
167
+ expect { engine.cycle_database('myapp') }.not_to raise_error
168
+ end
169
+
170
+ it 'raises and does not attempt to recreate if dropdb fails' do
171
+ ENV['FAKE_PSQL_CONN_COUNT'] = '0'
172
+ ENV['FAKE_PSQL_CREATE_COMMAND_OUTPUT'] = "CREATE DATABASE myapp WITH OWNER deploy ENCODING 'UTF8';"
173
+ ENV['FAKE_DROPDB_EXIT'] = '1'
174
+
175
+ expect { engine.cycle_database('myapp') }.to raise_error(/Failed to drop database/)
176
+ end
177
+
178
+ it 'raises clearly (dropped-but-not-recreated) if the final recreate fails after a successful drop' do
179
+ ENV['FAKE_PSQL_CONN_COUNT'] = '0'
180
+ ENV['FAKE_PSQL_CREATE_COMMAND_OUTPUT'] = "CREATE DATABASE myapp WITH OWNER deploy ENCODING 'UTF8';"
181
+ ENV['FAKE_DROPDB_EXIT'] = '0'
182
+ ENV['FAKE_PSQL_RECREATE_EXIT'] = '1'
183
+
184
+ expect { engine.cycle_database('myapp') }.to raise_error(/dropped and NOT recreated/)
185
+ end
186
+ end
187
+ end
188
+ end
metadata CHANGED
@@ -1,173 +1,173 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ey_cloud_server
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.0
4
+ version: 1.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - EngineYard
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-12-15 00:00:00.000000000 Z
11
+ date: 2026-08-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: json
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
- - - ~>
17
+ - - "~>"
18
18
  - !ruby/object:Gem::Version
19
19
  version: 2.6.1
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
- - - ~>
24
+ - - "~>"
25
25
  - !ruby/object:Gem::Version
26
26
  version: 2.6.1
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: open4
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
- - - ~>
31
+ - - "~>"
32
32
  - !ruby/object:Gem::Version
33
33
  version: 1.3.0
34
34
  type: :runtime
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
- - - ~>
38
+ - - "~>"
39
39
  - !ruby/object:Gem::Version
40
40
  version: 1.3.0
41
41
  - !ruby/object:Gem::Dependency
42
42
  name: nokogiri
43
43
  requirement: !ruby/object:Gem::Requirement
44
44
  requirements:
45
- - - ! '>='
45
+ - - ">="
46
46
  - !ruby/object:Gem::Version
47
47
  version: 1.6.6.2
48
- - - <=
48
+ - - "<="
49
49
  - !ruby/object:Gem::Version
50
50
  version: 1.6.8.1
51
51
  type: :runtime
52
52
  prerelease: false
53
53
  version_requirements: !ruby/object:Gem::Requirement
54
54
  requirements:
55
- - - ! '>='
55
+ - - ">="
56
56
  - !ruby/object:Gem::Version
57
57
  version: 1.6.6.2
58
- - - <=
58
+ - - "<="
59
59
  - !ruby/object:Gem::Version
60
60
  version: 1.6.8.1
61
61
  - !ruby/object:Gem::Dependency
62
62
  name: mime-types
63
63
  requirement: !ruby/object:Gem::Requirement
64
64
  requirements:
65
- - - ! '>='
65
+ - - ">="
66
66
  - !ruby/object:Gem::Version
67
67
  version: 3.3.1
68
68
  type: :runtime
69
69
  prerelease: false
70
70
  version_requirements: !ruby/object:Gem::Requirement
71
71
  requirements:
72
- - - ! '>='
72
+ - - ">="
73
73
  - !ruby/object:Gem::Version
74
74
  version: 3.3.1
75
75
  - !ruby/object:Gem::Dependency
76
76
  name: fog-aws
77
77
  requirement: !ruby/object:Gem::Requirement
78
78
  requirements:
79
- - - ! '>='
79
+ - - ">="
80
80
  - !ruby/object:Gem::Version
81
81
  version: 1.2.1
82
82
  type: :runtime
83
83
  prerelease: false
84
84
  version_requirements: !ruby/object:Gem::Requirement
85
85
  requirements:
86
- - - ! '>='
86
+ - - ">="
87
87
  - !ruby/object:Gem::Version
88
88
  version: 1.2.1
89
89
  - !ruby/object:Gem::Dependency
90
90
  name: ey_enzyme
91
91
  requirement: !ruby/object:Gem::Requirement
92
92
  requirements:
93
- - - ~>
93
+ - - "~>"
94
94
  - !ruby/object:Gem::Version
95
95
  version: 2.0.7pre1
96
96
  type: :runtime
97
97
  prerelease: false
98
98
  version_requirements: !ruby/object:Gem::Requirement
99
99
  requirements:
100
- - - ~>
100
+ - - "~>"
101
101
  - !ruby/object:Gem::Version
102
102
  version: 2.0.7pre1
103
103
  - !ruby/object:Gem::Dependency
104
104
  name: ey_instance_api_client
105
105
  requirement: !ruby/object:Gem::Requirement
106
106
  requirements:
107
- - - ~>
107
+ - - "~>"
108
108
  - !ruby/object:Gem::Version
109
109
  version: 0.1.11
110
110
  type: :runtime
111
111
  prerelease: false
112
112
  version_requirements: !ruby/object:Gem::Requirement
113
113
  requirements:
114
- - - ~>
114
+ - - "~>"
115
115
  - !ruby/object:Gem::Version
116
116
  version: 0.1.11
117
117
  - !ruby/object:Gem::Dependency
118
118
  name: rake
119
119
  requirement: !ruby/object:Gem::Requirement
120
120
  requirements:
121
- - - ~>
121
+ - - "~>"
122
122
  - !ruby/object:Gem::Version
123
123
  version: 10.4.2
124
124
  type: :development
125
125
  prerelease: false
126
126
  version_requirements: !ruby/object:Gem::Requirement
127
127
  requirements:
128
- - - ~>
128
+ - - "~>"
129
129
  - !ruby/object:Gem::Version
130
130
  version: 10.4.2
131
131
  - !ruby/object:Gem::Dependency
132
132
  name: rspec
133
133
  requirement: !ruby/object:Gem::Requirement
134
134
  requirements:
135
- - - ~>
135
+ - - "~>"
136
136
  - !ruby/object:Gem::Version
137
137
  version: '2.0'
138
138
  type: :development
139
139
  prerelease: false
140
140
  version_requirements: !ruby/object:Gem::Requirement
141
141
  requirements:
142
- - - ~>
142
+ - - "~>"
143
143
  - !ruby/object:Gem::Version
144
144
  version: '2.0'
145
145
  - !ruby/object:Gem::Dependency
146
146
  name: randexp
147
147
  requirement: !ruby/object:Gem::Requirement
148
148
  requirements:
149
- - - ~>
149
+ - - "~>"
150
150
  - !ruby/object:Gem::Version
151
151
  version: 0.1.7
152
152
  type: :development
153
153
  prerelease: false
154
154
  version_requirements: !ruby/object:Gem::Requirement
155
155
  requirements:
156
- - - ~>
156
+ - - "~>"
157
157
  - !ruby/object:Gem::Version
158
158
  version: 0.1.7
159
159
  - !ruby/object:Gem::Dependency
160
160
  name: cucumber
161
161
  requirement: !ruby/object:Gem::Requirement
162
162
  requirements:
163
- - - ~>
163
+ - - "~>"
164
164
  - !ruby/object:Gem::Version
165
165
  version: 2.4.0
166
166
  type: :development
167
167
  prerelease: false
168
168
  version_requirements: !ruby/object:Gem::Requirement
169
169
  requirements:
170
- - - ~>
170
+ - - "~>"
171
171
  - !ruby/object:Gem::Version
172
172
  version: 2.4.0
173
173
  - !ruby/object:Gem::Dependency
@@ -202,28 +202,28 @@ dependencies:
202
202
  name: bundler
203
203
  requirement: !ruby/object:Gem::Requirement
204
204
  requirements:
205
- - - ~>
205
+ - - "~>"
206
206
  - !ruby/object:Gem::Version
207
207
  version: 1.13.7
208
208
  type: :development
209
209
  prerelease: false
210
210
  version_requirements: !ruby/object:Gem::Requirement
211
211
  requirements:
212
- - - ~>
212
+ - - "~>"
213
213
  - !ruby/object:Gem::Version
214
214
  version: 1.13.7
215
215
  - !ruby/object:Gem::Dependency
216
216
  name: simplecov
217
217
  requirement: !ruby/object:Gem::Requirement
218
218
  requirements:
219
- - - ~>
219
+ - - "~>"
220
220
  - !ruby/object:Gem::Version
221
221
  version: 0.16.1
222
222
  type: :development
223
223
  prerelease: false
224
224
  version_requirements: !ruby/object:Gem::Requirement
225
225
  requirements:
226
- - - ~>
226
+ - - "~>"
227
227
  - !ruby/object:Gem::Version
228
228
  version: 0.16.1
229
229
  description: Miscellaneous EY server utilities
@@ -281,7 +281,9 @@ files:
281
281
  - spec/ey_backup/backup_spec.rb
282
282
  - spec/ey_backup/cli_spec.rb
283
283
  - spec/ey_backup/mysql_backups_spec.rb
284
+ - spec/ey_backup/mysql_engine_spec.rb
284
285
  - spec/ey_backup/postgres_backups_spec.rb
286
+ - spec/ey_backup/postgresql_engine_spec.rb
285
287
  - spec/ey_backup/spec_helper.rb
286
288
  - spec/fakefs_hax.rb
287
289
  - spec/gpg.public
@@ -298,17 +300,16 @@ require_paths:
298
300
  - lib
299
301
  required_ruby_version: !ruby/object:Gem::Requirement
300
302
  requirements:
301
- - - ! '>='
303
+ - - ">="
302
304
  - !ruby/object:Gem::Version
303
305
  version: '0'
304
306
  required_rubygems_version: !ruby/object:Gem::Requirement
305
307
  requirements:
306
- - - ! '>='
308
+ - - ">="
307
309
  - !ruby/object:Gem::Version
308
310
  version: '0'
309
311
  requirements: []
310
- rubyforge_project:
311
- rubygems_version: 2.6.14
312
+ rubygems_version: 3.2.33
312
313
  signing_key:
313
314
  specification_version: 4
314
315
  summary: Server side components for Engine Yard's cloud
@@ -321,7 +322,9 @@ test_files:
321
322
  - spec/ey_backup/backup_spec.rb
322
323
  - spec/ey_backup/cli_spec.rb
323
324
  - spec/ey_backup/mysql_backups_spec.rb
325
+ - spec/ey_backup/mysql_engine_spec.rb
324
326
  - spec/ey_backup/postgres_backups_spec.rb
327
+ - spec/ey_backup/postgresql_engine_spec.rb
325
328
  - spec/ey_backup/spec_helper.rb
326
329
  - spec/fakefs_hax.rb
327
330
  - spec/gpg.public