astrails-safe 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (3) hide show
  1. data/bin/astrails-safe +362 -0
  2. data/safe.gemspec +21 -0
  3. metadata +73 -0
data/bin/astrails-safe ADDED
@@ -0,0 +1,362 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'tmpdir'
4
+ require 'tempfile'
5
+ require 'rubygems'
6
+ require 'fileutils'
7
+ require "aws/s3"
8
+ require 'yaml'
9
+ #require 'ruby-debug'
10
+
11
+ def die(msg)
12
+ puts "ERROR: #{msg}"
13
+ exit 1
14
+ end
15
+
16
+ def usage
17
+ puts <<-END
18
+ Usage: backup.rb [OPTIONS] CONFIG_FILE
19
+ Options:
20
+ -h, --help This help screen
21
+ -v, --verbose be verbose, duh!
22
+ -n, --dry-run just pretend, don't do anything.
23
+ -L, --local skip S3
24
+
25
+ Note: config file will be created from template if missing
26
+ END
27
+ exit 1
28
+ end
29
+
30
+ def now
31
+ @now ||= Time.now
32
+ end
33
+
34
+ def timestamp
35
+ @timestamp ||= now.strftime("%y%m%d-%H%M")
36
+ end
37
+
38
+ # We allow to put configuration keys on any level
39
+ class ConfigHash
40
+ def initialize(root, *path)
41
+ path = [*path] unless path.empty?
42
+ # sequence of all the config levels on path given
43
+ # if at some point there is no value, @config will contain nil for this element and all the following
44
+ @configs = path.inject([root]) { |res, x| res << (res.last && res.last[x]) }.reverse
45
+ # if the first element (last before revers) is nil -> the required path does not exist
46
+ @configs = [{}] if @configs.first.nil?
47
+ end
48
+
49
+ def [](key)
50
+ conf = @configs.find {|x| x.is_a?(Hash) && x[key]}
51
+ conf && conf[key]
52
+ end
53
+
54
+ def keys
55
+ @configs.first.keys
56
+ end
57
+
58
+ end
59
+
60
+ def create_config_file(path)
61
+ File.open(path, "w") do |conf|
62
+ conf.write <<-CONF
63
+ # you can use comments
64
+
65
+ # Note: keys defined on a deeper level will override upper level keys
66
+ # See :path for example
67
+
68
+ # global path
69
+ :path: /backup
70
+
71
+ ## uncomment to enable uploads to Amazon S3
72
+ ## Amazon S3 auth (optional)
73
+ # :s3_key: YOUR_S3_KEY
74
+ # :s3_secret: YOUR_S3_SECRET
75
+ # :s3_bucket: S3_BUCKET
76
+
77
+ ## uncomment to enable backup rotation. keep only given number of latest
78
+ ## backups. remove the rest
79
+ #:keep_local: 50
80
+ #:keep_s3: 200
81
+
82
+ :mysql:
83
+ # local path override for mysql
84
+ :path: /backup/mysql
85
+ :socket: /var/run/mysqld/mysqld.sock
86
+ :mysqldump_options: -ceKq --single-transaction --create-options
87
+ :username: MYSQL_USER
88
+ :password: MYSQL_PASS
89
+
90
+ :databases:
91
+
92
+ :production_db:
93
+ :skip_tables:
94
+ - logged_exceptions
95
+ - request_logs
96
+ :gpg_password: OPTIONAL_PASSWORD_TO_ENCRYPT
97
+
98
+ :tar:
99
+ :path: /backup/archives
100
+ :archives:
101
+ :git_repositories:
102
+ :files:
103
+ - /home/git/repositories
104
+ - /home/mirrors/foo/repositories
105
+ :exclude:
106
+ - /home/mirrors/foo/repositories/junk
107
+ :etc:
108
+ :files:
109
+ - /etc
110
+
111
+ CONF
112
+ end
113
+ end
114
+
115
+ def process_options
116
+ usage if ARGV.delete("-h") || ARGV.delete("--help")
117
+ $VERBOSE = ARGV.delete("-v") || ARGV.delete("--verbose")
118
+ $DRY_RUN = ARGV.delete("-n") || ARGV.delete("--dry-run")
119
+ $LOCAL = ARGV.delete("-L") || ARGV.delete("--local")
120
+ usage unless ARGV.first
121
+ $CONFIG_FILE_NAME = File.expand_path(ARGV.first)
122
+ end
123
+
124
+ $KEEP_FILES = []
125
+
126
+ def create_temp_file(name)
127
+ file = Tempfile.new("mysqldump", $TMPDIR)
128
+
129
+ yield file
130
+
131
+ file.close
132
+ $KEEP_FILES << file # so that it will not get gcollected and removed from filesystem until the end
133
+ file.path
134
+ end
135
+
136
+ def create_mysql_password_file(conf)
137
+ create_temp_file("mysqldump") do |file|
138
+ username = conf[:username]
139
+ password = conf[:password]
140
+ socket = conf[:socket]
141
+ host = conf[:host]
142
+ port = conf[:port]
143
+
144
+ file.puts "[mysqldump]"
145
+ file.puts "user = #{username}" if username
146
+ file.puts "password = #{password}" if password
147
+ file.puts "socket = #{socket}" if socket
148
+ file.puts "host = #{host}" if host
149
+ file.puts "port = #{port}" if port
150
+ end
151
+ end
152
+
153
+ def create_gpg_password_file(pass)
154
+ create_temp_file("gpg-pass") { |file| file.write(pass) }
155
+ end
156
+
157
+
158
+ def mysql_skip_tables(conf, db)
159
+ if skip_tables = conf[:skip_tables]
160
+ skip_tables.map{|t| "--ignore-table=#{db}.#{t} "}.join
161
+ end
162
+ end
163
+
164
+ def mysqldump(conf, db)
165
+ cmd = "mysqldump --defaults-extra-file=#{create_mysql_password_file(conf)} "
166
+ cmd << conf[:mysqldump_options] << " " if conf[:mysqldump_options]
167
+ cmd << mysql_skip_tables(conf, db)
168
+ cmd << " #{db} "
169
+
170
+ path = conf[:path]
171
+ die "missing :path in configuration" unless path
172
+ backup_filename = File.join(path, "mysql-#{db}.#{timestamp}.sql")
173
+
174
+ [cmd, backup_filename]
175
+ end
176
+
177
+ def tar_extra_options(conf, cmd)
178
+ cmd << conf[:tar_options] << " " if conf[:tar_options]
179
+ cmd
180
+ end
181
+ def tar_exclude_files(conf, cmd)
182
+ if exclude = conf[:exclude]
183
+ cmd << exclude.map{|x| "--exclude=#{x} "}.join
184
+ end
185
+ cmd
186
+ end
187
+
188
+ def tar_files(conf, cmd)
189
+ die "missing files for tar" unless conf[:files]
190
+ cmd << conf[:files] * " "
191
+ end
192
+
193
+ def tar_archive(conf, arch)
194
+ cmd = "tar -cf - "
195
+ cmd = tar_extra_options(conf, cmd)
196
+ cmd = tar_exclude_files(conf, cmd)
197
+ cmd = tar_files(conf, cmd)
198
+
199
+ path = conf[:path]
200
+ die "missing :path in configuration" unless path
201
+ backup_filename = File.join(path, "archive-#{arch}.#{timestamp}.tar")
202
+
203
+ [cmd, backup_filename]
204
+ end
205
+
206
+ # GPG uses compression too :)
207
+ def compress(conf, cmd, backup_filename)
208
+
209
+ gpg_pass = conf[:gpg_password]
210
+ gpg_key = conf[:gpg_public_key]
211
+
212
+ if gpg_key
213
+ die "can't sue both password and pubkey" if gpg_pass
214
+ cmd << "|gpg -e -r #{gpg_key}"
215
+ backup_filename << ".gpg"
216
+ elsif gpg_pass
217
+ unless $DRY_RUN
218
+ cmd << "|gpg -c --passphrase-file #{create_gpg_password_file(gpg_pass)}"
219
+ else
220
+ cmd << "|gpg -c --passphrase-file TEMP_GENERATED_FILENAME"
221
+ end
222
+ backup_filename << ".gpg"
223
+ else
224
+ cmd << "|gzip"
225
+ backup_filename << ".gz"
226
+ end
227
+ [cmd, backup_filename]
228
+ end
229
+
230
+ def timestamped_path(prefix, filename, date)
231
+ File.join(prefix, "%04d" % date.year, "%02d" % date.month, "%02d" % date.day, File.basename(filename))
232
+ end
233
+
234
+ def cleanup_files(files, limit, &block)
235
+ return unless files.size > limit
236
+
237
+ to_remove = files[0..(files.size - limit - 1)]
238
+ to_remove.each(&block)
239
+ end
240
+
241
+ def cleanup_local(conf, backup_filename)
242
+ return unless keep_local = conf[:keep_local]
243
+
244
+ dir = File.dirname(backup_filename)
245
+ base = File.basename(backup_filename).split(".").first
246
+
247
+ files = Dir[File.join(dir, "#{base}*")].
248
+ select{|f| File.file?(f)}.
249
+ sort
250
+
251
+ cleanup_files(files, keep_local) do |f|
252
+ puts "removing local file #{f}" if $DRY_RUN || $VERBOSE
253
+ File.unlink(f) unless $DRY_RUN
254
+ end
255
+ end
256
+
257
+ class String
258
+ def starts_with?(str)
259
+ self[0..(str.length - 1)] == str
260
+ end
261
+ end
262
+
263
+ def cleanup_s3(conf, bucket, prefix, backup_filename)
264
+
265
+ return unless keep_s3 = conf[:keep_s3]
266
+
267
+ base = File.basename(backup_filename).split(".").first
268
+
269
+ puts "listing files in #{bucket}:#{prefix}"
270
+ files = AWS::S3::Bucket.objects(bucket, :prefix => prefix, :max_keys => keep_s3 * 2)
271
+ puts files.collect(&:key)
272
+ files = files.
273
+ collect(&:key).
274
+ select{|o| File.basename(o).starts_with?(base)}.
275
+ sort
276
+
277
+ cleanup_files(files, keep_s3) do |f|
278
+ puts "removing s3 file #{bucket}:#{f}" if $DRY_RUN || $VERBOSE
279
+ AWS::S3::Bucket.find(bucket)[f].delete unless $DRY_RUN
280
+ end
281
+ end
282
+
283
+
284
+ def s3_upload(conf, backup_filename, default_path)
285
+ s3_bucket = conf[:s3_bucket]
286
+ s3_key = conf[:s3_key]
287
+ s3_secret = conf[:s3_secret]
288
+ s3_prefix = conf[:s3_path] || default_path
289
+ s3_path = timestamped_path(s3_prefix, backup_filename, now)
290
+
291
+ return unless s3_bucket && s3_key && s3_secret
292
+
293
+ puts "Uploading file #{backup_filename} to #{s3_bucket}/#{s3_path}" if $VERBOSE || $DRY_RUN
294
+
295
+ AWS::S3::Base.establish_connection!(:access_key_id => s3_key, :secret_access_key => s3_secret, :use_ssl => true)
296
+
297
+ unless $DRY_RUN || $LOCAL
298
+ AWS::S3::Bucket.create(s3_bucket)
299
+ AWS::S3::S3Object.store(s3_path, open(backup_filename), s3_bucket)
300
+ end
301
+ puts "...done" if $VERBOSE
302
+
303
+ cleanup_s3(conf, s3_bucket, s3_prefix, backup_filename)
304
+ end
305
+
306
+ def stream_backup(conf, cmd, backup_name, default_path)
307
+ # prepare COMPRESS
308
+ cmd, backup_filename = compress(conf, cmd, backup_name)
309
+
310
+ dir = File.dirname(backup_filename)
311
+ FileUtils.mkdir_p(dir) unless File.directory?(dir) || $DRY_RUN
312
+
313
+ # EXECUTE
314
+ puts "Backup command: #{cmd} > #{backup_filename}" if $DRY_RUN || $VERBOSE
315
+ system "#{cmd} > #{backup_filename}" unless $DRY_RUN
316
+
317
+ # UPLOAD
318
+ s3_upload(conf, backup_filename, default_path)
319
+
320
+ # CLEANUP
321
+ cleanup_local(conf, backup_filename)
322
+ end
323
+
324
+ def backup_mysql
325
+ ConfigHash.new($CONFIG, :mysql, :databases).keys.each do |db|
326
+ puts "Backup database #{db}" if $VERBOSE
327
+ conf = ConfigHash.new($CONFIG, :mysql, :databases, db)
328
+ cmd, backup_filename = mysqldump(conf, db)
329
+ stream_backup(conf, cmd, backup_filename, "mysql/#{db}/")
330
+ end
331
+ end
332
+
333
+ def backup_archives
334
+ ConfigHash.new($CONFIG, :tar, :archives).keys.each do |arch|
335
+ puts "Backup archive #{arch}" if $VERBOSE
336
+ conf = ConfigHash.new($CONFIG, :tar, :archives, arch)
337
+
338
+ cmd, backup_filename = tar_archive(conf, arch)
339
+ stream_backup(conf, cmd, backup_filename, "archives/#{arch}/")
340
+ end
341
+ end
342
+
343
+ def main
344
+ process_options
345
+
346
+ unless File.exists?($CONFIG_FILE_NAME)
347
+ die "Missing configuration file. NOT CREATED! Rerun w/o the -n argument to create a template configuration file." if $DRY_RUN
348
+ create_config_file($CONFIG_FILE_NAME)
349
+ die "Created default #{$CONFIG_FILE_NAME}. Please edit and run again."
350
+ end
351
+
352
+ $CONFIG = YAML.load(File.read($CONFIG_FILE_NAME))
353
+
354
+ # create temp directory
355
+ $TMPDIR = Dir.mktmpdir
356
+
357
+ backup_mysql
358
+
359
+ backup_archives
360
+ end
361
+
362
+ main
data/safe.gemspec ADDED
@@ -0,0 +1,21 @@
1
+ Gem::Specification.new do |s|
2
+ s.name = "safe"
3
+ s.version = "0.0.2"
4
+ s.date = "2009-03-03"
5
+ s.summary = "Astrails Safe"
6
+ s.email = "we@astrails.com"
7
+ s.homepage = "http://github.com/astrails/safe"
8
+ s.description = "Simple tool to backup MySQL databases and filesystem locally or to Amazon S3 (with optional encryption)"
9
+ s.has_rdoc = false
10
+ s.authors = ["Astrails Ltd."]
11
+ s.files = files = %w(
12
+ bin/astrails-safe
13
+ safe.gemspec
14
+ )
15
+ s.executables = files.grep(/^bin/).map {|x| x.gsub(/^bin\//, "")}
16
+
17
+ s.test_files = []
18
+ s.add_dependency("aws-s3")
19
+ s.add_dependency("yaml")
20
+ end
21
+
metadata ADDED
@@ -0,0 +1,73 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: astrails-safe
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.2
5
+ platform: ruby
6
+ authors:
7
+ - Astrails Ltd.
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+
12
+ date: 2009-03-03 00:00:00 -08:00
13
+ default_executable:
14
+ dependencies:
15
+ - !ruby/object:Gem::Dependency
16
+ name: aws-s3
17
+ type: :runtime
18
+ version_requirement:
19
+ version_requirements: !ruby/object:Gem::Requirement
20
+ requirements:
21
+ - - ">="
22
+ - !ruby/object:Gem::Version
23
+ version: "0"
24
+ version:
25
+ - !ruby/object:Gem::Dependency
26
+ name: yaml
27
+ type: :runtime
28
+ version_requirement:
29
+ version_requirements: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: "0"
34
+ version:
35
+ description: Simple tool to backup MySQL databases and filesystem locally or to Amazon S3 (with optional encryption)
36
+ email: we@astrails.com
37
+ executables:
38
+ - astrails-safe
39
+ extensions: []
40
+
41
+ extra_rdoc_files: []
42
+
43
+ files:
44
+ - bin/astrails-safe
45
+ - safe.gemspec
46
+ has_rdoc: false
47
+ homepage: http://github.com/astrails/safe
48
+ post_install_message:
49
+ rdoc_options: []
50
+
51
+ require_paths:
52
+ - lib
53
+ required_ruby_version: !ruby/object:Gem::Requirement
54
+ requirements:
55
+ - - ">="
56
+ - !ruby/object:Gem::Version
57
+ version: "0"
58
+ version:
59
+ required_rubygems_version: !ruby/object:Gem::Requirement
60
+ requirements:
61
+ - - ">="
62
+ - !ruby/object:Gem::Version
63
+ version: "0"
64
+ version:
65
+ requirements: []
66
+
67
+ rubyforge_project:
68
+ rubygems_version: 1.2.0
69
+ signing_key:
70
+ specification_version: 2
71
+ summary: Astrails Safe
72
+ test_files: []
73
+