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.
- data/bin/astrails-safe +362 -0
- data/safe.gemspec +21 -0
- 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
|
+
|