reptile 0.0.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.
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