reptile 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
data/History.txt ADDED
@@ -0,0 +1,4 @@
1
+ == 0.0.1 2009-06-24
2
+
3
+ * 1 major enhancement:
4
+ * Initial release
data/Manifest.txt ADDED
@@ -0,0 +1,20 @@
1
+ History.txt
2
+ Manifest.txt
3
+ PostInstall.txt
4
+ README.rdoc
5
+ Rakefile
6
+ bin/replication_status
7
+ lib/reptile.rb
8
+ lib/reptile/databases.rb
9
+ lib/reptile/delta_monitor.rb
10
+ lib/reptile/dtd.sql
11
+ lib/reptile/heartbeat.rb
12
+ lib/reptile/replication_monitor.rb
13
+ lib/reptile/runner.rb
14
+ lib/reptile/status.rb
15
+ lib/reptile/users.rb
16
+ script/console
17
+ script/destroy
18
+ script/generate
19
+ test/test_helper.rb
20
+ test/test_reptile.rb
data/PostInstall.txt ADDED
@@ -0,0 +1,5 @@
1
+
2
+ For more information on reptile, see http://reptile.rubyforge.org
3
+
4
+
5
+
data/README.rdoc ADDED
@@ -0,0 +1,36 @@
1
+ = reptile
2
+
3
+ Reptile is an easy to use utility that will monitor your MySQL replication, so you can forget about it and focus on the good stuff. It provides a utility for generate replication reports, and can email if replication appears to be failing.
4
+
5
+ == REQUIREMENTS:
6
+
7
+ A mysql replication setup.
8
+
9
+ == INSTALL:
10
+
11
+ sudo gem install reptile
12
+
13
+ == LICENSE:
14
+
15
+ (The MIT License)
16
+
17
+ Copyright (c) 2009 Nick Stielau
18
+
19
+ Permission is hereby granted, free of charge, to any person obtaining
20
+ a copy of this software and associated documentation files (the
21
+ 'Software'), to deal in the Software without restriction, including
22
+ without limitation the rights to use, copy, modify, merge, publish,
23
+ distribute, sublicense, and/or sell copies of the Software, and to
24
+ permit persons to whom the Software is furnished to do so, subject to
25
+ the following conditions:
26
+
27
+ The above copyright notice and this permission notice shall be
28
+ included in all copies or substantial portions of the Software.
29
+
30
+ THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
31
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
32
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
33
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
34
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
35
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
36
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/Rakefile ADDED
@@ -0,0 +1,38 @@
1
+ require 'rubygems' unless ENV['NO_RUBYGEMS']
2
+ %w[rake rake/clean fileutils newgem rubigen].each { |f| require f }
3
+ require File.dirname(__FILE__) + '/lib/reptile'
4
+
5
+ # Generate all the Rake tasks
6
+ # Run 'rake -T' to see list of generated tasks (from gem root directory)
7
+ $hoe = Hoe.new('reptile', Reptile::VERSION) do |p|
8
+ p.developer('FIXME full name', 'FIXME email')
9
+ p.changes = p.paragraphs_of("History.txt", 0..1).join("\n\n")
10
+ p.post_install_message = 'PostInstall.txt'
11
+ p.rubyforge_name = p.name
12
+ p.bin_files = ["bin/replication_status"]
13
+ p.extra_dev_deps = [
14
+ ['newgem', ">= #{::Newgem::VERSION}"]
15
+ ]
16
+
17
+ p.clean_globs |= %w[**/.DS_Store tmp *.log]
18
+ path = (p.rubyforge_name == p.name) ? p.rubyforge_name : "\#{p.rubyforge_name}/\#{p.name}"
19
+ p.remote_rdoc_dir = File.join(path.gsub(/^#{p.rubyforge_name}\/?/,''), 'rdoc')
20
+ p.rsync_args = '-av --delete --ignore-errors'
21
+ end
22
+
23
+ desc "Upload current documentation to Rubyforge"
24
+ task :upload_docs => [:redocs] do
25
+ sh "scp -r doc/* nstielau@rubyforge.org:/var/www/gforge-projects/reptile/doc/"
26
+ end
27
+
28
+ desc "Upload current documentation to Rubyforge"
29
+ task :upload_site do
30
+ #webgen && scp -r output/* nstielau@rubyforge.org:/var/www/gforge-projects/reptile/
31
+ sh "scp -r site/* nstielau@rubyforge.org:/var/www/gforge-projects/reptile/"
32
+ end
33
+
34
+ require 'newgem/tasks' # load /tasks/*.rake
35
+ Dir['tasks/**/*.rake'].each { |t| load t }
36
+
37
+ # TODO - want other tests/tasks run by default? Add them to the list
38
+ # task :default => [:spec, :features]
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'optparse'
4
+ require 'reptile'
5
+
6
+ commands = []
7
+
8
+ OptionParser.new do |opts|
9
+ opts.banner = "Usage: #{File.basename($0)} [path_to_config_file]"
10
+
11
+ opts.on("-h", "--help", "Displays this help info") do
12
+ puts opts
13
+ exit 0
14
+ end
15
+
16
+ opts.on("-s", "--status", "Displays the slave status") do
17
+ commands << 'check_slaves'
18
+ end
19
+
20
+ opts.on("-d", "--diff", "Checks the row count difference between master and slave") do
21
+ commands << 'diff_tables'
22
+ end
23
+
24
+ opts.on("-r", "--report", "Sends a report email") do
25
+ commands << 'report'
26
+ end
27
+
28
+ opts.on("-b", "--heartbeat", "Checks the heartbeat timestamp difference between master and slave") do
29
+ commands << 'heartbeat'
30
+ end
31
+
32
+ opts.on("-x", "--stop_slaves", "Stops all slaves") do
33
+ commands << 'stop_slaves'
34
+ end
35
+
36
+ opts.on("-g", "--start_slaves", "Starts all slaves") do
37
+ commands << 'start_slaves'
38
+ end
39
+
40
+ begin
41
+ opts.parse!(ARGV)
42
+ rescue OptionParser::ParseError => e
43
+ warn e.message
44
+ puts opts
45
+ exit 1
46
+ end
47
+ end
48
+
49
+ config_file = 'replication.yml'
50
+
51
+ if ARGV.empty? && !File.exists?(config_file)
52
+ abort "Please specify the directory containing the '#{config_file}' file, e.g. `#{File.basename($0)} ~/repl'"
53
+ elsif !ARGV.empty? && !File.exists?(ARGV.first)
54
+ abort "`#{ARGV.first}' does not exist."
55
+ elsif !ARGV.empty? && !File.directory?(ARGV.first)
56
+ abort "`#{ARGV.first}' is not a directory."
57
+ elsif !ARGV.empty? && ARGV.length > 1
58
+ abort "Too many arguments; please specify only the directory to #{File.basename($0)}."
59
+ end
60
+
61
+ Reptile::ReplicationMonitor.load_config_file(ARGV.first.nil? ? config_file : "#{ARGV.first}/#{config_file}")
62
+
63
+ if (commands.include?('start_slaves') || commands.include?('stop_slaves'))
64
+ Reptile::Runner.send(commands[0])
65
+ else
66
+ (commands.empty? ? ['check_slaves', 'heartbeat', 'diff_tables'] : commands).each do |command|
67
+ Reptile::ReplicationMonitor.send(command)
68
+ end
69
+ end
@@ -0,0 +1,51 @@
1
+ module Reptile
2
+ # The Databases class stores information about different databases, including the config settings
3
+ # for the master and slave of each particular database.
4
+ class Databases
5
+ attr :databases
6
+
7
+ def initialize(databases)
8
+ @databases = databases
9
+ end
10
+
11
+ # returns an array of the master names
12
+ def masters
13
+ @master_configs ||= get_masters
14
+ end
15
+
16
+ # returns an array of the slave names
17
+ def slaves
18
+ @slave_configs ||= get_slaves
19
+ end
20
+
21
+ private
22
+
23
+ def get_masters
24
+ masters = databases.dup
25
+ masters.each_key{|key| masters.delete(key) if masters[key]['master'].nil? }
26
+ masters.each_key{|key| masters[key] = masters[key]['master'] }
27
+ masters
28
+ end
29
+
30
+ # TODO: make private
31
+ def get_slaves
32
+ dbs = databases.dup
33
+ dbs.each_key{|name| dbs.delete(key) if dbs[name]['slave'].nil? }
34
+ dbs.each_key{|name| dbs[name] = dbs[name]['slave'] }
35
+ slaves = dbs
36
+ end
37
+
38
+ # Tries to establish a database connection, and returns that connection.
39
+ # Dumps configs on error
40
+ def self.connect(configs)
41
+ ActiveRecord::Base.establish_connection(configs)
42
+ ActiveRecord::Base.connection
43
+ rescue Exception => e
44
+ puts "****"
45
+ puts "Error connecting to database: #{e}"
46
+ puts "****"
47
+ puts YAML::dump(configs)
48
+ exit 1
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,86 @@
1
+ module Reptile
2
+ # This monitor compares the row counts for each table for each master and slave.
3
+ class DeltaMonitor
4
+ # Set the user settings for a user that has global SELECT privilidgess
5
+ def self.user=(user_settings)
6
+ @user = user_settings
7
+ end
8
+
9
+ # The user settings for a user that has global select privilidgess
10
+ def self.user
11
+ raise "You need to specify a user!" if @user.nil?
12
+ @user
13
+ end
14
+
15
+ def self.open_log()
16
+ logFile = 'db_delta.log'
17
+ @logFileObj = File.open(logFile, "a")
18
+ end
19
+
20
+ def self.log(msg)
21
+ open_log if @logFileObj.nil?
22
+ puts msg
23
+ @logFileObj.puts msg
24
+ end
25
+
26
+ # Retrieve the active database connection. Nil of none exists.
27
+ def self.connection
28
+ ActiveRecord::Base.connection
29
+ end
30
+
31
+ # Compares the row counts for master tables and slave tables
32
+ # Returns a hash of TABLE_NAME => ROW COUNT DELTAs
33
+ def self.diff(db_name, master_configs, slave_configs)
34
+ ActiveRecord::Base.establish_connection(slave_configs.merge(user))
35
+ slave_counts = get_table_counts
36
+
37
+ ActiveRecord::Base.establish_connection(master_configs.merge(user))
38
+ master_counts = get_table_counts
39
+
40
+ deltas= {}
41
+ master_counts.each do |table, master_count|
42
+ delta = master_count.first.to_i - slave_counts[table].first.to_i
43
+ deltas[table] = delta
44
+ end
45
+
46
+ print_deltas(db_name, deltas, master_configs)
47
+
48
+ deltas
49
+ end
50
+
51
+ # Prints stats about the differences in number of rows between the master and slave
52
+ def self.print_deltas(db_name, deltas, configs)
53
+ non_zero_deltas = deltas.select{|table, delta| not delta.zero?}
54
+ if non_zero_deltas.size.zero?
55
+ log "Replication counts A-OK for #{db_name} on #{configs['host']} @ #{Time.now}"
56
+ else
57
+ log "Replication Row Count Deltas for #{db_name} on #{configs['host']} @ #{Time.now}"
58
+ log "There #{non_zero_deltas.size > 1 ? 'are' : 'is'} #{non_zero_deltas.size} #{non_zero_deltas.size > 1 ? 'deltas' : 'delta'}"
59
+ non_zero_deltas.each do |table, delta|
60
+ log " #{table} table: #{delta}" unless delta.zero?
61
+ end
62
+ end
63
+ end
64
+
65
+ # Returns an array of strings containing the table names
66
+ # for the current database.
67
+ def self.get_tables
68
+ tables = []
69
+ connection.execute('SHOW TABLES').each { |row| tables << row }
70
+ tables
71
+ end
72
+
73
+ # Returns a hash of TABLE_NAME => # Rows for all tables in current db
74
+ def self.get_table_counts
75
+ tables = get_tables
76
+
77
+ tables_w_count = {}
78
+ tables.each do |table|
79
+ connection.execute("SELECT COUNT(*) FROM #{table}").each do |table_count|
80
+ tables_w_count["#{table}"] = table_count
81
+ end
82
+ end
83
+ tables_w_count
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,18 @@
1
+ // Replace 'REPLICATION_USER', 'MONITORING_BOX', 'REPLICATION_PASS', 'READ_ONLY_PASS', 'READ_ONLY_USER', 'HEARTBEAT_USER' and 'HEARTBEAT_PASS', then execute this SQL as root on the master, slave, and monitoring server
2
+
3
+ GRANT REPLICATION SLAVE, REPLICATION CLIENT, SUPER ON *.* TO 'REPLICATION_USER'@"localhost" IDENTIFIED BY 'REPLICATION_PASS';
4
+ GRANT REPLICATION SLAVE, REPLICATION CLIENT, SUPER ON *.* TO 'REPLICATION_USER'@"MONITORING_BOX" IDENTIFIED BY 'REPLICATION_PASS';
5
+
6
+ GRANT SELECT, REPLICATION CLIENT ON *.* TO 'READ_ONLY_USER'@"localhost" IDENTIFIED BY 'READ_ONLY_PASS';
7
+ GRANT SELECT, REPLICATION CLIENT ON *.* TO 'READ_ONLY_USER'@"MONITORING_BOX" IDENTIFIED BY 'READ_ONLY_PASS';
8
+
9
+ GRANT SELECT, INSERT, UPDATE, ALTER ON replication_monitor.* TO 'HEARTBEAT_USER'@"localhost" IDENTIFIED BY 'HEARTBEAT_PASS';
10
+ GRANT SELECT, INSERT, UPDATE, ALTER ON replication_monitor.* TO 'HEARTBEAT_USER'@"MONITORING_BOX" IDENTIFIED BY 'HEARTBEAT_PASS';
11
+
12
+ CREATE DATABASE replication_monitor;
13
+
14
+ CREATE TABLE replication_monitor.heartbeats (
15
+ unix_time INTEGER NOT NULL,
16
+ db_time TIMESTAMP NOT NULL,
17
+ INDEX time_idx(unix_time)
18
+ )
@@ -0,0 +1,99 @@
1
+ require 'active_record'
2
+
3
+ module Reptile
4
+ # MySQL DTD for setting up Heartbeats. Change HEARTBEAT_USER, HEARTBEAT_PASS, and MONITORING_BOX
5
+ # to ip of the monitoring server.
6
+ #
7
+ # GRANT SELECT, INSERT, UPDATE, ALTER ON replication_monitor.*
8
+ # TO 'HEARTBEAT_USER'@"localhost" IDENTIFIED BY 'HEARTBEAT_PASS'; GRANT SELECT, INSERT, UPDATE,
9
+ # ALTER ON replication_monitor.* TO 'HEARTBEAT_USER'@"MONITORING_BOX" IDENTIFIED BY 'HEARTBEAT_PASS';
10
+ #
11
+ # CREATE DATABASE replication_monitor;
12
+ #
13
+ # CREATE TABLE replication_monitor.heartbeats (
14
+ # unix_time INTEGER NOT NULL,
15
+ # db_time TIMESTAMP NOT NULL,
16
+ # INDEX time_idx(unix_time)
17
+ # )
18
+ #
19
+ class Heartbeat < ActiveRecord::Base
20
+
21
+ # Set the default connection settings for writing/reading heartbeats.
22
+ # These will be merged with the per-database settings passed to <code>connect</code>.
23
+ def self.user=(default_configs)
24
+ @user = default_configs
25
+ end
26
+
27
+ # The default connection settings which override per-database settings.
28
+ def self.user
29
+ @user ||= {}
30
+ end
31
+
32
+ # Merge the connection settings in the configs parameter with HeartBeat defaults.
33
+ def self.connect(configs)
34
+ ReplicationMonitor.connect(configs.merge(user))
35
+ end
36
+
37
+ # Write a heartbeat.
38
+ # Thump thump.
39
+ def self.write(name, configs)
40
+ connect(configs)
41
+ heartbeat = Heartbeat.create(:unix_time => Time.now.to_i, :db_time => "NOW()")
42
+ log "Wrote heartbeat to #{name} at #{Time.at(heartbeat.unix_time)}"
43
+ end
44
+
45
+ # Read the most recent heartbeat and return the delay in seconds, or nil if no heartbeat are found.
46
+ def self.read(name, configs)
47
+ connect(configs)
48
+
49
+ current_time = Time.now
50
+
51
+ delay = nil
52
+ heartbeat = Heartbeat.find(:first, :order => 'db_time DESC')
53
+
54
+ # No heartbeats at all!
55
+ if heartbeat.nil?
56
+ log "No heartbeats found on #{name} at #{Time.now}"
57
+ return nil;
58
+ end
59
+
60
+ # Not sure why we have both, (one is easier to read?).
61
+ # Use one or the other to calculate delay...
62
+ delay = (Time.now - Time.at(heartbeat.unix_time)).round
63
+ #delay = (Time.now - heartbeat.db_time)
64
+
65
+ log "Read heartbeat from #{name} at #{Time.at(heartbeat.unix_time)}. The delay is #{strfdelay(delay)}"
66
+
67
+ delay
68
+ end
69
+
70
+ private
71
+
72
+ # Open the 'heartbeat.log' file.
73
+ def self.open_log
74
+ logFile = 'heartbeat.log'
75
+ @logFileObj = File.open(logFile, "a")
76
+ end
77
+
78
+ # Log a message, both to the file and standard out.
79
+ def self.log(msg)
80
+ open_log if @logFileObj.nil?
81
+ puts msg
82
+ @logFileObj.puts msg
83
+ end
84
+
85
+ # Format the delay (in seconds) as a human-readable string.
86
+ def self.strfdelay(delay)
87
+ seconds = delay % 60
88
+ minutes = delay / 60 % 60
89
+ hours = delay / 60 / 60
90
+ delay_str = ""
91
+ delay_str << "#{hours} hours" if hours > 0
92
+ delay_str << " " if !hours.zero? && (minutes > 0 || seconds > 0)
93
+ delay_str << "#{minutes} minutes" if minutes > 0
94
+ delay_str << " " if (!minutes.zero? || !hours.zero?) && seconds > 0
95
+ delay_str << "#{seconds} seconds" unless seconds.zero?
96
+ delay_str
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,224 @@
1
+ module Reptile
2
+ class ReplicationMonitor
3
+ # Attempts to load the replication.yml configuration file.
4
+ def self.load_config_file(databases_file)
5
+ @databases_file = databases_file
6
+ yaml = YAML::load(File.read(@databases_file))
7
+ @configs = yaml.delete('config')
8
+ @users = Users.new(yaml.delete('users'))
9
+ @databases = Databases.new(yaml)
10
+
11
+ Heartbeat.user = users.heartbeat_user
12
+ Runner.user = users.replication_user
13
+ Status.user = users.replication_user
14
+ DeltaMonitor.user = users.ro_user
15
+ Runner.databases = databases
16
+
17
+ raise "Please specify a delay theshold 'delay_threshold_secs: 360'" if @configs['delay_threshold_secs'].nil?
18
+ raise "Please specify a row delta theshold 'row_difference_threshold: 10'" if @configs['row_difference_threshold'].nil?
19
+
20
+ rescue Errno::EACCES => e
21
+ puts "Unable to open config file: Permission Denied"
22
+ end
23
+
24
+ # Returns the configs from the replication.yml file
25
+ def self.configs
26
+ @configs
27
+ end
28
+
29
+ # Returns the databases from the yml file.
30
+ def self.databases
31
+ @databases
32
+ end
33
+
34
+ # Returns the +Users+ loaded from the replication.yml file
35
+ def self.users
36
+ @users
37
+ end
38
+
39
+ def self.diff_tables
40
+ unsynced_dbs = 0
41
+
42
+ databases.each_pair do |name, roles|
43
+ master, slave = roles['master'], roles['slave']
44
+ deltas = DeltaMonitor.diff(name, master, slave)
45
+
46
+ egregious_deltas = deltas.select{|table, delta| delta > configs['row_difference_threshold'] }
47
+ if egregious_deltas.size > 0
48
+ queue_replication_warning :host => master[:host], :database => master[:database], :deltas => egregious_deltas, :noticed_at => Time.now
49
+ unsynced_dbs += 1
50
+ end
51
+ end
52
+
53
+ unsynced_dbs.zero?
54
+ end
55
+
56
+ def self.heartbeat
57
+ databases.each_key do |name|
58
+ Heartbeat.write(name, databases[name]['master'])
59
+ end
60
+
61
+ overdue_slaves = 0
62
+
63
+ databases.each_key do |name|
64
+ db_configs = databases[name]['slave']
65
+ delay = Heartbeat.read(name, db_configs)
66
+ if delay.nil?
67
+ queue_replication_warning :host => name,
68
+ :database => configs[:database],
69
+ :general_error => "Error: No Heartbeats found.",
70
+ :noticed_at => Time.now
71
+ overdue_slaves += 1
72
+ elsif delay > configs['delay_threshold_secs']
73
+ queue_replication_warning :host => name,
74
+ :database => configs[:database],
75
+ :delay => Heartbeat.strfdelay(delay),
76
+ :noticed_at => Time.now
77
+ overdue_slaves += 1
78
+ end
79
+ end
80
+
81
+ overdue_slaves.zero?
82
+ end
83
+
84
+ # Checks the status of each slave.
85
+ def self.check_slaves
86
+ databases.slaves.each do |slave_name, slave_configs|
87
+ status = Status.check_slave_status(slave_name, slave_configs)
88
+ if status != Status.const_get(:RUNNING)
89
+ queue_replication_warning :host => name,
90
+ :database => configs[:database],
91
+ :status_error => Status.get_error_message(status),
92
+ :noticed_at => Time.now
93
+ end
94
+ end
95
+ end
96
+
97
+ def self.queue_replication_warning(options)
98
+ email = OpenStruct.new
99
+ email.recipients = get_recipients
100
+ email.subject = "A replication error occured on #{options[:host]} at #{Time.now}"
101
+ email.body = ''
102
+
103
+ if options[:delay]
104
+ email.body += "There was a #{delay} second replication latency, which is greater than the allowed latency of #{configs['delay_threshold_secs']} seconds"
105
+ elsif options[:deltas]
106
+ email.body += "The following tables have master/slave row count difference greater than the allowed #{configs['row_difference_threshold']}\n\n"
107
+ options[:deltas].each do |table, delta|
108
+ email.body += " table '#{table}' was off by #{delta} rows\n"
109
+ end
110
+ elsif options[:status_error]
111
+ email.body += " MySQL Status message: #{options[:status_error]}"
112
+ elsif options[:general_error]
113
+ email.body += " Error: #{options[:general_error]}"
114
+ end
115
+
116
+ email.body += "\n"
117
+ email.body += " Server: #{options[:host]}\n"
118
+ email.body += " Databse: #{options[:database]}\n"
119
+
120
+ send_email(email)
121
+ end
122
+
123
+ # Gets the 'email_to' value from the 'configs' section of the replication.yml file
124
+ def self.get_recipients
125
+ configs['email_to']
126
+ end
127
+
128
+ # Gets the 'email_from' value from the 'configs' section of the replication.yml file
129
+ def self.get_sender
130
+ configs['email_from']
131
+ end
132
+
133
+ def self.report
134
+ email = OpenStruct.new
135
+ email.recipients = get_recipients
136
+ email.sender = get_sender
137
+ raise "Please specify report recipients 'email_to: user@address.com'" if email.recipients.nil?
138
+ raise "Please specify report recipients 'email_from: user@address.com'" if email.sender.nil?
139
+
140
+ email.subject = "Daily Replication Report for #{Time.now.strftime('%D')}"
141
+
142
+ puts "Generating report email"
143
+
144
+ old_stdout = $stdout
145
+ out = StringIO.new
146
+ $stdout = out
147
+ begin
148
+ puts " Checking slave status"
149
+ puts
150
+ self.check_slaves
151
+ puts
152
+ puts
153
+ puts " Checking table row counts"
154
+ puts
155
+ puts "The row count difference threshold is #{configs['row_difference_threshold']} rows"
156
+ puts
157
+ self.diff_tables
158
+ puts
159
+ puts
160
+ puts " Checking replication heartbeat"
161
+ puts
162
+ puts "The heartbeat latency threshold is #{configs['delay_threshold_secs']} seconds"
163
+ puts
164
+ self.heartbeat
165
+ ensure
166
+ $stdout = old_stdout
167
+ end
168
+ email.body = out.string
169
+
170
+ puts "Sending report email"
171
+
172
+ send_email(email)
173
+
174
+ puts "Report sent to #{get_recipients}"
175
+ end
176
+
177
+ def self.send_exception_email(ex)
178
+ email = OpenStruct.new
179
+ email.recipients = get_recipients
180
+ email.sender = get_sender
181
+ email.subject = "An exception occured while checking replication at #{Time.now}"
182
+ email.body = 'Expception\n\n'
183
+ email.body += "#{ex.message}\n"
184
+ ex.backtrace.each do |line|
185
+ email.body += "#{line}\n"
186
+ end
187
+
188
+ send_email(email)
189
+ end
190
+
191
+ def self.send_email(email)
192
+ # TODO: could do Net::SMTP.respond_to?(enable_tls) ? enable_TLS : puts "Install TLS gem to use SSL/TLS"
193
+ Net::SMTP.enable_tls(OpenSSL::SSL::VERIFY_NONE)
194
+ Net::SMTP.start(configs['email_server'],
195
+ configs['email_port'],
196
+ configs['email_domain'],
197
+ get_sender,
198
+ configs['email_password'],
199
+ configs['email_auth_type'].to_sym) do |smtp|
200
+ email.recipients.each do |email_addy|
201
+ hdr = "From: #{email.sender}\n"
202
+ hdr += "To: #{email_addy} <#{email_addy}>\n"
203
+ hdr += "Subject: #{email.subject}\n\n"
204
+ msg = hdr + email.body
205
+ puts "Sending to #{email_addy}"
206
+ smtp.send_message msg, email.sender, email_addy
207
+ end
208
+ end
209
+ # TODO: could try and recover
210
+ # rescue Net::SMTPAuthenticationError => e
211
+ # if e.message =~ /504 5.7.4 Unrecognized authentication type/
212
+ # puts "Attempting to load necesary files for TLS/SSL authentication"
213
+ # puts "Make sure openssl and the tlsmail gem are installed"
214
+ # require 'openssl'
215
+ # require 'rubygems'
216
+ # has_tlsmail_gem = require 'tlsmail'
217
+ # raise "Please install the 'tlsmail' gem" unless has_tlsmail_gem
218
+ # Net::SMTP.enable_tls(OpenSSL::SSL::VERIFY_NONE)
219
+ # send_email(email)
220
+ # end
221
+ end
222
+
223
+ end
224
+ end
@@ -0,0 +1,116 @@
1
+ module Reptile
2
+ # The runner class is responsible for running command on each slave.
3
+ # The commands are run sequentially, no guaranteed order (though probably from yml file).
4
+ class Runner
5
+ # Set the user settings for a user that has REPLICATION SLAVE privilidgess
6
+ def self.user=(replication_user_settings)
7
+ @repl_user = replication_user_settings
8
+ end
9
+
10
+ # The user settings for a user that has REPLICATION SLAVE privilidgess
11
+ def self.user
12
+ raise "You need to specify a replication user!" if @repl_user.nil?
13
+ @repl_user
14
+ end
15
+
16
+ # Set the databases to run command upon.
17
+ def self.databases=(databases)
18
+ @databases = databases
19
+ end
20
+
21
+ # The databases to run commands upon.
22
+ def self.databases
23
+ @databases
24
+ end
25
+
26
+ # Set the slaves to run command upon.
27
+ def self.slaves=(slaves)
28
+ slaves.each do |name, configs|
29
+ configs.delete('port')
30
+ configs.delete('host')
31
+ # With activeRecord, you have to connect to some DB, even if you are acting on the server...
32
+ configs['database'] = 'information_schema'
33
+ # TODO: Delete these somewhere else
34
+ configs.delete('heartbeat')
35
+ configs.delete('replication_user')
36
+ end
37
+ @slaves = slaves
38
+ end
39
+
40
+ # The slaves to run commands upon.
41
+ def self.slaves
42
+ raise "You need to specify the slaves to run against!" if @slaves.nil?
43
+ @slaves
44
+ end
45
+
46
+
47
+ # Tries to establish a database connection, and returns that connection.
48
+ # Dumps configs on error.
49
+ def self.connect(configs)
50
+ ActiveRecord::Base.establish_connection(configs)
51
+ ActiveRecord::Base.connection
52
+ rescue Exception => e
53
+ puts "****"
54
+ puts "Error connecting to database: #{e}"
55
+ puts "****"
56
+ puts YAML::dump(configs)
57
+ exit 1
58
+ end
59
+
60
+ # Executes a command on all the slaves, sequentially.
61
+ # Takes an optional set of connection paramters to override defaults.
62
+ def self.execute_on_slaves(cmd, configs={})
63
+ slaves.each do |name, slave_configs|
64
+ puts "Executing #{cmd} on #{name}"
65
+ puts slave_configs.inspect
66
+ connection = connect(slave_configs.merge(user).merge(configs))
67
+ connection.execute(cmd)
68
+ end
69
+ end
70
+
71
+ # Execute STOP SLAVE on all slaves;
72
+ def self.stop_slaves
73
+ execute_on_slaves("STOP SLAVE;")
74
+ end
75
+
76
+ # Execute START SLAVE on all slaves.
77
+ def self.start_slaves
78
+ execute_on_slaves("START SLAVE;")
79
+ end
80
+
81
+ # Creates users with specific permissions on the different mysql servers, both masters and slaves.
82
+ # Prompts for username and password of an account that has GRANT priviledges.
83
+ def self.setup
84
+ raise "GET CONFIGS"
85
+ grant_user_configs = User.prompt_for_grant_user
86
+ # TODO: use specific tables, not *.*
87
+ # TODO: are these on localhost? or where?
88
+ # TODO: We need all databases in order to grant permissions there.
89
+ # TODO: There could be a different GRANT password for each mysql server, so identify which before asking for permissions.
90
+ execute_on_slaves("GRANT select ON *.* TO #{ro_user}@???? INDENTIFIED BY #{ro_password}", grant_user_configs)
91
+ execute_on_slaves("GRANT select ON *.* TO #{repl_user}@???? INDENTIFIED BY #{repl_password}", grant_user_configs)
92
+ end
93
+
94
+ def self.setup_heartbeat
95
+ # MySQL DTD for setting up Heartbeats. Change HEARTBEAT_USER, HEARTBEAT_PASS, and MONITORING_BOX to ip of the monitoring server.
96
+ # GRANT SELECT, INSERT, UPDATE, ALTER ON replication_monitor.* TO 'HEARTBEAT_USER'@"localhost" IDENTIFIED BY 'HEARTBEAT_PASS';
97
+ # GRANT SELECT, INSERT, UPDATE, ALTER ON replication_monitor.* TO 'HEARTBEAT_USER'@"MONITORING_BOX" IDENTIFIED BY 'HEARTBEAT_PASS';
98
+ #
99
+ # CREATE DATABASE replication_monitor;
100
+ #
101
+ # CREATE TABLE replication_monitor.heartbeats (
102
+ # unix_time INTEGER NOT NULL,
103
+ # db_time TIMESTAMP NOT NULL,
104
+ # INDEX time_idx(unix_time)
105
+ # )
106
+ end
107
+
108
+ def self.test_connections
109
+ databases.each do |db|
110
+ databases.roles.each do |role|
111
+ db[role]
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,79 @@
1
+ require 'tlsmail'
2
+
3
+ module Reptile
4
+ # The Status class is responsible for asking a slave database for its status is,
5
+ # parsing the result, and returning the appropiate status code.
6
+ #
7
+ # This class also allows you to convert a status code to a friendly message the corresponds to that status.
8
+
9
+ class Status
10
+
11
+ @@errors = []
12
+
13
+ # Status code indicating the SQL thread has stopped running.
14
+ SQL_THREAD_DOWN = 'sql_thread_down'
15
+
16
+ # Status code indicating the IO thread has stopped running.
17
+ IO_THREAD_DOWN = 'io_thread_down'
18
+
19
+ # Status code indicating that the slave has stopped replicating.
20
+ SLAVE_DOWN = 'slave_down'
21
+
22
+ # Status code indicating that the slave is up and running.
23
+ RUNNING = 'running'
24
+
25
+ # Set the user settings for a user that has global SELECT privilidgess
26
+ def self.user=(user_settings)
27
+ @user = user_settings
28
+ end
29
+
30
+ # The user settings for a user that has global select privilidgess
31
+ def self.user
32
+ raise "You need to specify a user!" if @user.nil?
33
+ @user
34
+ end
35
+
36
+ # Checks the value of the MySQL command "SHOW SLAVE STATUS".
37
+ # Returns a status code.
38
+ def self.check_slave_status(name, configs)
39
+ # TODO: Do this in Databases
40
+ configs.delete("port")
41
+ configs['database'] = "information_schema"
42
+ Databases.connect(configs.merge(user)).execute('SHOW SLAVE STATUS').each do |row|
43
+ if row =~ /Slave_SQL_Running/ && row =~ /No/
44
+ return SQL_THREAD_DOWN
45
+ elsif row =~ /Slave_IO_Running/ && row =~ /No/
46
+ return IO_THREAD_DOWN
47
+ elsif row =~ /Slave_Running/ && row =~ /No/
48
+ return SLAVE_DOWN
49
+ else
50
+ return RUNNING
51
+ end
52
+ end
53
+ end
54
+
55
+ # Returns a nice error message for the given status code
56
+ def self.get_error_message(status)
57
+ case status
58
+ when SQL_THREAD_DOWN
59
+ "The SQL thread has stopped"
60
+ when IO_THREAD_DOWN
61
+ "The IO thread has stopped"
62
+ when SLAVE_DOWN
63
+ "The slave has stoppped"
64
+ else
65
+ raise "Invalid status code. Must be one of #{status_codes.keys.inspect}"
66
+ end
67
+ end
68
+
69
+ # A hash containing the names of the constants that represent status codes,
70
+ # and the strings they represent
71
+ def self.status_codes
72
+ status_codes = {}
73
+ self.constants.each do |const|
74
+ status_codes[const] = const_get(const)
75
+ end
76
+ status_codes
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,64 @@
1
+ module Reptile
2
+ # The Users class is holds the different sets of parameters for logging into a database,
3
+ # including username, password, etc. There are read_only configs, replication user configs,
4
+ # heartbeat configs, etc, for correctly and safely allowing access to different databases for
5
+ # different reasons.
6
+ #
7
+ # This also data also is used to create the permissions, allowing the setup of replication
8
+ # to be even easier.
9
+ class Users
10
+ def initialize(options)
11
+ @repl_user = options["replication_user"]
12
+ @ro_user = options["ro_user"]
13
+ @heartbeat_user = options["heartbeat_user"]
14
+ end
15
+
16
+
17
+ def self.prompt_for_grant_user
18
+ require 'rubygems'
19
+ require 'highline'
20
+
21
+ asker = HighLine.new
22
+ asker.say("Please enter credentials for a user that has GRANT priviledges.")
23
+ {:username => asker.ask("Enter your username:"),
24
+ :password => asker.ask("Enter your password: ") { |q| q.echo = "x" }}
25
+ end
26
+
27
+ # # Set the user settings for a user that has REPLICATION SLAVE privilidgess
28
+ # def replication_user=(replication_user_settings)
29
+ # @repl_user = replication_user_settings
30
+ # end
31
+
32
+ # The user settings for a user that has REPLICATION SLAVE privilidgess
33
+ def replication_user
34
+ # TODO: only bail on getting a user if it is acutally used
35
+ #raise "You need to specify a replication user!" if @repl_user.nil?
36
+ @repl_user
37
+ end
38
+
39
+
40
+ # # Set the user settings for a user that has SELECT privilidgess
41
+ # def ro_user=(ro_user_settings)
42
+ # @ro_user = ro_user_settings
43
+ # end
44
+
45
+ # The user settings for a user that has SELECT privilidgess
46
+ def ro_user
47
+ #raise "You need to specify a SELECT user!" if @ro_user.nil?
48
+ @ro_user || {}
49
+ end
50
+
51
+
52
+ # # Set the user settings for a user that has reads/writes heartbeats
53
+ # def heartbeat_user=(heartbeat_user_settings)
54
+ # @heartbeat_user = heartbeat_user_settings
55
+ # end
56
+
57
+ # The user settings for a user that reads/writes heartbeats
58
+ def heartbeat_user
59
+ #raise "You need to specify a heartbeat user!" if @heartbeat_user.nil?
60
+ @heartbeat_user || {}
61
+ end
62
+
63
+ end
64
+ end
data/lib/reptile.rb ADDED
@@ -0,0 +1,23 @@
1
+ $:.unshift(File.dirname(__FILE__)) unless
2
+ $:.include?(File.dirname(__FILE__)) || $:.include?(File.expand_path(File.dirname(__FILE__)))
3
+
4
+ require 'ostruct'
5
+ require 'openssl'
6
+ require 'rubygems'
7
+ require 'tlsmail'
8
+ require 'net/smtp'
9
+
10
+
11
+ require 'reptile/heartbeat'
12
+ require 'reptile/delta_monitor'
13
+ require 'reptile/replication_monitor'
14
+ require 'reptile/status'
15
+ require 'reptile/runner'
16
+ require 'reptile/users'
17
+ require 'reptile/databases'
18
+
19
+ require 'active_record'
20
+
21
+ module Reptile
22
+ VERSION = '0.0.1'
23
+ end
data/script/console ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env ruby
2
+ # File: script/console
3
+ irb = RUBY_PLATFORM =~ /(:?mswin|mingw)/ ? 'irb.bat' : 'irb'
4
+
5
+ libs = " -r irb/completion"
6
+ # Perhaps use a console_lib to store any extra methods I may want available in the cosole
7
+ # libs << " -r #{File.dirname(__FILE__) + '/../lib/console_lib/console_logger.rb'}"
8
+ libs << " -r #{File.dirname(__FILE__) + '/../lib/reptile.rb'}"
9
+ puts "Loading reptile gem"
10
+ exec "#{irb} #{libs} --simple-prompt"
data/script/destroy ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+ APP_ROOT = File.expand_path(File.join(File.dirname(__FILE__), '..'))
3
+
4
+ begin
5
+ require 'rubigen'
6
+ rescue LoadError
7
+ require 'rubygems'
8
+ require 'rubigen'
9
+ end
10
+ require 'rubigen/scripts/destroy'
11
+
12
+ ARGV.shift if ['--help', '-h'].include?(ARGV[0])
13
+ RubiGen::Base.use_component_sources! [:rubygems, :newgem, :newgem_theme, :test_unit]
14
+ RubiGen::Scripts::Destroy.new.run(ARGV)
data/script/generate ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+ APP_ROOT = File.expand_path(File.join(File.dirname(__FILE__), '..'))
3
+
4
+ begin
5
+ require 'rubigen'
6
+ rescue LoadError
7
+ require 'rubygems'
8
+ require 'rubigen'
9
+ end
10
+ require 'rubigen/scripts/generate'
11
+
12
+ ARGV.shift if ['--help', '-h'].include?(ARGV[0])
13
+ RubiGen::Base.use_component_sources! [:rubygems, :newgem, :newgem_theme, :test_unit]
14
+ RubiGen::Scripts::Generate.new.run(ARGV)
@@ -0,0 +1,3 @@
1
+ require 'stringio'
2
+ require 'test/unit'
3
+ require File.dirname(__FILE__) + '/../lib/reptile'
@@ -0,0 +1,11 @@
1
+ require File.dirname(__FILE__) + '/test_helper.rb'
2
+
3
+ class TestReptile < Test::Unit::TestCase
4
+
5
+ def setup
6
+ end
7
+
8
+ def test_truth
9
+ assert true
10
+ end
11
+ end
metadata ADDED
@@ -0,0 +1,97 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: reptile
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - FIXME full name
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+
12
+ date: 2009-06-26 00:00:00 -07:00
13
+ default_executable:
14
+ dependencies:
15
+ - !ruby/object:Gem::Dependency
16
+ name: newgem
17
+ type: :development
18
+ version_requirement:
19
+ version_requirements: !ruby/object:Gem::Requirement
20
+ requirements:
21
+ - - ">="
22
+ - !ruby/object:Gem::Version
23
+ version: 1.4.1
24
+ version:
25
+ - !ruby/object:Gem::Dependency
26
+ name: hoe
27
+ type: :development
28
+ version_requirement:
29
+ version_requirements: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: 1.8.0
34
+ version:
35
+ description: ""
36
+ email:
37
+ - FIXME email
38
+ executables:
39
+ - replication_status
40
+ extensions: []
41
+
42
+ extra_rdoc_files:
43
+ - History.txt
44
+ - Manifest.txt
45
+ - PostInstall.txt
46
+ - README.rdoc
47
+ files:
48
+ - History.txt
49
+ - Manifest.txt
50
+ - PostInstall.txt
51
+ - README.rdoc
52
+ - Rakefile
53
+ - bin/replication_status
54
+ - lib/reptile.rb
55
+ - lib/reptile/databases.rb
56
+ - lib/reptile/delta_monitor.rb
57
+ - lib/reptile/dtd.sql
58
+ - lib/reptile/heartbeat.rb
59
+ - lib/reptile/replication_monitor.rb
60
+ - lib/reptile/runner.rb
61
+ - lib/reptile/status.rb
62
+ - lib/reptile/users.rb
63
+ - script/console
64
+ - script/destroy
65
+ - script/generate
66
+ - test/test_helper.rb
67
+ - test/test_reptile.rb
68
+ has_rdoc: true
69
+ homepage: Reptile is an easy to use utility that will monitor your MySQL replication, so you can forget about it and focus on the good stuff. It provides a utility for generate replication reports, and can email if replication appears to be failing.
70
+ post_install_message: PostInstall.txt
71
+ rdoc_options:
72
+ - --main
73
+ - README.rdoc
74
+ require_paths:
75
+ - lib
76
+ required_ruby_version: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: "0"
81
+ version:
82
+ required_rubygems_version: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - ">="
85
+ - !ruby/object:Gem::Version
86
+ version: "0"
87
+ version:
88
+ requirements: []
89
+
90
+ rubyforge_project: reptile
91
+ rubygems_version: 1.3.1
92
+ signing_key:
93
+ specification_version: 2
94
+ summary: ""
95
+ test_files:
96
+ - test/test_helper.rb
97
+ - test/test_reptile.rb