Class: EY::Backup::MysqlEngine

Inherits:
Engine show all
Defined in:
lib/ey_backup/engines/mysql_engine.rb

Constant Summary

Constants included from Spawner

Spawner::CHUNK_SIZE

Instance Attribute Summary

Attributes inherited from Engine

#allow_concurrent, #force, #host, #key_id, #log_coordinates, #password, #skip_analyze, #username

Instance Method Summary collapse

Methods inherited from Engine

#backup_running?, #block_concurrent, descendants, #gpg?, inherited, #initialize, label, lookup, register

Methods included from Spawner

#ioify, #run, #runs?, #spawn

Methods inherited from Base

#logger

Constructor Details

This class inherits a constructor from EY::Backup::Engine

Instance Method Details

#capture_create_database_statement(database_name) ⇒ Object



160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
# File 'lib/ey_backup/engines/mysql_engine.rb', line 160

def capture_create_database_statement(database_name)
  query = "show create database `#{database_name}` \\G"
  raw = %x(mysql #{username_option} #{host_option} -NB -e #{Shellwords.escape(query)})
  capture_status = $?

  # `-N` strips the field-name labels that `\G` would otherwise print,
  # so the last non-blank line is the CREATE DATABASE statement text.
  create_cmd = raw.to_s.split("\n").map(&:strip).reject(&:empty?).last.to_s

  if create_cmd.empty? || !capture_status.success?
    # No existing database to read from (e.g. this is the first-ever
    # restore against a fresh target), or the probe itself failed.
    # Fall back to a bare CREATE DATABASE so the restore can still
    # proceed.
    "CREATE DATABASE `#{database_name}`"
  else
    create_cmd
  end
end

#check_if_replicaObject



48
49
50
51
52
53
54
55
# File 'lib/ey_backup/engines/mysql_engine.rb', line 48

def check_if_replica
  err_msg="ERROR: Target host: '#{host}' is currently: "
  err_msg=err_msg + "a replica, " if db_replicating?
  err_msg=err_msg + "in read_only mode, " if read_only_on?
  err_msg=err_msg + "restores should be processed against the master."

  EY::Backup.logger.fatal(%Q{#{err_msg}}) if db_replicating? or read_only_on?
end

#cycle_database(database_name) ⇒ Object



149
150
151
152
153
154
155
156
157
158
# File 'lib/ey_backup/engines/mysql_engine.rb', line 149

def cycle_database(database_name)
  create_cmd = capture_create_database_statement(database_name)

  unless create_cmd =~ /\ACREATE\s+DATABASE\b/i
    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."
  end

  drop_database!(database_name)
  recreate_database!(create_cmd, database_name)
end

#databases_option(db) ⇒ Object



72
73
74
# File 'lib/ey_backup/engines/mysql_engine.rb', line 72

def databases_option(db)
  "#{db}"
end

#db_has_myisam?(database_name) ⇒ Boolean



88
89
90
91
92
# File 'lib/ey_backup/engines/mysql_engine.rb', line 88

def db_has_myisam?(database_name)
  query = "SELECT 1 FROM information_schema.tables WHERE table_schema='#{database_name}' AND engine='MyISAM' LIMIT 1;"
  stdout = %x{mysql #{username_option} #{host_option} -N -e"#{query}"}
  stdout.strip == '1'
end

#db_replicating?Boolean



94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
# File 'lib/ey_backup/engines/mysql_engine.rb', line 94

def db_replicating?
  statement = replica_status_statement
  stdout = %x(mysql #{username_option} #{host_option} -BN -e #{Shellwords.escape(statement)})
  probe_status = $?

  unless probe_status.success?
    # The replication-status probe itself failed -- e.g. the statement
    # is unsupported on this server version, or a connection/permission
    # problem. We cannot prove the host is NOT a replica, so fail
    # closed and treat it as a possible replica rather than silently
    # letting the restore proceed (this is how MySQL 8.4's removal of
    # `SHOW SLAVE STATUS` used to defeat this check entirely).
    warn("Unable to determine replication status on '#{host}' via #{statement.inspect}: #{stdout.strip}; treating host as a possible replica.")
    return true
  end

  !stdout.strip.empty?
end

#drop_database!(database_name) ⇒ Object



180
181
182
183
# File 'lib/ey_backup/engines/mysql_engine.rb', line 180

def drop_database!(database_name)
  %x(mysql #{username_option} #{host_option} -e #{Shellwords.escape("DROP DATABASE IF EXISTS `#{database_name}`")})
  raise "Failed to drop database '#{database_name}' prior to restore; the existing database was left intact." unless $?.success?
end

#dump(database_name, basename) ⇒ Object



8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# File 'lib/ey_backup/engines/mysql_engine.rb', line 8

def dump(database_name, basename)
  file = basename + '.sql'

  command = "( #{server_id} mysqldump #{username_option} #{host_option} #{single_transaction_option(database_name)} #{routines_option} #{master_data_option} #{databases_option(database_name)} ) "

  if gpg?
    command << " | " << GPGEncryptor.command_for(key_id)
    file << GPGEncryptor.extension
  else
    command << " | " << GZipper.gzip
    file << GZipper.extension
  end

  command << " > #{file}"
  
  block_concurrent(database_name) unless allow_concurrent

  run(command, database_name)

  file
end

#host_optionObject



76
77
78
# File 'lib/ey_backup/engines/mysql_engine.rb', line 76

def host_option
  "-h#{host}"
end

#load(database_name, file) ⇒ Object



30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
# File 'lib/ey_backup/engines/mysql_engine.rb', line 30

def load(database_name, file)
  command = "cat #{file}"

  if file =~ /.gpz$/ # GPG?
    abort "\nCannot restore a GPG backup directly; decrypt the file (#{file}) using your key and then load using the mysql client.
  To decrypt a backup: https://support.cloud.engineyard.com/hc/en-us/articles/205413948-Use-PGP-Encrypted-Database-Backups-with-Engine-Yard-Cloud#restore
  Once decrypted, restore with: `gunzip -f < <filename> | mysql #{username_option} #{host_option} #{database_name}`\n\n"
  else
    command << " | " << GZipper.gunzip
  end
  
  cycle_database(database_name)

  command << " | mysql #{username_option} #{host_option} #{database_name} "

  run(command, database_name)
end

#log_bin_on?Boolean



144
145
146
147
# File 'lib/ey_backup/engines/mysql_engine.rb', line 144

def log_bin_on?
  stdout = %x{mysql #{username_option} #{host_option} -BN -e"select @@global.log_bin"}
  stdout.to_i == 1
end

#master_data_optionObject



68
69
70
# File 'lib/ey_backup/engines/mysql_engine.rb', line 68

def master_data_option
  "--master-data=2" if log_bin_on? and log_coordinates
end

#mysql_8_4_or_newer?Boolean



119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
# File 'lib/ey_backup/engines/mysql_engine.rb', line 119

def mysql_8_4_or_newer?
  stdout = %x(mysql #{username_option} #{host_option} -BN -e #{Shellwords.escape('select @@version')})
  return false unless $?.success?

  version = stdout.strip
  # MariaDB keeps its own version numbering (10.x, 11.x) and retains
  # `SHOW SLAVE STATUS` across all versions, so we must not compare
  # its major.minor against MySQL's 8.4 boundary.
  return false if version =~ /mariadb/i

  # Anchor at the start of the string so we only inspect the leading
  # version tuple (e.g. "8.4.11-11" → "8.4"), not any later digit-dot-digit
  # pattern in suffixes or build metadata.
  numeric = version[/\A\d+\.\d+/]
  return false unless numeric

  major, minor = numeric.split('.').map(&:to_i)
  major > 8 || (major == 8 && minor >= 4)
end

#read_only_on?Boolean



139
140
141
142
# File 'lib/ey_backup/engines/mysql_engine.rb', line 139

def read_only_on?
  stdout = %x{mysql #{username_option} #{host_option} -BN -e"select @@global.read_only"}
  stdout.to_i == 1
end

#recreate_database!(create_cmd, database_name) ⇒ Object



185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
# File 'lib/ey_backup/engines/mysql_engine.rb', line 185

def recreate_database!(create_cmd, database_name)
  # Feed the CREATE DATABASE statement to `mysql` on STDIN rather than
  # interpolating it into a quoted shell `-e` argument. From MySQL
  # 8.0.16 onward, `SHOW CREATE DATABASE` output can contain a
  # single-quoted clause (e.g. `/*!80016 DEFAULT ENCRYPTION='N' */`)
  # whose quote would otherwise terminate a single-quoted shell string
  # and truncate the statement mid-SQL -- which is exactly what turned
  # a syntax error into a dropped, never-recreated database.
  IO.popen(['mysql', username_option, host_option], 'w') do |io|
    io.write(create_cmd)
    io.write(";\n")
  end

  unless $?.success?
    raise "Failed to recreate database '#{database_name}' after DROP -- the database has been dropped and NOT recreated. Statement attempted: #{create_cmd.inspect}"
  end
end

#replica_status_statementObject

SHOW SLAVE STATUS was deprecated in MySQL 8.0.22 and removed in 8.4, replaced by SHOW REPLICA STATUS.



115
116
117
# File 'lib/ey_backup/engines/mysql_engine.rb', line 115

def replica_status_statement
  mysql_8_4_or_newer? ? "show replica status" : "show slave status"
end

#routines_optionObject



57
58
59
# File 'lib/ey_backup/engines/mysql_engine.rb', line 57

def routines_option
  "--routines"
end

#server_idObject



61
62
63
64
65
66
# File 'lib/ey_backup/engines/mysql_engine.rb', line 61

def server_id
  if log_bin_on? and log_coordinates
    stdout = %x{mysql #{username_option} #{host_option} -BN -e"select @@global.server_id"}
    "echo '-- Server_id: #{stdout.strip}' && "
  end 
end

#single_transaction_option(database_name) ⇒ Object



84
85
86
# File 'lib/ey_backup/engines/mysql_engine.rb', line 84

def single_transaction_option(database_name)
  '--single-transaction' unless db_has_myisam?(database_name)
end

#suffixObject



203
204
205
# File 'lib/ey_backup/engines/mysql_engine.rb', line 203

def suffix
  /sql\.(gz|gpz)$/
end

#username_optionObject



80
81
82
# File 'lib/ey_backup/engines/mysql_engine.rb', line 80

def username_option
  "-u#{username}"
end