astrails-safe 0.0.2

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