mongolly 0.1.1 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -16,17 +16,17 @@ module Mongolly
16
16
  end
17
17
 
18
18
  desc "backup", "Snapshots the Database EBS Volumes"
19
+ method_option :dry_run, type: :boolean, desc: 'Step through command without changes'
19
20
  def backup
20
- puts " ** Backing up ..."
21
- Shepherd.new(@config).backup
21
+ Shepherd.new({dry_run: options[:dry_run]}.merge(@config)).backup
22
22
  end
23
23
 
24
24
  desc "clean", "Removes old Database EBS Snapshots"
25
25
  method_option :age, aliases: '-a', required: true
26
+ method_option :dry_run, type: :boolean, desc: 'Step through command without changes'
26
27
  def clean
27
28
  age = Time.parse(options[:age])
28
- puts " ** Cleaning snapshots older than #{age}"
29
- Shepherd.new(@config).cleanup(age)
29
+ Shepherd.new({dry_run: options[:dry_run]}.merge(@config)).cleanup(age)
30
30
  end
31
31
 
32
32
  private
@@ -39,7 +39,11 @@ module Mongolly
39
39
  db_password: nil,
40
40
  access_key_id: nil,
41
41
  secret_access_key: nil,
42
- aws_region: 'us-east-1'
42
+ region: 'us-east-1',
43
+ log_level: 'info',
44
+ mongo_start_command: nil,
45
+ mongo_stop_command: nil,
46
+ config_server_ssh_user: nil
43
47
  }
44
48
 
45
49
  File.open( CONFIG_PATH, "w" ) do |f|
@@ -14,7 +14,7 @@ class AWS::EC2::InstanceCollection
14
14
 
15
15
  return instances.first
16
16
  rescue SocketError
17
- raise RuntimeError.new("Unable to determine IP address from #{address}")
17
+ raise RuntimeError.new("Unable to determine IP address from #{address}:#{port}")
18
18
  end
19
19
 
20
20
  private
@@ -1,58 +1,242 @@
1
1
  require 'mongo'
2
+ require 'logger'
3
+ require 'net/ssh'
4
+ require 'retries'
5
+
6
+ require 'debugger'
2
7
 
3
8
  class Mongo::MongoClient
9
+ MAX_DISABLE_BALANCER_WAIT = 60*5 # 5 Minutes
4
10
 
5
11
  def snapshot_ebs(options={})
12
+
13
+ @mongolly_dry_run = options[:dry_run] || false
14
+ @mongolly_logger = options[:logger] || Logger.new(STDOUT)
6
15
  options[:volume_tag] ||= 'mongolly'
7
- ec2 = AWS::EC2.new(access_key_id: options[:access_key_id], secret_access_key: options[:secret_access_key], region: options[:region])
16
+ options[:backup_key] ||= (0...8).map{65.+(rand(25)).chr}.join
8
17
 
9
- instance = ec2.instances.find_from_address(*snapshot_ebs_target.split(':'))
10
- volumes = instance.volumes_with_tag(options[:volume_tag])
18
+ @ec2 = AWS::EC2.new(access_key_id: options[:access_key_id], secret_access_key: options[:secret_access_key], region: options[:region])
11
19
 
12
- raise RuntimeError "no suitable volumes found" unless volumes.length > 0
20
+ if mongos?
21
+ @mongolly_logger.info("Detected sharded cluster")
22
+ with_disabled_balancing do
23
+ stop_config_server(options[:config_server_ssh_user], options[:mongo_stop_command])
13
24
 
14
- begin
15
- if volumes.length >= 1
16
- disable_profiling
17
- lock!
25
+ backup_instance(config_server, options, false)
26
+
27
+ shards.each do |name,hosts|
28
+ @mongolly_logger.debug("Found Shard #{name} with hosts #{hosts}.")
29
+ replica_set_connection(hosts, options).snapshot_ebs(options)
30
+ end
31
+ start_config_server(options[:config_server_ssh_user], options[:mongo_start_command])
18
32
  end
33
+ else
34
+ backup_instance(snapshot_ebs_target, options, true )
35
+ end
36
+ end
37
+
38
+ protected
39
+ def snapshot_ebs_target
40
+ host_port.join(':')
41
+ end
42
+
43
+ def backup_instance(address, options, lock = true)
44
+ host, port = address.split(':')
45
+ instance = @ec2.instances.find_from_address(host, port)
46
+
47
+ @mongolly_logger.info("Backing up instance #{instance.id} from #{host}:#{port}")
48
+
49
+ volumes = instance.volumes_with_tag(options[:volume_tag])
50
+
51
+ @mongolly_logger.debug("Found target volumes #{volumes.map(&:id).join(', ')} ")
19
52
 
