jetpants 0.7.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,74 @@
1
+ # Entrypoint for simple_tracker example asset tracker plugin.
2
+ # config options:
3
+ # tracker_data_file_path -- path and filename of where to save the asset data JSON file
4
+ # app_config_file_path -- path and filename of where to save the database configuration YAML file for a fictional web app
5
+
6
+ require 'json'
7
+
8
+ module Jetpants
9
+ module Plugin
10
+
11
+ # The SimpleTracker class just handles the manipulations of the asset JSON file and the application
12
+ # YAML file. The Jetpants::Topology class is monkeypatched to maintain a single SimpleTracker object,
13
+ # which it uses to interact with these files.
14
+ class SimpleTracker
15
+ # Array of hashes, each containing info from Pool#to_hash
16
+ attr_accessor :global_pools
17
+
18
+ # Array of hashes, each containing info from Shard#to_hash
19
+ attr_accessor :shards
20
+
21
+ # Array of any of the following:
22
+ # * hashes each containing key 'node'. could expand to include 'role' or other metadata as well,
23
+ # but currently not supported.
24
+ # * objects responding to to_db, such as String or Jetpants::DB
25
+ attr_accessor :spares
26
+
27
+ attr_reader :app_config_file_path
28
+
29
+ def initialize
30
+ @tracker_data_file_path = Jetpants.plugins['simple_tracker']['tracker_data_file_path'] || '/etc/jetpants_tracker.json'
31
+ @app_config_file_path = Jetpants.plugins['simple_tracker']['app_config_file_path'] || '/var/lib/mysite/config/databases.yaml'
32
+ data = JSON.parse(File.read(@tracker_data_file_path)) rescue {'pools' => {}, 'shards' => [], 'spares' => []}
33
+ @global_pools = data['pools']
34
+ @shards = data['shards']
35
+ @spares = data['spares']
36
+ end
37
+
38
+ def save
39
+ File.open(@tracker_data_file_path, 'w') do |f|
40
+ data = {'pools' => @global_pools, 'shards' => @shards, 'spares' => @spares}
41
+ f.puts JSON.pretty_generate(data)
42
+ end
43
+ end
44
+
45
+ def determine_pool_and_role(ip, port=3306)
46
+ ip += ":#{port}" if port.to_i != 3306
47
+
48
+ [@global_pools + @shards].each do |h|
49
+ pool = (h['name'] ? Jetpants.topology.pool(h['name']) : Jetpants.topology.shard(h['min_id'], h['max_id']))
50
+ return [pool, 'MASTER'] if h['master'] == ip
51
+ h['slaves'].each do |s|
52
+ return [pool, s['role']] if s['host'] == ip
53
+ end
54
+ end
55
+
56
+ raise "Unable to find #{ip} among tracked assets"
57
+ end
58
+
59
+ def determine_slaves(ip, port=3306)
60
+ ip += ":#{port}" if port.to_i != 3306
61
+
62
+ [@global_pools + @shards].each do |h|
63
+ next unless h['master'] == ip
64
+ return h['slaves'].map {|s| s['host'].to_db}
65
+ end
66
+ [] # return empty array if not a master
67
+ end
68
+
69
+ end
70
+ end
71
+ end
72
+
73
+ # load all the monkeypatches for other Jetpants classes
74
+ %w(pool shard topology).each {|mod| require "simple_tracker/#{mod}"}
@@ -0,0 +1,66 @@
1
+ module Jetpants
2
+ class Topology
3
+
4
+ attr_accessor :tracker
5
+
6
+ ##### METHOD OVERRIDES #####################################################
7
+
8
+ # Populates @pools by reading asset tracker data
9
+ def load_pools
10
+ @tracker = Jetpants::Plugin::SimpleTracker.new
11
+
12
+ # Create Pool and Shard objects
13
+ @pools.concat(@tracker.global_pools.map {|h| Pool.from_hash(h)}.compact)
14
+ all_shards = @tracker.shards.map {|h| Shard.from_hash(h)}.reject {|s| s.state == :recycle}
15
+ @pools.concat all_shards
16
+
17
+ # Now that all shards exist, we can safely assign parent/child relationships
18
+ @tracker.shards.each {|h| Shard.assign_relationships(h, all_shards)}
19
+ end
20
+
21
+ # Generates a database configuration file for a hypothetical web application
22
+ def write_config
23
+ config_file_path = @tracker.app_config_file_path
24
+
25
+ # Convert the pool list into a hash
26
+ db_data = {
27
+ 'database' => {
28
+ 'pools' => functional_partitions.map {|p| p.to_hash(true)},
29
+ 'shards' => shards.select {|s| s.in_config?}.map {|s| s.to_hash(true)},
30
+ }
31
+ }
32
+
33
+ # Convert that hash to YAML and write it to a file
34
+ File.open(config_file_path, 'w') do |f|
35
+ f.write db_data.to_yaml
36
+ end
37
+ puts "Regenerated #{config_file_path}"
38
+ end
39
+
40
+ def claim_spares(count, options={})
41
+ raise "Not enough spare machines -- requested #{count}, only have #{@tracker.spares.count}" if @tracker.spares.count < count
42
+ hashes = @tracker.spares.shift(count)
43
+ hashes.map {|h| h['node'] ? h['node'].to_db : h.to_db}
44
+ end
45
+
46
+ def count_spares(options={})
47
+ @tracker.spares.count
48
+ end
49
+
50
+
51
+ ##### NEW METHODS ##########################################################
52
+
53
+ # Called by Pool#sync_configuration to update our asset tracker json.
54
+ # This actually re-writes all the json. With a more dynamic asset tracker
55
+ # (something backed by a database, for example) this wouldn't be necessary -
56
+ # instead Pool#sync_configuration could just update the info for that pool
57
+ # only.
58
+ def update_tracker_data
59
+ @tracker.global_pools = functional_partitions.map &:to_hash
60
+ @tracker.shards = shards.map &:to_hash
61
+ @tracker.save
62
+ end
63
+
64
+
65
+ end
66
+ end
@@ -0,0 +1,260 @@
1
+ module Jetpants
2
+ module Tasks
3
+ class Promotion
4
+
5
+ def initialize nodes = {}
6
+ @demoted = nodes['demote']
7
+ @promoted = nodes['promote']
8
+ super
9
+ Jetpants.verify_replication = false # since master may be offline
10
+ advise
11
+ establish_roles
12
+ prepare
13
+ end
14
+
15
+ def error message
16
+ abort ['ERROR:'.red, message].join ' '
17
+ end
18
+
19
+ def inform message
20
+ puts message.blue
21
+ end
22
+
23
+ def is_ip? address
24
+ address =~ /(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/
25
+ end
26
+
27
+ def establish_roles
28
+ establish_demoted
29
+ establish_replicas
30
+ establish_promoted
31
+ end
32
+
33
+ def establish_demoted
34
+ # derive demoted from promoted if possible
35
+ if @promoted and not @demoted
36
+ error "invalid ip address #{@promoted}" unless is_ip? @promoted
37
+ @promoted = Jetpants::DB.new @promoted
38
+
39
+ # bail the promoted node isn't a slave or we can't connect
40
+ unless @promoted.is_slave?
41
+ error "node (#{@promoted}) does not appear to be a replica of another node"
42
+ end rescue error("unable to connect to node #{@promoted} to promote")
43
+
44
+ # recommend a node to demote
45
+ agreed = agree [
46
+ "Would you like to demote the following node?",
47
+ "address: #{@promoted.master}",
48
+ "slaves : #{@promoted.master.slaves.join(', ')}",
49
+ "- yes/no -"
50
+ ].join "\n"
51
+ error "unable to promote #{@promoted} unless you demote #{@promoted.master}" unless agreed
52
+
53
+ @demoted = @promoted.master.ip
54
+ end
55
+
56
+ # unable to derive demoted, so ask and convert to a DB object
57
+ unless @demoted.kind_of? Jetpants::DB
58
+ @demoted = ask 'Please enter the node to demote:' unless @demoted
59
+ error "Invalid IP address #{@demoted}" unless is_ip? @demoted
60
+ @demoted = @demoted.to_db
61
+ end
62
+
63
+ # connect and ensure node is a master; handle offline nodes appropriately
64
+ if @demoted.available?
65
+ error 'Cannot demote a node that has no slaves!' unless @demoted.has_slaves?
66
+ else
67
+ inform "unable to connect to node #{@demoted} to demote"
68
+ error "unable to perform promotion" unless agree "please confirm that #{@demoted} is offline: yes/no "
69
+ @replicas = @demoted.slaves # An asset-tracker plugin may have been populated the slave list anyway
70
+ if !@replicas || @replicas.count < 1
71
+ replicas = ask "please provide a comma seperated list of current replicas of #{@demoted}: ", lambda {|replicas| replicas.split /,\s*/}
72
+ error "user supplied list of replicas appears to be invalid - #{replicas}" unless replicas.all? {|replica| is_ip? replica}
73
+ @replicas = replicas.collect {|replica| replica.to_db}
74
+
75
+ # ensure they were replicas of @demoted
76
+ @replicas.each do |replica|
77
+ error "#{replica} does not appear to be a valid replica of #{@demoted}" unless replica.master == @demoted
78
+ end
79
+ end
80
+ end
81
+
82
+ error 'unable to establish demoteable node' unless @demoted.kind_of? Jetpants::DB
83
+ end
84
+
85
+ def establish_replicas
86
+ @replicas ||= @demoted.slaves
87
+ error 'no replicas to promote' if @replicas.empty?
88
+ error 'replicas appear to be invalid' unless @replicas.all? {|replica| replica.kind_of? Jetpants::DB}
89
+ inform "#{@demoted} has the following replicas: #{@replicas.join(', ')}"
90
+ end
91
+
92
+ def establish_promoted
93
+ # user supplied node to promote
94
+ if @promoted and not @promoted.kind_of? Jetpants::DB
95
+ error "invalid ip address #{@promoted}" unless is_ip? @promoted
96
+ @promoted = Jetpants::DB.new @promoted
97
+ end
98
+
99
+ # user hasn't supplied a valid node to promote
100
+ unless @replicas.include? @promoted
101
+ inform "unable to promote node (#{@promoted}) that is not a replica of #{@demoted}" if @promoted
102
+
103
+ # recommend a node
104
+ puts "\nREPLICA LIST:"
105
+ @replicas.sort_by {|replica| replica.seconds_behind_master}.each do |node|
106
+ file, pos = node.repl_binlog_coordinates(false)
107
+ puts " * %-13s %-30s lag: %2ds coordinates: (%-13s, %d)" % [node.ip, node.hostname, node.seconds_behind_master, file, pos]
108
+ end
109
+ puts
110
+ recommended = @replicas.sort_by {|replica| replica.seconds_behind_master}.reject {|r| r.for_backups?}.first
111
+ agreed = agree [
112
+ "Would you like to promote the following replica?",
113
+ "#{recommended.ip} (#{recommended.hostname})",
114
+ "- yes/no -"
115
+ ].join "\n"
116
+ @promoted = recommended if agreed
117
+
118
+ # choose a new node if they disagreed with our recommendation
119
+ unless agreed
120
+ choose do |promote|
121
+ promote.prompt = 'Please choose a replica to promote:'
122
+ @replicas.each do |replica|
123
+ promote.choice "#{replica} - replication lag: #{replica.seconds_behind_master} seconds" do
124
+ @promoted = replica
125
+ end
126
+ end
127
+ end
128
+ raise "You chose a backup slave. These are not suitable for promotion. Please try again." if @promoted.for_backups?
129
+ end
130
+ end
131
+
132
+ error "unable to establish node to promote" unless @promoted.kind_of? Jetpants::DB
133
+ end
134
+
135
+ def advise
136
+ @states = {
137
+ preparing: "processing promotion requirements",
138
+ prepared: "preparing to disable writes on #{@demoted}",
139
+ read_only: "writes have been disabled on #{@demoted}, preparing to demote #{@demoted} and promote #{@promoted}",
140
+ promoted: "#{@promoted} has been promoted, please prepare database config for deploy.",
141
+ deployable: "promotion is complete, please commit and deploy.",
142
+ }
143
+ inform @states[@state.to_sym]
144
+ end
145
+
146
+ state_machine :initial => :preparing do
147
+ after_transition any => any, :do => :advise
148
+
149
+ event :prepare do
150
+ transition :preparing => :prepared, :if => :roles_populated?
151
+ end
152
+ after_transition :preparing => :prepared, :do => :disable_writes
153
+
154
+ event :disable_writes do
155
+ transition :prepared => :read_only, :if => :read_only!
156
+ end
157
+ after_transition :prepared => :read_only, :do => :promote
158
+
159
+ event :promote do
160
+ transition :read_only => :promoted, :if => :execute_promotion
161
+ end
162
+ after_transition :read_only => :promoted, :do => :prepare_config
163
+
164
+ event :prepare_config do
165
+ transition :promoted => :deployable, :if => :nodes_consistent?
166
+ end
167
+ after_transition :promoted => :deployable, :do => :summarize_promotion
168
+
169
+ state :preparing, :prepared do
170
+ def is_db? node
171
+ node.kind_of? Jetpants::DB
172
+ end
173
+
174
+ def roles_populated?
175
+ # ensure our roles are populated with dbs
176
+ [@demoted, @promoted, @replicas].all? do |role|
177
+ is_db? role or role.all? do |node|
178
+ is_db? node
179
+ end
180
+ end
181
+ end
182
+
183
+ def read_only!
184
+ unless @demoted.available?
185
+ status = @promoted.slave_status
186
+ @log, @position = status[:master_log_file], status[:exec_master_log_pos].to_i
187
+ return true
188
+ end
189
+
190
+ # set read_only if needed
191
+ @demoted.read_only! unless @demoted.read_only?
192
+ # bail if we're unable to set read_only
193
+ error "unable to set 'read_only' on #{@demoted}" unless @demoted.read_only?
194
+ # record the current log possition to ensure writes are not taking place later.
195
+ @log, @position = @demoted.binlog_coordinates
196
+ error "#{@demoted} is still taking writes, unable to promote #{@promoted}" unless writes_disabled?
197
+ @demoted.read_only?
198
+ end
199
+
200
+ def writes_disabled?
201
+ return true unless @demoted.available?
202
+
203
+ # ensure no writes have been logged since read_only!
204
+ [@log, @position] == @demoted.binlog_coordinates
205
+ end
206
+
207
+ end
208
+
209
+ state :read_only, :promoted, :promoted, :deployable do
210
+ def nodes_consistent?
211
+ return true unless @demoted.available?
212
+ @replicas.all? {|replica| replica.slave_status[:exec_master_log_pos].to_i == @position}
213
+ end
214
+
215
+ def ensure_nodes_consistent?
216
+ inform "ensuring replicas are in a consistent state"
217
+ until nodes_consistent? do
218
+ print '.'
219
+ sleep 0.5
220
+ end
221
+ nodes_consistent?
222
+ end
223
+
224
+ def promotable?
225
+ disable_replication if ensure_nodes_consistent? and @promoted.disable_read_only!
226
+ end
227
+
228
+ def execute_promotion
229
+ error 'nodes are not in a promotable state.' unless promotable?
230
+ error 'replicas are not in a consistent state' unless nodes_consistent?
231
+
232
+ @demoted.pool.master_promotion! @promoted
233
+ end
234
+
235
+ def replicas_replicating? replicas = @replicas
236
+ replicas.all? {|replica| replica.replicating?}
237
+ end
238
+
239
+ def disable_replication replicas = @replicas
240
+ replicas.each do |replica|
241
+ replica.pause_replication if replica.replicating?
242
+ end
243
+ not replicas_replicating? replicas
244
+ end
245
+
246
+ def summarize_promotion transition
247
+ summary = Terminal::Table.new :title => 'Promotion Summary:' do |rows|
248
+ rows << ['demoted', @demoted]
249
+ rows << ['promoted', @promoted]
250
+ rows << ["replicas of #{@promoted}", @promoted.slaves.join(', ')]
251
+ end
252
+ puts summary
253
+ exit
254
+ end
255
+ end
256
+ end
257
+
258
+ end
259
+ end
260
+ end
metadata ADDED
@@ -0,0 +1,191 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: jetpants
3
+ version: !ruby/object:Gem::Version
4
+ prerelease:
5
+ version: 0.7.0
6
+ platform: ruby
7
+ authors:
8
+ - Evan Elias
9
+ - Dallas Marlow
10
+ autorequire:
11
+ bindir: bin
12
+ cert_chain: []
13
+
14
+ date: 2012-06-07 00:00:00 Z
15
+ dependencies:
16
+ - !ruby/object:Gem::Dependency
17
+ name: mysql2
18
+ prerelease: false
19
+ requirement: &id001 !ruby/object:Gem::Requirement
20
+ none: false
21
+ requirements:
22
+ - - ">="
23
+ - !ruby/object:Gem::Version
24
+ version: "0"
25
+ type: :runtime
26
+ version_requirements: *id001
27
+ - !ruby/object:Gem::Dependency
28
+ name: sequel
29
+ prerelease: false
30
+ requirement: &id002 !ruby/object:Gem::Requirement
31
+ none: false
32
+ requirements:
33
+ - - ">="
34
+ - !ruby/object:Gem::Version
35
+ version: "0"
36
+ type: :runtime
37
+ version_requirements: *id002
38
+ - !ruby/object:Gem::Dependency
39
+ name: net-ssh
40
+ prerelease: false
41
+ requirement: &id003 !ruby/object:Gem::Requirement
42
+ none: false
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: "0"
47
+ type: :runtime
48
+ version_requirements: *id003
49
+ - !ruby/object:Gem::Dependency
50
+ name: state_machine
51
+ prerelease: false
52
+ requirement: &id004 !ruby/object:Gem::Requirement
53
+ none: false
54
+ requirements:
55
+ - - ">="
56
+ - !ruby/object:Gem::Version
57
+ version: "0"
58
+ type: :runtime
59
+ version_requirements: *id004
60
+ - !ruby/object:Gem::Dependency
61
+ name: pry
62
+ prerelease: false
63
+ requirement: &id005 !ruby/object:Gem::Requirement
64
+ none: false
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: "0"
69
+ type: :runtime
70
+ version_requirements: *id005
71
+ - !ruby/object:Gem::Dependency
72
+ name: thor
73
+ prerelease: false
74
+ requirement: &id006 !ruby/object:Gem::Requirement
75
+ none: false
76
+ requirements:
77
+ - - ">="
78
+ - !ruby/object:Gem::Version
79
+ version: "0"
80
+ type: :runtime
81
+ version_requirements: *id006
82
+ - !ruby/object:Gem::Dependency
83
+ name: highline
84
+ prerelease: false
85
+ requirement: &id007 !ruby/object:Gem::Requirement
86
+ none: false
87
+ requirements:
88
+ - - ">="
89
+ - !ruby/object:Gem::Version
90
+ version: "0"
91
+ type: :runtime
92
+ version_requirements: *id007
93
+ - !ruby/object:Gem::Dependency
94
+ name: terminal-table
95
+ prerelease: false
96
+ requirement: &id008 !ruby/object:Gem::Requirement
97
+ none: false
98
+ requirements:
99
+ - - ">="
100
+ - !ruby/object:Gem::Version
101
+ version: "0"
102
+ type: :runtime
103
+ version_requirements: *id008
104
+ - !ruby/object:Gem::Dependency
105
+ name: colored
106
+ prerelease: false
107
+ requirement: &id009 !ruby/object:Gem::Requirement
108
+ none: false
109
+ requirements:
110
+ - - ">="
111
+ - !ruby/object:Gem::Version
112
+ version: "0"
113
+ type: :runtime
114
+ version_requirements: *id009
115
+ description: Jetpants is an automation toolkit for handling monstrously large MySQL database topologies. It is geared towards common operational tasks like cloning slaves, rebalancing shards, and performing master promotions. It features a command suite for easy use by operations staff, though it's also a full Ruby library for use in developing custom migration scripts and database automation.
116
+ email:
117
+ - me@evanelias.com
118
+ - dallasmarlow@gmail.com
119
+ executables:
120
+ - jetpants
121
+ extensions: []
122
+
123
+ extra_rdoc_files:
124
+ - README.rdoc
125
+ - doc/plugins.rdoc
126
+ - doc/configuration.rdoc
127
+ - doc/commands.rdoc
128
+ - doc/requirements.rdoc
129
+ files:
130
+ - Gemfile
131
+ - README.rdoc
132
+ - doc/plugins.rdoc
133
+ - doc/configuration.rdoc
134
+ - doc/commands.rdoc
135
+ - doc/requirements.rdoc
136
+ - lib/jetpants/callback.rb
137
+ - lib/jetpants/topology.rb
138
+ - lib/jetpants/db/server.rb
139
+ - lib/jetpants/db/state.rb
140
+ - lib/jetpants/db/import_export.rb
141
+ - lib/jetpants/db/privileges.rb
142
+ - lib/jetpants/db/client.rb
143
+ - lib/jetpants/db/replication.rb
144
+ - lib/jetpants/shard.rb
145
+ - lib/jetpants/db.rb
146
+ - lib/jetpants/host.rb
147
+ - lib/jetpants/pool.rb
148
+ - lib/jetpants/monkeypatch.rb
149
+ - lib/jetpants/table.rb
150
+ - lib/jetpants.rb
151
+ - bin/jetpants
152
+ - plugins/simple_tracker/topology.rb
153
+ - plugins/simple_tracker/shard.rb
154
+ - plugins/simple_tracker/simple_tracker.rb
155
+ - plugins/simple_tracker/db.rb
156
+ - plugins/simple_tracker/pool.rb
157
+ - tasks/promotion.rb
158
+ - etc/jetpants.yaml.sample
159
+ homepage: https://github.com/tumblr/jetpants/
160
+ licenses: []
161
+
162
+ post_install_message:
163
+ rdoc_options:
164
+ - --line-numbers
165
+ - --title
166
+ - "Jetpants: a MySQL automation toolkit by Tumblr"
167
+ - --main
168
+ - README.rdoc
169
+ require_paths:
170
+ - lib
171
+ required_ruby_version: !ruby/object:Gem::Requirement
172
+ none: false
173
+ requirements:
174
+ - - ">="
175
+ - !ruby/object:Gem::Version
176
+ version: 1.9.2
177
+ required_rubygems_version: !ruby/object:Gem::Requirement
178
+ none: false
179
+ requirements:
180
+ - - ">="
181
+ - !ruby/object:Gem::Version
182
+ version: "0"
183
+ requirements: []
184
+
185
+ rubyforge_project:
186
+ rubygems_version: 1.8.10
187
+ signing_key:
188
+ specification_version: 3
189
+ summary: "Jetpants: a MySQL automation toolkit by Tumblr"
190
+ test_files: []
191
+