bostonlogic-safe 0.3.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/LICENSE +20 -0
- data/README.markdown +227 -0
- data/Rakefile +54 -0
- data/VERSION.yml +4 -0
- data/bin/astrails-safe +53 -0
- data/examples/example_helper.rb +19 -0
- data/examples/integration/archive_integration_example.rb +86 -0
- data/examples/integration/cleanup_example.rb +62 -0
- data/examples/unit/archive_example.rb +67 -0
- data/examples/unit/config_example.rb +184 -0
- data/examples/unit/gpg_example.rb +138 -0
- data/examples/unit/gzip_example.rb +64 -0
- data/examples/unit/local_example.rb +110 -0
- data/examples/unit/mysqldump_example.rb +83 -0
- data/examples/unit/pgdump_example.rb +45 -0
- data/examples/unit/rcloud_example.rb +110 -0
- data/examples/unit/s3_example.rb +112 -0
- data/examples/unit/svndump_example.rb +39 -0
- data/lib/astrails/safe.rb +71 -0
- data/lib/astrails/safe/archive.rb +24 -0
- data/lib/astrails/safe/backup.rb +20 -0
- data/lib/astrails/safe/config/builder.rb +62 -0
- data/lib/astrails/safe/config/node.rb +66 -0
- data/lib/astrails/safe/gpg.rb +45 -0
- data/lib/astrails/safe/gzip.rb +25 -0
- data/lib/astrails/safe/local.rb +48 -0
- data/lib/astrails/safe/mysqldump.rb +31 -0
- data/lib/astrails/safe/notification.rb +66 -0
- data/lib/astrails/safe/pgdump.rb +36 -0
- data/lib/astrails/safe/pipe.rb +13 -0
- data/lib/astrails/safe/rcloud.rb +73 -0
- data/lib/astrails/safe/s3.rb +68 -0
- data/lib/astrails/safe/sftp.rb +79 -0
- data/lib/astrails/safe/sink.rb +33 -0
- data/lib/astrails/safe/source.rb +46 -0
- data/lib/astrails/safe/stream.rb +19 -0
- data/lib/astrails/safe/svndump.rb +13 -0
- data/lib/astrails/safe/tmp_file.rb +48 -0
- data/lib/extensions/mktmpdir.rb +45 -0
- data/templates/script.rb +155 -0
- metadata +135 -0
@@ -0,0 +1,39 @@
|
|
1
|
+
require File.expand_path(File.dirname(__FILE__) + '/../example_helper')
|
2
|
+
|
3
|
+
describe Astrails::Safe::Svndump do
|
4
|
+
def def_config
|
5
|
+
{
|
6
|
+
:options => "OPTS",
|
7
|
+
:repo_path => "bar/baz"
|
8
|
+
}
|
9
|
+
end
|
10
|
+
|
11
|
+
def svndump(id = :foo, config = def_config)
|
12
|
+
Astrails::Safe::Svndump.new(id, Astrails::Safe::Config::Node.new(nil, config))
|
13
|
+
end
|
14
|
+
|
15
|
+
before(:each) do
|
16
|
+
stub(Time).now.stub!.strftime {"NOW"}
|
17
|
+
end
|
18
|
+
|
19
|
+
after(:each) { Astrails::Safe::TmpFile.cleanup }
|
20
|
+
|
21
|
+
describe :backup do
|
22
|
+
before(:each) do
|
23
|
+
@svn = svndump
|
24
|
+
end
|
25
|
+
|
26
|
+
{
|
27
|
+
:id => "foo",
|
28
|
+
:kind => "svndump",
|
29
|
+
:extension => ".svn",
|
30
|
+
:filename => "svndump-foo.NOW",
|
31
|
+
:command => "svnadmin dump OPTS bar/baz",
|
32
|
+
}.each do |k, v|
|
33
|
+
it "should set #{k} to #{v}" do
|
34
|
+
@svn.backup.send(k).should == v
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,71 @@
|
|
1
|
+
require "aws/s3"
|
2
|
+
require 'cloudfiles'
|
3
|
+
require 'net/sftp'
|
4
|
+
require 'fileutils'
|
5
|
+
require 'benchmark'
|
6
|
+
require 'net/smtp'
|
7
|
+
|
8
|
+
require 'yaml'
|
9
|
+
|
10
|
+
require 'tempfile'
|
11
|
+
require File.dirname(__FILE__) + '/../extensions/mktmpdir'
|
12
|
+
|
13
|
+
require File.dirname(__FILE__) + '/safe/tmp_file'
|
14
|
+
|
15
|
+
require File.dirname(__FILE__) + '/safe/config/node'
|
16
|
+
require File.dirname(__FILE__) + '/safe/config/builder'
|
17
|
+
|
18
|
+
require File.dirname(__FILE__) + '/safe/stream'
|
19
|
+
|
20
|
+
require File.dirname(__FILE__) + '/safe/backup'
|
21
|
+
|
22
|
+
require File.dirname(__FILE__) + '/safe/backup'
|
23
|
+
|
24
|
+
require File.dirname(__FILE__) + '/safe/source'
|
25
|
+
require File.dirname(__FILE__) + '/safe/mysqldump'
|
26
|
+
require File.dirname(__FILE__) + '/safe/pgdump'
|
27
|
+
require File.dirname(__FILE__) + '/safe/archive'
|
28
|
+
require File.dirname(__FILE__) + '/safe/svndump'
|
29
|
+
|
30
|
+
require File.dirname(__FILE__) + '/safe/pipe'
|
31
|
+
require File.dirname(__FILE__) + '/safe/gpg'
|
32
|
+
require File.dirname(__FILE__) + '/safe/gzip'
|
33
|
+
|
34
|
+
require File.dirname(__FILE__) + '/safe/sink'
|
35
|
+
require File.dirname(__FILE__) + '/safe/local'
|
36
|
+
require File.dirname(__FILE__) + '/safe/s3'
|
37
|
+
require File.dirname(__FILE__) + '/safe/sftp'
|
38
|
+
|
39
|
+
require File.dirname(__FILE__) + '/safe/rcloud'
|
40
|
+
require File.dirname(__FILE__) + '/safe/notification'
|
41
|
+
|
42
|
+
module Astrails
|
43
|
+
module Safe
|
44
|
+
ROOT = File.join(File.dirname(__FILE__), "..", "..")
|
45
|
+
|
46
|
+
def safe(&block)
|
47
|
+
config = Config::Node.new(&block)
|
48
|
+
#config.dump
|
49
|
+
|
50
|
+
begin
|
51
|
+
[[Mysqldump, [:mysqldump, :databases]],
|
52
|
+
[Pgdump, [:pgdump, :databases]],
|
53
|
+
[Archive, [:tar, :archives]],
|
54
|
+
[Svndump, [:svndump, :repos]]
|
55
|
+
].each do |klass, path|
|
56
|
+
if collection = config[*path]
|
57
|
+
collection.each do |name, config|
|
58
|
+
klass.new(name, config).backup.run(config, :gpg, :gzip, :local, :s3, :sftp, :rcloud)
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
Astrails::Safe::TmpFile.cleanup
|
64
|
+
rescue Exception => e
|
65
|
+
puts e.to_yaml if $DRY_RUN || $_VERBOSE
|
66
|
+
Notification.new(config, e).send_failure
|
67
|
+
end
|
68
|
+
end
|
69
|
+
module_function :safe
|
70
|
+
end
|
71
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
module Astrails
|
2
|
+
module Safe
|
3
|
+
class Archive < Source
|
4
|
+
|
5
|
+
def command
|
6
|
+
"tar -cf - #{@config[:options]} #{tar_exclude_files} #{tar_files}"
|
7
|
+
end
|
8
|
+
|
9
|
+
def extension; '.tar'; end
|
10
|
+
|
11
|
+
protected
|
12
|
+
|
13
|
+
def tar_exclude_files
|
14
|
+
[*@config[:exclude]].compact.map{|x| "--exclude=#{x}"} * " "
|
15
|
+
end
|
16
|
+
|
17
|
+
def tar_files
|
18
|
+
raise RuntimeError, "missing files for tar" unless @config[:files]
|
19
|
+
[*@config[:files]].map {|s| s.strip} * " "
|
20
|
+
end
|
21
|
+
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
module Astrails
|
2
|
+
module Safe
|
3
|
+
class Backup
|
4
|
+
attr_accessor :id, :kind, :filename, :extension, :command, :compressed, :timestamp, :path
|
5
|
+
def initialize(opts = {})
|
6
|
+
opts.each do |k, v|
|
7
|
+
self.send("#{k}=", v)
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
def run(config, *mods)
|
12
|
+
mods.each do |mod|
|
13
|
+
mod = mod.to_s
|
14
|
+
mod[0] = mod[0..0].upcase
|
15
|
+
Astrails::Safe.const_get(mod).new(config, self).process
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
module Astrails
|
2
|
+
module Safe
|
3
|
+
module Config
|
4
|
+
class Builder
|
5
|
+
COLLECTIONS = %w/database archive repo/
|
6
|
+
ITEMS = %w/s3 key secret bucket path gpg password keep local mysqldump pgdump options
|
7
|
+
user host port socket skip_tables tar files exclude filename svndump repo_path sftp
|
8
|
+
rcloud username api_key container
|
9
|
+
notification subject host domain username password authentication port from recipients/
|
10
|
+
NAMES = COLLECTIONS + ITEMS
|
11
|
+
def initialize(node)
|
12
|
+
@node = node
|
13
|
+
end
|
14
|
+
|
15
|
+
# supported args:
|
16
|
+
# args = [value]
|
17
|
+
# args = [id, data]
|
18
|
+
# args = [data]
|
19
|
+
# id/value - simple values, data - hash
|
20
|
+
def method_missing(sym, *args, &block)
|
21
|
+
return super unless NAMES.include?(sym.to_s)
|
22
|
+
|
23
|
+
# do we have id or value?
|
24
|
+
unless args.first.is_a?(Hash)
|
25
|
+
id_or_value = args.shift # nil for args == []
|
26
|
+
end
|
27
|
+
|
28
|
+
id_or_value = id_or_value.map {|v| v.to_s} if id_or_value.is_a?(Array)
|
29
|
+
|
30
|
+
# do we have data hash?
|
31
|
+
if data = args.shift
|
32
|
+
raise "#{sym}: hash expected: #{data.inspect}" unless data.is_a?(Hash)
|
33
|
+
end
|
34
|
+
|
35
|
+
#puts "#{sym}: args=#{args.inspect}, id_or_value=#{id_or_value}, data=#{data.inspect}, block=#{block.inspect}"
|
36
|
+
|
37
|
+
raise "#{sym}: unexpected: #{args.inspect}" unless args.empty?
|
38
|
+
raise "#{sym}: missing arguments" unless id_or_value || data || block
|
39
|
+
|
40
|
+
if COLLECTIONS.include?(sym.to_s) && id_or_value
|
41
|
+
data ||= {}
|
42
|
+
end
|
43
|
+
|
44
|
+
if !data && !block
|
45
|
+
# simple value assignment
|
46
|
+
@node[sym] = id_or_value
|
47
|
+
|
48
|
+
elsif id_or_value
|
49
|
+
# collection element with id => create collection node and a subnode in it
|
50
|
+
key = sym.to_s + "s"
|
51
|
+
collection = @node[key] || @node.set(key, {})
|
52
|
+
collection.set(id_or_value, data || {}, &block)
|
53
|
+
|
54
|
+
else
|
55
|
+
# simple subnode
|
56
|
+
@node.set(sym, data || {}, &block)
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
require 'astrails/safe/config/builder'
|
2
|
+
module Astrails
|
3
|
+
module Safe
|
4
|
+
module Config
|
5
|
+
class Node
|
6
|
+
attr_reader :parent
|
7
|
+
def initialize(parent = nil, data = {}, &block)
|
8
|
+
@parent, @data = parent, {}
|
9
|
+
data.each { |k, v| self[k] = v }
|
10
|
+
Builder.new(self).instance_eval(&block) if block
|
11
|
+
end
|
12
|
+
|
13
|
+
# looks for the path from this node DOWN. will not delegate to parent
|
14
|
+
def get(*path)
|
15
|
+
key = path.shift
|
16
|
+
value = @data[key.to_s]
|
17
|
+
return value if value && path.empty?
|
18
|
+
|
19
|
+
value && value.get(*path)
|
20
|
+
end
|
21
|
+
|
22
|
+
# recursive find
|
23
|
+
# starts at the node and continues to the parent
|
24
|
+
def find(*path)
|
25
|
+
get(*path) || @parent && @parent.find(*path)
|
26
|
+
end
|
27
|
+
alias :[] :find
|
28
|
+
|
29
|
+
def set(key, value, &block)
|
30
|
+
@data[key.to_s] =
|
31
|
+
if value.is_a?(Hash)
|
32
|
+
Node.new(self, value, &block)
|
33
|
+
else
|
34
|
+
raise(ArgumentError, "#{key}: no block supported for simple values") if block
|
35
|
+
value
|
36
|
+
end
|
37
|
+
end
|
38
|
+
alias :[]= :set
|
39
|
+
|
40
|
+
def each(&block)
|
41
|
+
@data.each(&block)
|
42
|
+
end
|
43
|
+
include Enumerable
|
44
|
+
|
45
|
+
def to_hash
|
46
|
+
@data.keys.inject({}) do |res, key|
|
47
|
+
value = @data[key]
|
48
|
+
res[key] = value.is_a?(Node) ? value.to_hash : value
|
49
|
+
res
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
def dump(indent = "")
|
54
|
+
@data.each do |key, value|
|
55
|
+
if value.is_a?(Node)
|
56
|
+
puts "#{indent}#{key}:"
|
57
|
+
value.dump(indent + " ")
|
58
|
+
else
|
59
|
+
puts "#{indent}#{key}: #{value.inspect}"
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
module Astrails
|
2
|
+
module Safe
|
3
|
+
class Gpg < Pipe
|
4
|
+
|
5
|
+
protected
|
6
|
+
|
7
|
+
def post_process
|
8
|
+
@backup.compressed = true
|
9
|
+
end
|
10
|
+
|
11
|
+
def pipe
|
12
|
+
if key
|
13
|
+
"|gpg #{@config[:gpg, :options]} -e -r #{key}"
|
14
|
+
elsif password
|
15
|
+
"|gpg #{@config[:gpg,:options]} -c --passphrase-file #{gpg_password_file(password)}"
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
def extension
|
20
|
+
".gpg"
|
21
|
+
end
|
22
|
+
|
23
|
+
def active?
|
24
|
+
raise RuntimeError, "can't use both gpg password and pubkey" if key && password
|
25
|
+
|
26
|
+
!!(password || key)
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def password
|
32
|
+
@password ||= @config[:gpg, :password]
|
33
|
+
end
|
34
|
+
|
35
|
+
def key
|
36
|
+
@key ||= @config[:gpg, :key]
|
37
|
+
end
|
38
|
+
|
39
|
+
def gpg_password_file(pass)
|
40
|
+
return "TEMP_GENERATED_FILENAME" if $DRY_RUN
|
41
|
+
Astrails::Safe::TmpFile.create("gpg-pass") { |file| file.write(pass) }
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
module Astrails
|
2
|
+
module Safe
|
3
|
+
class Gzip < Pipe
|
4
|
+
|
5
|
+
protected
|
6
|
+
|
7
|
+
def post_process
|
8
|
+
@backup.compressed = true
|
9
|
+
end
|
10
|
+
|
11
|
+
def pipe
|
12
|
+
"|gzip"
|
13
|
+
end
|
14
|
+
|
15
|
+
def extension
|
16
|
+
".gz"
|
17
|
+
end
|
18
|
+
|
19
|
+
def active?
|
20
|
+
!@backup.compressed
|
21
|
+
end
|
22
|
+
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
module Astrails
|
2
|
+
module Safe
|
3
|
+
class Local < Sink
|
4
|
+
|
5
|
+
protected
|
6
|
+
|
7
|
+
def active?
|
8
|
+
# S3 can't upload from pipe. it needs to know file size, so we must pass through :local
|
9
|
+
# will change once we add SSH/FTP sink
|
10
|
+
true
|
11
|
+
end
|
12
|
+
|
13
|
+
def path
|
14
|
+
@path ||= File.expand_path(expand(@config[:local, :path] || raise(RuntimeError, "missing :local/:path")))
|
15
|
+
end
|
16
|
+
|
17
|
+
def save
|
18
|
+
puts "command: #{@backup.command}" if $_VERBOSE
|
19
|
+
|
20
|
+
@backup.path = full_path # need to do it outside DRY_RUN so that it will be avialable for S3 DRY_RUN
|
21
|
+
|
22
|
+
unless $DRY_RUN
|
23
|
+
FileUtils.mkdir_p(path) unless File.directory?(path)
|
24
|
+
benchmark = Benchmark.realtime do
|
25
|
+
system "#{@backup.command}>#{@backup.path}"
|
26
|
+
end
|
27
|
+
puts("command took " + sprintf("%.2f", benchmark) + " second(s).") if $_VERBOSE
|
28
|
+
end
|
29
|
+
|
30
|
+
end
|
31
|
+
|
32
|
+
def cleanup
|
33
|
+
return unless keep = @config[:keep, :local]
|
34
|
+
|
35
|
+
puts "listing files #{base}" if $_VERBOSE
|
36
|
+
|
37
|
+
files = Dir["#{base}*"] .
|
38
|
+
select{|f| File.file?(f) && File.size(f) > 0} .
|
39
|
+
sort
|
40
|
+
|
41
|
+
cleanup_with_limit(files, keep) do |f|
|
42
|
+
puts "removing local file #{f}" if $DRY_RUN || $_VERBOSE
|
43
|
+
File.unlink(f) unless $DRY_RUN
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
module Astrails
|
2
|
+
module Safe
|
3
|
+
class Mysqldump < Source
|
4
|
+
|
5
|
+
def command
|
6
|
+
"mysqldump --defaults-extra-file=#{mysql_password_file} #{@config[:options]} #{mysql_skip_tables} #{@id}"
|
7
|
+
end
|
8
|
+
|
9
|
+
def extension; '.sql'; end
|
10
|
+
|
11
|
+
protected
|
12
|
+
|
13
|
+
def mysql_password_file
|
14
|
+
Astrails::Safe::TmpFile.create("mysqldump") do |file|
|
15
|
+
file.puts "[mysqldump]"
|
16
|
+
%w/user password socket host port/.each do |k|
|
17
|
+
v = @config[k]
|
18
|
+
file.puts "#{k} = #{v}" if v
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def mysql_skip_tables
|
24
|
+
if skip_tables = @config[:skip_tables]
|
25
|
+
[*skip_tables].map { |t| "--ignore-table=#{@id}.#{t}" } * " "
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
module Astrails
|
2
|
+
module Safe
|
3
|
+
class Notification
|
4
|
+
|
5
|
+
attr_accessor :config, :error
|
6
|
+
def initialize(config, error)
|
7
|
+
@config, @error = config, error
|
8
|
+
end
|
9
|
+
|
10
|
+
def send_failure
|
11
|
+
if valid?
|
12
|
+
msg = "Subject: #{subject}\n\n"
|
13
|
+
msg += "#{subject} at #{Time.now.strftime('%A, %B %d, %Y %I:%M:%S %p')}\n\n"
|
14
|
+
msg += "Exception: #{error.to_s}\n\n"
|
15
|
+
msg += "Stack Trace: #{error.backtrace.join("\n")}\n"
|
16
|
+
Net::SMTP.start(host, port, domain, username, password, authentication) do |smtp|
|
17
|
+
smtp.send_message(msg, from, recipients)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
def valid?
|
25
|
+
subject && host && domain && username && password && authentication && port && from && recipients
|
26
|
+
end
|
27
|
+
|
28
|
+
def subject
|
29
|
+
@config[:notification, :subject]
|
30
|
+
end
|
31
|
+
|
32
|
+
def host
|
33
|
+
@config[:notification, :host]
|
34
|
+
end
|
35
|
+
|
36
|
+
def domain
|
37
|
+
@config[:notification, :domain]
|
38
|
+
end
|
39
|
+
|
40
|
+
def username
|
41
|
+
@config[:notification, :username]
|
42
|
+
end
|
43
|
+
|
44
|
+
def password
|
45
|
+
@config[:notification, :password]
|
46
|
+
end
|
47
|
+
|
48
|
+
def authentication
|
49
|
+
@config[:notification, :authentication]
|
50
|
+
end
|
51
|
+
|
52
|
+
def port
|
53
|
+
@config[:notification, :port]
|
54
|
+
end
|
55
|
+
|
56
|
+
def from
|
57
|
+
@config[:notification, :from]
|
58
|
+
end
|
59
|
+
|
60
|
+
def recipients
|
61
|
+
@config[:notification, :recipients]
|
62
|
+
end
|
63
|
+
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|