jetpants 0.7.0

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.
@@ -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
+