20
- backup_key = (0...8).map{65.+(rand(25)).chr}.join
53
+ raise RuntimeError.new "no suitable volumes found" unless volumes.length > 0
54
+
55
+ backup_block = proc do
21
56
  volumes.each do |volume|
22
- snapshot = volume.create_snapshot("#{backup_key} #{Time.now} mongolly backup")
23
- snapshot.add_tag('created_at', value: Time.now)
24
- snapshot.add_tag('backup_key', value: backup_key)
57
+ @mongolly_logger.debug("Snapshotting #{volume.id} with tag #{options[:backup_key]}")
58
+ unless @mongolly_dry_run
59
+ snapshot = volume.create_snapshot("#{options[:backup_key]} #{Time.now} mongolly #{host}")
60
+ snapshot.add_tag('created_at', value: Time.now)
61
+ snapshot.add_tag('backup_key', value: options[:backup_key])
62
+ end
63
+ end
64
+ end
65
+
66
+ if lock
67
+ with_database_locked &backup_block
68
+ else
69
+ backup_block.call
70
+ end
71
+ end
72
+
73
+ def disable_balancing
74
+ @mongolly_logger.debug "Disabling Shard Balancing"
75
+ self['config'].collection('settings').update({_id: 'balancer'}, {'$set' => {stopped: true}}, upsert: true) unless @mongolly_dry_run
76
+ end
77
+
78
+ def enable_balancing
79
+ @mongolly_logger.debug "Enabling Shard Balancing"
80
+ retry_logger = Proc.new do |exception, attempt_number, total_delay|
81
+ @mongolly_logger.debug "Error enabling balancing (config server not up?); retry attempt #{attempt_number}; #{total_delay} seconds have passed."
82
+ end
83
+ with_retries(max_tries: 5, handler: retry_logger, rescue: Mongo::OperationFailure, base_sleep_seconds: 5, max_sleep_seconds: 120) do
84
+ self['config'].collection('settings').update({_id: 'balancer'}, {'$set' => {stopped: false}}, upsert: true) unless @mongolly_dry_run
85
+ end
86
+ end
87
+
88
+ def balancer_active?
89
+ self['config'].collection('locks').find({_id: 'balancer'}).count > 1
90
+ end
91
+
92
+ def stop_config_server(user, stop_command)
93
+ raise RuntimeError.new "mongo ssh user not specified" unless user.to_s.strip != ''
94
+ raise RuntimeError.new "mongo ssh user not specified" unless stop_command.to_s.strip != ''
95
+ @mongolly_logger.debug "Stopping config server #{config_server}"
96
+ unless @mongolly_dry_run
97
+ exit_code = nil
98
+ output = ''
99
+ Net::SSH.start(config_server, user.strip) do |ssh|
100
+ channel = ssh.open_channel do |ch|
101
+ ch.request_pty
102
+ ch.exec(stop_command.strip) do |ch, success|
103
+ raise "Unable to stop config server on #{config_server}" unless success
104
+ end
105
+ end
106
+ channel.on_request("exit-status") do |ch,data|
107
+ exit_code = data.read_long
108
+ end
109
+ channel.on_extended_data do |ch,type,data|
110
+ output += data
111
+ end
112
+ ssh.loop
113
+ end
114
+
115
+ if exit_code != 0
116
+ raise RuntimeError.new "Unable to stop config server, #{output}"
25
117
  end
118
+ end
119
+ end
26
120
 
121
+ def start_config_server(user, start_command )
122
+ raise RuntimeError.new "mongo ssh user not specified" unless user.to_s.strip != ''
123
+ raise RuntimeError.new "mongo ssh user not specified" unless start_command.to_s.strip != ''
124
+ @mongolly_logger.debug "Starting config server #{config_server}"
125
+ unless @mongolly_dry_run
126
+ exit_code = nil
127
+ output = ''
128
+ Net::SSH.start(config_server, user.strip) do |ssh|
129
+ channel = ssh.open_channel do |ch|
130
+ ch.request_pty
131
+ ch.exec(start_command.strip) do |ch, success|
132
+ raise "Unable to start config server on #{config_server}" unless success
133
+ end
134
+ end
135
+ channel.on_request("exit-status") do |ch,data|
136
+ exit_code = data.read_long
137
+ end
138
+ channel.on_extended_data do |ch,type,data|
139
+ output += data
140
+ end
141
+ ssh.loop
142
+ end
143
+
144
+ if exit_code != 0
145
+ raise RuntimeError.new "Unable to start config server, #{output}"
146
+ end
147
+ end
148
+ end
149
+
150
+ def config_server
151
+ unless @config_server
152
+ @config_server = self['admin'].command( { getCmdLineOpts: 1 } )["parsed"]["configdb"].split(",").sort.first.split(":").first
153
+ @mongolly_logger.debug "Found config server #{@config_server}"
154
+ end
155
+ return @config_server
156
+ end
157
+
158
+ def with_disabled_balancing
159
+ begin
160
+ disable_balancing
161
+ term_time = Time.now + MAX_DISABLE_BALANCER_WAIT
162
+ while !@mongolly_dry_run && (Time.now < term_time) && balancer_active?
163
+ @mongolly_logger.info "Balancer active, sleeping for 10s (#{(term_time - Time.now).round}s remaining)"
164
+ sleep 10
165
+ end
166
+ if !@mongolly_dry_run && balancer_active?
167
+ raise RuntimeError.new "unable to disable balancer within #{MAX_DISABLE_BALANCER_WAIT}s"
168
+ end
169
+ @mongolly_logger.debug "With shard balancing disabled..."
170
+ yield
27
171
  ensure
