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.
- data/bin/mongolly +9 -5
- data/lib/mongolly/extensions/aws/ec2/instance_collection.rb +1 -1
- data/lib/mongolly/extensions/mongo/mongo_client.rb +206 -22
- data/lib/mongolly/extensions/mongo/mongo_replica_set_client.rb +3 -1
- data/lib/mongolly/shepherd.rb +27 -5
- data/lib/mongolly/version.rb +1 -1
- data/mongolly.gemspec +2 -0
- metadata +34 -2
data/bin/mongolly
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
16
|
+
options[:backup_key] ||= (0...8).map{65.+(rand(25)).chr}.join
|
|
8
17
|
|
|
9
|
-
|
|
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
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
29
|
-
enable_profiling
|
|
172
|
+
enable_balancing
|
|
30
173
|
end
|
|
31
174
|
end
|
|
32
175
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
data/lib/mongolly/shepherd.rb
CHANGED
|
@@ -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[:
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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)
|
data/lib/mongolly/version.rb
CHANGED
data/mongolly.gemspec
CHANGED
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.
|
|
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-
|
|
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
|