netguru-safe 0.2.9
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/.autotest +42 -0
- data/.document +5 -0
- data/.gitignore +11 -0
- data/CHANGELOG +25 -0
- data/Gemfile +4 -0
- data/Gemfile.lock +44 -0
- data/LICENSE +20 -0
- data/README.markdown +237 -0
- data/Rakefile +32 -0
- data/TODO +11 -0
- data/VERSION +1 -0
- data/astrails-safe.gemspec +37 -0
- data/bin/astrails-safe +53 -0
- data/examples/example_helper.rb +19 -0
- data/lib/astrails/safe.rb +73 -0
- data/lib/astrails/safe/archive.rb +24 -0
- data/lib/astrails/safe/backup.rb +20 -0
- data/lib/astrails/safe/cloudfiles.rb +77 -0
- data/lib/astrails/safe/config/builder.rb +60 -0
- data/lib/astrails/safe/config/node.rb +76 -0
- data/lib/astrails/safe/gpg.rb +46 -0
- data/lib/astrails/safe/gzip.rb +25 -0
- data/lib/astrails/safe/local.rb +51 -0
- data/lib/astrails/safe/mongodump.rb +23 -0
- data/lib/astrails/safe/mysqldump.rb +32 -0
- data/lib/astrails/safe/pgdump.rb +36 -0
- data/lib/astrails/safe/pipe.rb +17 -0
- data/lib/astrails/safe/s3.rb +75 -0
- data/lib/astrails/safe/sftp.rb +88 -0
- data/lib/astrails/safe/sink.rb +35 -0
- data/lib/astrails/safe/source.rb +47 -0
- data/lib/astrails/safe/stream.rb +20 -0
- data/lib/astrails/safe/svndump.rb +13 -0
- data/lib/astrails/safe/tmp_file.rb +48 -0
- data/lib/astrails/safe/version.rb +5 -0
- data/lib/extensions/mktmpdir.rb +45 -0
- data/spec/integration/airbrake_integration_spec.rb +76 -0
- data/spec/integration/archive_integration_spec.rb +88 -0
- data/spec/integration/cleanup_spec.rb +61 -0
- data/spec/spec.opts +5 -0
- data/spec/spec_helper.rb +13 -0
- data/spec/unit/archive_spec.rb +67 -0
- data/spec/unit/cloudfiles_spec.rb +177 -0
- data/spec/unit/config_spec.rb +234 -0
- data/spec/unit/gpg_spec.rb +148 -0
- data/spec/unit/gzip_spec.rb +64 -0
- data/spec/unit/local_spec.rb +110 -0
- data/spec/unit/mongodump_spec.rb +54 -0
- data/spec/unit/mysqldump_spec.rb +83 -0
- data/spec/unit/pgdump_spec.rb +45 -0
- data/spec/unit/s3_spec.rb +163 -0
- data/spec/unit/svndump_spec.rb +39 -0
- data/templates/script.rb +160 -0
- metadata +222 -0
data/VERSION
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
0.2.9.pre
|
@@ -0,0 +1,37 @@
|
|
1
|
+
require './lib/astrails/safe/version'
|
2
|
+
|
3
|
+
Gem::Specification.new do |s|
|
4
|
+
s.name = %q{netguru-safe}
|
5
|
+
s.version = Astrails::Safe::VERSION
|
6
|
+
s.authors = ["Astrails Ltd."]
|
7
|
+
s.date = Time.now.utc.strftime("%Y-%m-%d")
|
8
|
+
s.email = %q{we@astrails.com}
|
9
|
+
s.homepage = %q{http://blog.astrails.com/astrails-safe}
|
10
|
+
s.summary = %q{Backup filesystem and databases (MySQL and PostgreSQL) locally or to a remote server/service (with encryption)}
|
11
|
+
s.description = %q{Astrails-Safe is a simple tool to backup databases (MySQL and PostgreSQL), Subversion repositories (with svndump) and just files.
|
12
|
+
Backups can be stored locally or remotely and can be enctypted.
|
13
|
+
Remote storage is supported on Amazon S3, Rackspace Cloud Files, or just plain SFTP.
|
14
|
+
}
|
15
|
+
|
16
|
+
s.extra_rdoc_files = [
|
17
|
+
"LICENSE",
|
18
|
+
"README.markdown",
|
19
|
+
"TODO"
|
20
|
+
]
|
21
|
+
|
22
|
+
s.files = `git ls-files`.split("\n")
|
23
|
+
s.test_files = `git ls-files spec`.split("\n")
|
24
|
+
s.require_paths = ["lib"]
|
25
|
+
s.required_rubygems_version = %q{1.5.0}
|
26
|
+
s.default_executable = %q{astrails-safe}
|
27
|
+
s.executables = ["astrails-safe"]
|
28
|
+
|
29
|
+
# tests
|
30
|
+
s.add_development_dependency 'rspec', '~> 1.3.2'
|
31
|
+
s.add_development_dependency 'rr', '~> 1.0.4'
|
32
|
+
|
33
|
+
s.add_runtime_dependency 'aws-sdk', '~> 1.2.3'
|
34
|
+
s.add_runtime_dependency 'cloudfiles', '~> 1.4.7'
|
35
|
+
s.add_runtime_dependency 'net-sftp', '~> 2.0.4'
|
36
|
+
s.add_runtime_dependency 'toadhopper', '~> 2.0'
|
37
|
+
end
|
data/bin/astrails-safe
ADDED
@@ -0,0 +1,53 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require 'rubygems'
|
4
|
+
|
5
|
+
# require 'ruby-debug'
|
6
|
+
$:.unshift File.expand_path("../../lib", __FILE__)
|
7
|
+
|
8
|
+
require 'astrails/safe'
|
9
|
+
include Astrails::Safe
|
10
|
+
|
11
|
+
def die(msg)
|
12
|
+
puts "ERROR: #{msg}"
|
13
|
+
exit 1
|
14
|
+
end
|
15
|
+
|
16
|
+
def usage
|
17
|
+
puts <<-END
|
18
|
+
Usage: astrails-safe [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 and Cloud Files
|
24
|
+
|
25
|
+
Note: config file will be created from template if missing
|
26
|
+
END
|
27
|
+
exit 1
|
28
|
+
end
|
29
|
+
|
30
|
+
def process_options
|
31
|
+
usage if ARGV.delete("-h") || ARGV.delete("--help")
|
32
|
+
$_VERBOSE = ARGV.delete("-v") || ARGV.delete("--verbose")
|
33
|
+
$DRY_RUN = ARGV.delete("-n") || ARGV.delete("--dry-run")
|
34
|
+
$LOCAL = ARGV.delete("-L") || ARGV.delete("--local")
|
35
|
+
usage unless ARGV.first
|
36
|
+
$CONFIG_FILE_NAME = File.expand_path(ARGV.first)
|
37
|
+
end
|
38
|
+
|
39
|
+
def main
|
40
|
+
process_options
|
41
|
+
|
42
|
+
unless File.exists?($CONFIG_FILE_NAME)
|
43
|
+
die "Missing configuration file. NOT CREATED! Rerun w/o the -n argument to create a template configuration file." if $DRY_RUN
|
44
|
+
|
45
|
+
FileUtils.cp File.join(Astrails::Safe::ROOT, "templates", "script.rb"), $CONFIG_FILE_NAME
|
46
|
+
|
47
|
+
die "Created default #{$CONFIG_FILE_NAME}. Please edit and run again."
|
48
|
+
end
|
49
|
+
|
50
|
+
load($CONFIG_FILE_NAME)
|
51
|
+
end
|
52
|
+
|
53
|
+
main
|
@@ -0,0 +1,19 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'micronaut'
|
3
|
+
require 'ruby-debug'
|
4
|
+
|
5
|
+
SAFE_ROOT = File.dirname(File.dirname(__FILE__))
|
6
|
+
$LOAD_PATH.unshift(File.dirname(__FILE__))
|
7
|
+
$LOAD_PATH.unshift(File.join(SAFE_ROOT, 'lib'))
|
8
|
+
|
9
|
+
require 'astrails/safe'
|
10
|
+
|
11
|
+
def not_in_editor?
|
12
|
+
!(ENV.has_key?('TM_MODE') || ENV.has_key?('EMACS') || ENV.has_key?('VIM'))
|
13
|
+
end
|
14
|
+
|
15
|
+
Micronaut.configure do |c|
|
16
|
+
c.color_enabled = not_in_editor?
|
17
|
+
c.filter_run :focused => true
|
18
|
+
c.mock_with :rr
|
19
|
+
end
|
@@ -0,0 +1,73 @@
|
|
1
|
+
require "aws-sdk"
|
2
|
+
require "cloudfiles"
|
3
|
+
require 'net/sftp'
|
4
|
+
require 'fileutils'
|
5
|
+
require 'benchmark'
|
6
|
+
require 'toadhopper'
|
7
|
+
|
8
|
+
require 'tempfile'
|
9
|
+
require 'extensions/mktmpdir'
|
10
|
+
|
11
|
+
require 'astrails/safe/tmp_file'
|
12
|
+
|
13
|
+
require 'astrails/safe/config/node'
|
14
|
+
require 'astrails/safe/config/builder'
|
15
|
+
|
16
|
+
require 'astrails/safe/stream'
|
17
|
+
|
18
|
+
require 'astrails/safe/backup'
|
19
|
+
|
20
|
+
require 'astrails/safe/source'
|
21
|
+
require 'astrails/safe/mysqldump'
|
22
|
+
require 'astrails/safe/pgdump'
|
23
|
+
require 'astrails/safe/archive'
|
24
|
+
require 'astrails/safe/svndump'
|
25
|
+
require 'astrails/safe/mongodump'
|
26
|
+
|
27
|
+
require 'astrails/safe/pipe'
|
28
|
+
require 'astrails/safe/gpg'
|
29
|
+
require 'astrails/safe/gzip'
|
30
|
+
|
31
|
+
require 'astrails/safe/sink'
|
32
|
+
require 'astrails/safe/local'
|
33
|
+
require 'astrails/safe/s3'
|
34
|
+
require 'astrails/safe/cloudfiles'
|
35
|
+
require 'astrails/safe/sftp'
|
36
|
+
|
37
|
+
require 'astrails/safe/version'
|
38
|
+
|
39
|
+
module Astrails
|
40
|
+
module Safe
|
41
|
+
ROOT = File.join(File.dirname(__FILE__), "..", "..")
|
42
|
+
|
43
|
+
def safe(&block)
|
44
|
+
config = Config::Node.new(&block)
|
45
|
+
|
46
|
+
begin
|
47
|
+
[[Mysqldump, [:mysqldump, :databases]],
|
48
|
+
[Pgdump, [:pgdump, :databases]],
|
49
|
+
[Mongodump, [:mongodump, :databases]],
|
50
|
+
[Archive, [:tar, :archives]],
|
51
|
+
[Svndump, [:svndump, :repos]]
|
52
|
+
].each do |klass, path|
|
53
|
+
if collection = config[*path]
|
54
|
+
collection.each do |name, config|
|
55
|
+
klass.new(name, config).backup.run(config, :gpg, :gzip, :local, :s3, :cloudfiles, :sftp)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
rescue => e
|
60
|
+
begin
|
61
|
+
if config["airbrake"]
|
62
|
+
toad = Toadhopper.new(config["airbrake"]["api_key"])
|
63
|
+
toad.post!(e)
|
64
|
+
end
|
65
|
+
rescue
|
66
|
+
end
|
67
|
+
ensure
|
68
|
+
Astrails::Safe::TmpFile.cleanup
|
69
|
+
end
|
70
|
+
end
|
71
|
+
module_function :safe
|
72
|
+
end
|
73
|
+
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}"}.join(" ")
|
15
|
+
end
|
16
|
+
|
17
|
+
def tar_files
|
18
|
+
raise RuntimeError, "missing files for tar" unless @config[:files]
|
19
|
+
[*@config[:files]].map{|s| s.strip}.join(" ")
|
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,77 @@
|
|
1
|
+
module Astrails
|
2
|
+
module Safe
|
3
|
+
class Cloudfiles < Sink
|
4
|
+
MAX_CLOUDFILES_FILE_SIZE = 5368709120
|
5
|
+
|
6
|
+
protected
|
7
|
+
|
8
|
+
def active?
|
9
|
+
container && user && api_key
|
10
|
+
end
|
11
|
+
|
12
|
+
def path
|
13
|
+
@path ||= expand(config[:cloudfiles, :path] || config[:local, :path] || ":kind/:id")
|
14
|
+
end
|
15
|
+
|
16
|
+
# UGLY: we need this function for the reason that
|
17
|
+
# we can't double mock on ruby 1.9.2, duh!
|
18
|
+
# so we created this func to mock it all together
|
19
|
+
def get_file_size(path)
|
20
|
+
File.stat(path).size
|
21
|
+
end
|
22
|
+
|
23
|
+
def save
|
24
|
+
raise RuntimeError, "pipe-streaming not supported for S3." unless @backup.path
|
25
|
+
|
26
|
+
# needed in cleanup even on dry run
|
27
|
+
cf = CloudFiles::Connection.new(user, api_key, true, service_net) unless $LOCAL
|
28
|
+
puts "Uploading #{container}:#{full_path} from #{@backup.path}" if $_VERBOSE || $DRY_RUN
|
29
|
+
unless $DRY_RUN || $LOCAL
|
30
|
+
if get_file_size(@backup.path) > MAX_CLOUDFILES_FILE_SIZE
|
31
|
+
STDERR.puts "ERROR: File size exceeds maximum allowed for upload to Cloud Files (#{MAX_CLOUDFILES_FILE_SIZE}): #{@backup.path}"
|
32
|
+
return
|
33
|
+
end
|
34
|
+
benchmark = Benchmark.realtime do
|
35
|
+
cf_container = cf.create_container(container)
|
36
|
+
o = cf_container.create_object(full_path,true)
|
37
|
+
o.write(File.open(@backup.path))
|
38
|
+
end
|
39
|
+
puts "...done" if $_VERBOSE
|
40
|
+
puts("Upload took " + sprintf("%.2f", benchmark) + " second(s).") if $_VERBOSE
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def cleanup
|
45
|
+
return if $LOCAL
|
46
|
+
|
47
|
+
return unless keep = @config[:keep, :cloudfiles]
|
48
|
+
|
49
|
+
puts "listing files: #{container}:#{base}*" if $_VERBOSE
|
50
|
+
cf = CloudFiles::Connection.new(user, api_key, true, service_net) unless $LOCAL
|
51
|
+
cf_container = cf.container(container)
|
52
|
+
files = cf_container.objects(:prefix => base).sort
|
53
|
+
|
54
|
+
cleanup_with_limit(files, keep) do |f|
|
55
|
+
puts "removing Cloud File #{container}:#{f}" if $DRY_RUN || $_VERBOSE
|
56
|
+
cf_container.delete_object(f) unless $DRY_RUN || $LOCAL
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def container
|
61
|
+
@config[:cloudfiles, :container]
|
62
|
+
end
|
63
|
+
|
64
|
+
def user
|
65
|
+
@config[:cloudfiles, :user]
|
66
|
+
end
|
67
|
+
|
68
|
+
def api_key
|
69
|
+
@config[:cloudfiles, :api_key]
|
70
|
+
end
|
71
|
+
|
72
|
+
def service_net
|
73
|
+
@config[:cloudfiles, :service_net] || false
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
module Astrails
|
2
|
+
module Safe
|
3
|
+
module Config
|
4
|
+
class Builder
|
5
|
+
COLLECTIONS = %w/database archive repo/
|
6
|
+
ITEMS = %w/s3 cloudfiles key secret bucket api_key container service_net path gpg password keep local mysqldump pgdump command options
|
7
|
+
user host port socket skip_tables tar files exclude filename svndump repo_path sftp mongodump airbrake/
|
8
|
+
NAMES = COLLECTIONS + ITEMS
|
9
|
+
def initialize(node)
|
10
|
+
@node = node
|
11
|
+
end
|
12
|
+
|
13
|
+
# supported args:
|
14
|
+
# args = [value]
|
15
|
+
# args = [id, data]
|
16
|
+
# args = [data]
|
17
|
+
# id/value - simple values, data - hash
|
18
|
+
def method_missing(sym, *args, &block)
|
19
|
+
return super unless NAMES.include?(sym.to_s)
|
20
|
+
|
21
|
+
# do we have id or value?
|
22
|
+
unless args.first.is_a?(Hash)
|
23
|
+
id_or_value = args.shift # nil for args == []
|
24
|
+
end
|
25
|
+
|
26
|
+
id_or_value = id_or_value.map {|v| v.to_s} if id_or_value.is_a?(Array)
|
27
|
+
|
28
|
+
# do we have data hash?
|
29
|
+
if data = args.shift
|
30
|
+
raise "#{sym}: hash expected: #{data.inspect}" unless data.is_a?(Hash)
|
31
|
+
end
|
32
|
+
|
33
|
+
#puts "#{sym}: args=#{args.inspect}, id_or_value=#{id_or_value}, data=#{data.inspect}, block=#{block.inspect}"
|
34
|
+
|
35
|
+
raise "#{sym}: unexpected: #{args.inspect}" unless args.empty?
|
36
|
+
raise "#{sym}: missing arguments" unless id_or_value || data || block
|
37
|
+
|
38
|
+
if COLLECTIONS.include?(sym.to_s) && id_or_value
|
39
|
+
data ||= {}
|
40
|
+
end
|
41
|
+
|
42
|
+
if !data && !block
|
43
|
+
# simple value assignment
|
44
|
+
@node[sym] = id_or_value
|
45
|
+
|
46
|
+
elsif id_or_value
|
47
|
+
# collection element with id => create collection node and a subnode in it
|
48
|
+
key = sym.to_s + "s"
|
49
|
+
collection = @node[key] || @node.set(key, {})
|
50
|
+
collection.set(id_or_value, data || {}, &block)
|
51
|
+
|
52
|
+
else
|
53
|
+
# simple subnode
|
54
|
+
@node.set(sym, data || {}, &block)
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
@@ -0,0 +1,76 @@
|
|
1
|
+
require 'astrails/safe/config/builder'
|
2
|
+
module Astrails
|
3
|
+
module Safe
|
4
|
+
module Config
|
5
|
+
class Node
|
6
|
+
attr_reader :parent
|
7
|
+
attr_reader :data
|
8
|
+
def initialize(parent = nil, data = {}, &block)
|
9
|
+
@parent, @data = parent, {}
|
10
|
+
data.each { |k, v| self[k] = v }
|
11
|
+
Builder.new(self).instance_eval(&block) if block
|
12
|
+
end
|
13
|
+
|
14
|
+
# looks for the path from this node DOWN. will not delegate to parent
|
15
|
+
def get(*path)
|
16
|
+
key = path.shift
|
17
|
+
value = @data[key.to_s]
|
18
|
+
return value if value && path.empty?
|
19
|
+
|
20
|
+
value && value.get(*path)
|
21
|
+
end
|
22
|
+
|
23
|
+
# recursive find
|
24
|
+
# starts at the node and continues to the parent
|
25
|
+
def find(*path)
|
26
|
+
get(*path) || @parent && @parent.find(*path)
|
27
|
+
end
|
28
|
+
alias :[] :find
|
29
|
+
|
30
|
+
MULTIVALUES = %w/skip_tables exclude files/
|
31
|
+
def set(key, value, &block)
|
32
|
+
if @data[key.to_s]
|
33
|
+
raise(ArgumentError, "duplicate value for '#{key}'") if value.is_a?(Hash) || !MULTIVALUES.include?(key.to_s)
|
34
|
+
end
|
35
|
+
|
36
|
+
if value.is_a?(Hash)
|
37
|
+
@data[key.to_s] = Node.new(self, value, &block)
|
38
|
+
else
|
39
|
+
raise(ArgumentError, "#{key}: no block supported for simple values") if block
|
40
|
+
if @data[key.to_s]
|
41
|
+
@data[key.to_s] = [*@data[key.to_s]] + [value]
|
42
|
+
else
|
43
|
+
@data[key.to_s] = value
|
44
|
+
end
|
45
|
+
value
|
46
|
+
end
|
47
|
+
end
|
48
|
+
alias :[]= :set
|
49
|
+
|
50
|
+
def each(&block)
|
51
|
+
@data.each(&block)
|
52
|
+
end
|
53
|
+
include Enumerable
|
54
|
+
|
55
|
+
def to_hash
|
56
|
+
@data.keys.inject({}) do |res, key|
|
57
|
+
value = @data[key]
|
58
|
+
res[key] = value.is_a?(Node) ? value.to_hash : value
|
59
|
+
res
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
def dump(indent = "")
|
64
|
+
@data.each do |key, value|
|
65
|
+
if value.is_a?(Node)
|
66
|
+
puts "#{indent}#{key}:"
|
67
|
+
value.dump(indent + " ")
|
68
|
+
else
|
69
|
+
puts "#{indent}#{key}: #{value.inspect}"
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|