28
- unlock! if locked?
29
- enable_profiling
172
+ enable_balancing
30
173
  end
31
174
  end
32
175
 
33
- protected
34
- def snapshot_ebs_target
35
- host_port.join(':')
176
+ def with_database_locked
177
+ begin
178
+ @mongolly_logger.debug "Locking database..."
179
+ disable_profiling
180
+ lock! unless @mongolly_dry_run || locked?
181
+ @mongolly_logger.debug "With database locked..."
182
+ yield
183
+ ensure
184
+ @mongolly_logger.debug "Unlocking database..."
185
+ unlock! if !@mongolly_dry_run && locked?
186
+ enable_profiling
187
+ end
36
188
  end
37
189
 
38
190
  def disable_profiling
191
+ @mongolly_logger.debug("Disabling profiling... ")
192
+
39
193
  @profiled_dbs = {}
40
194
  database_names.each do |db|
41
195
  begin
42
- @profiled_dbs[db] = self[db].profiling_level unless self[db].profiling_level == :off
43
- self[db].profiling_level = :off
196
+ unless self[db].profiling_level == :off
197
+ @mongolly_logger.debug("Disabling profiling for #{db}, level #{self[db].profiling_level}")
198
+ @profiled_dbs[db] = self[db].profiling_level
199
+ self[db].profiling_level = :off unless @mongolly_dry_run
200
+ end
44
201
  rescue Mongo::InvalidNSName
202
+ @mongolly_logger.debug("Skipping database #{db} due to invalid name")
45
203
  end
46
204
  end
47
205
  end
48
206
 
49
207
  def enable_profiling
50
- return false if locked?
51
- return true unless @profiled_dbs
208
+ if locked?
209
+ @mongolly_logger.debug("Database locked, can't turn on profiling")
210
+ return false
211
+ end
212
+ unless @profiled_dbs
213
+ @monglly_logger.debug("No dbs in @profiled_dbs")
214
+ return true
215
+ end
216
+
52
217
  @profiled_dbs.each do |db,level|
53
- self[db].profiling_level = level rescue Mongo::InvalidNSName
218
+ begin
219
+ @mongolly_logger.debug("Enabling profiling for #{db}, level #{level}")
220
+ self[db].profiling_level = level unless @mongolly_dry_run
221
+ rescue Mongo::InvalidNSName
222
+ @mongolly_logger.debug("Skipping database #{db} due to invalid name")
223
+ end
54
224
  end
55
225
  return true
56
226
  end
57
227
 
228
+ def shards
229
+ shards = {}
230
+ self['config']['shards'].find().each do |shard|
231
+ shards[shard['_id']] = shard['host'].split("/")[1].split(",")
232
+ end
233
+ shards
234
+ end
235
+
236
+ def replica_set_connection(hosts, options)
237
+ db = Mongo::MongoReplicaSetClient.new(hosts)
238
+ db['admin'].authenticate(options[:db_username], options[:db_password], true)
239
+ return db
240
+ end
241
+
58
242
  end
@@ -4,7 +4,9 @@ class Mongo::MongoReplicaSetClient
4
4
 
5
5
  def most_current_secondary
6
6
  replica = self['admin'].command( replSetGetStatus: 1 )
7
- replica['members'].select { |m| m['state'] == 2 }.sort_by { |m| m['optime'] }.reverse.first['name']
7
+ current = replica['members'].select { |m| m['state'] == 2 }.sort_by { |m| m['optime'] }.reverse.first['name']
8
+ @mongolly_logger.debug("Found most current secondary #{current}")
9
+ current
8
10
  end
9
11
 
10
12
  protected
@@ -2,34 +2,56 @@ module Mongolly
2
2
  class Shepherd
3
3
 
4
4
  def initialize(options={})
5
+ @options = options
5
6
  @access_key_id = options[:access_key_id]
6
7
  @secret_access_key = options[:secret_access_key]
7
- @region = options[:aws_region] || 'us-east-1'
8
+ @region = options[:region] || 'us-east-1'
8
9
  @database = options[:database]
