mongolly 0.1.1 → 0.2.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.
@@ -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