9
10
  @db_username = options[:db_username]
10
11
  @db_password = options[:db_password]
12
+ @dry_run = options[:dry_run]
13
+ @logger = options[:logger] || Logger.new(STDOUT)
14
+ @logger.level = case options[:log_level].strip
15
+ when 'fatal'; then Logger::FATAL
16
+ when 'error'; then Logger::ERROR
17
+ when 'warn' ; then Logger::WARN
18
+ when 'debug'; then Logger::DEBUG
19
+ else Logger::INFO
20
+ end
11
21
  end
12
22
 
13
23
  def backup
14
- connection.snapshot_ebs(access_key_id: @access_key_id, secret_access_key: @secret_access_key, region: @region)
24
+ @logger.info "Starting backup..."
25
+ connection.snapshot_ebs({logger: @logger}.merge(@options))
26
+ @logger.info "Backup complete."
15
27
  end
16
28
 
17
29
  def cleanup(age)
18
- raise ArgumentError.new("Must provide a Time object cleanup") unless age.class <= Time
30
+ @logger.info "Starting cleanup..."
31
+ raise ArgumentError.new("Must provide a Time object to cleanup") unless age.class <= Time
19
32
 
20
- ec2 = AWS::EC2.new(access_key_id: @access_key_id, secret_access_key: @secret_access_key, region: @region)
33
+ ec2 = AWS::EC2.new(access_key_id: @access_key_id,
34
+ secret_access_key: @secret_access_key,
35
+ region: @region)
21
36
 
37
+ @logger.debug "deleting snapshots older than #{age}}"
22
38
  ec2.snapshots.with_owner(:self).each do |snapshot|
23
39
  unless snapshot.tags[:created_at].nil? || snapshot.tags[:backup_key].nil?
24
- snapshot.delete if Time.parse(snapshot.tags[:created_at]) < age
40
+ if Time.parse(snapshot.tags[:created_at]) < age
41
+ @logger.debug "deleting snapshot #{snapshot.id} tagged #{snapshot.tags[:backup_key]} created at #{snapshot.tags[:created_at]}, earlier than #{age}"
42
+ snapshot.delete unless @dry_run
43
+ end
25
44
  end
26
45
  end
46
+ @logger.info "Cleanup complete."
27
47
  end
28
48
 
29
49
  def connection
30
50
  db = if @database.is_a? Array
51
+ @logger.debug "connecting to a replica set #{@database}"
31
52
  Mongo::MongoReplicaSetClient.new(@database)
32
53
  else
54
+ @logger.debug "connecting to a single instance #{@database}"
33
55
  Mongo::MongoClient.new(*@database.split(':'))
34
56
  end
35
57
  db['admin'].authenticate(@db_username, @db_password, true)
@@ -1,3 +1,3 @@
1
1
  module Mongolly
2
- VERSION = "0.1.1"
2
+ VERSION = "0.2.0"
3
3
  end
@@ -22,5 +22,7 @@ Gem::Specification.new do |gem|
22
22
  gem.add_dependency("bson_ext", ["~> 1.8.3"])
23
23
  gem.add_dependency("aws-sdk", ["~> 1.9.5"])
24
24
  gem.add_dependency("ipaddress")
25
+ gem.add_dependency("net-ssh", ["~> 2.0"])
26
+ gem.add_dependency("retries")
25
27
 
26
28
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mongolly
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  prerelease:
6
6
  platform: ruby
7
7
  authors:
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2013-05-18 00:00:00.000000000 Z
12
+ date: 2013-05-28 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: thor
@@ -91,6 +91,38 @@ dependencies:
91
91
  - - ! '>='
92
92
  - !ruby/object:Gem::Version
93
93
  version: '0'
94
+ - !ruby/object:Gem::Dependency
95
+ name: net-ssh
96
+ requirement: !ruby/object:Gem::Requirement
97
+ none: false
98
+ requirements:
99
+ - - ~>
100
+ - !ruby/object:Gem::Version
101
+ version: '2.0'
102
+ type: :runtime
103
+ prerelease: false
104
+ version_requirements: !ruby/object:Gem::Requirement
105
+ none: false
106
+ requirements:
107
+ - - ~>
108
+ - !ruby/object:Gem::Version
109
+ version: '2.0'
110
+ - !ruby/object:Gem::Dependency
111
+ name: retries
112
+ requirement: !ruby/object:Gem::Requirement
113
+ none: false
114
+ requirements:
115
+ - - ! '>='
116
+ - !ruby/object:Gem::Version
117
+ version: '0'
118
+ type: :runtime
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ none: false
122
+ requirements:
123
+ - - ! '>='
124
+ - !ruby/object:Gem::Version
125
+ version: '0'
94
126
  description: Easy backups for EBS-based MongoDB Databases
95
127
  email:
96
128
  - m@saffitz.com