darkofabijan-astrails-safe 0.2.8
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 +237 -0
- data/Rakefile +61 -0
- data/bin/astrails-safe +53 -0
- data/examples/example_helper.rb +19 -0
- data/lib/astrails/safe.rb +61 -0
- data/lib/astrails/safe/archive.rb +24 -0
- data/lib/astrails/safe/backup.rb +20 -0
- data/lib/astrails/safe/cloudfiles.rb +70 -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 +70 -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 +86 -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/extensions/mktmpdir.rb +45 -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 +16 -0
- data/spec/unit/archive_spec.rb +67 -0
- data/spec/unit/cloudfiles_spec.rb +170 -0
- data/spec/unit/config_spec.rb +213 -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/mysqldump_spec.rb +83 -0
- data/spec/unit/pgdump_spec.rb +45 -0
- data/spec/unit/s3_spec.rb +160 -0
- data/spec/unit/svndump_spec.rb +39 -0
- data/templates/script.rb +165 -0
- metadata +179 -0
@@ -0,0 +1,17 @@
|
|
1
|
+
module Astrails
|
2
|
+
module Safe
|
3
|
+
class Pipe < Stream
|
4
|
+
# process adds required commands to the current
|
5
|
+
# shell command string
|
6
|
+
# :active?, :pipe, :extension and :post_process are
|
7
|
+
# defined in inheriting pipe classes
|
8
|
+
def process
|
9
|
+
return unless active?
|
10
|
+
|
11
|
+
@backup.command << pipe
|
12
|
+
@backup.extension << extension
|
13
|
+
post_process
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,86 @@
|
|
1
|
+
module Astrails
|
2
|
+
module Safe
|
3
|
+
class S3 < Sink
|
4
|
+
MAX_S3_FILE_SIZE = 5368709120
|
5
|
+
|
6
|
+
protected
|
7
|
+
|
8
|
+
def active?
|
9
|
+
bucket && key && secret
|
10
|
+
end
|
11
|
+
|
12
|
+
def path
|
13
|
+
@path ||= expand(config[:s3, :path] || config[:local, :path] || ":kind/:id")
|
14
|
+
end
|
15
|
+
|
16
|
+
def save
|
17
|
+
# FIXME: user friendly error here :)
|
18
|
+
raise RuntimeError, "pipe-streaming not supported for S3." unless @backup.path
|
19
|
+
|
20
|
+
# needed in cleanup even on dry run
|
21
|
+
AWS::S3::Base.establish_connection!(:access_key_id => key, :secret_access_key => secret, :use_ssl => true) unless $LOCAL
|
22
|
+
|
23
|
+
unless $DRY_RUN || $LOCAL
|
24
|
+
benchmark = Benchmark.realtime do
|
25
|
+
AWS::S3::Bucket.create(bucket)
|
26
|
+
store_files_to_bucket(@backup.path)
|
27
|
+
end
|
28
|
+
puts("Upload took " + sprintf("%.2f", benchmark) + " second(s).") if $_VERBOSE
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def cleanup
|
33
|
+
return if $LOCAL
|
34
|
+
|
35
|
+
return unless keep = @config[:keep, :s3]
|
36
|
+
|
37
|
+
puts "listing files: #{bucket}:#{base}*" if $_VERBOSE
|
38
|
+
files = AWS::S3::Bucket.objects(bucket, :prefix => base, :max_keys => keep * 2)
|
39
|
+
puts files.collect {|x| x.key} if $_VERBOSE
|
40
|
+
|
41
|
+
files = files.collect {|x| x.key}.sort
|
42
|
+
|
43
|
+
cleanup_with_limit(files, keep) do |f|
|
44
|
+
puts "removing s3 file #{bucket}:#{f}" if $DRY_RUN || $_VERBOSE
|
45
|
+
AWS::S3::Bucket.find(bucket)[f].delete unless $DRY_RUN || $LOCAL
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
def bucket
|
50
|
+
@config[:s3, :bucket]
|
51
|
+
end
|
52
|
+
|
53
|
+
def key
|
54
|
+
@config[:s3, :key]
|
55
|
+
end
|
56
|
+
|
57
|
+
def secret
|
58
|
+
@config[:s3, :secret]
|
59
|
+
end
|
60
|
+
|
61
|
+
def store_files_to_bucket(files)
|
62
|
+
if files.class == String
|
63
|
+
store_file(files)
|
64
|
+
elsif files.class == Array
|
65
|
+
files.each do |file|
|
66
|
+
store_file(file)
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def store_file(filepath)
|
72
|
+
if File.stat(filepath).size > MAX_S3_FILE_SIZE
|
73
|
+
STDERR.puts "ERROR: File size exceeds maximum allowed for upload to S3 (#{MAX_S3_FILE_SIZE}): #{@backup.path}"
|
74
|
+
return
|
75
|
+
end
|
76
|
+
File.open(filepath) do |file|
|
77
|
+
destination_path = full_path(File.basename(filepath), '')
|
78
|
+
puts "Uploading #{bucket}:#{destination_path}" if $_VERBOSE
|
79
|
+
AWS::S3::S3Object.store(destination_path, file, bucket)
|
80
|
+
puts "...done" if $_VERBOSE
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
@@ -0,0 +1,88 @@
|
|
1
|
+
module Astrails
|
2
|
+
module Safe
|
3
|
+
class Sftp < Sink
|
4
|
+
|
5
|
+
protected
|
6
|
+
|
7
|
+
def active?
|
8
|
+
host && user
|
9
|
+
end
|
10
|
+
|
11
|
+
def path
|
12
|
+
@path ||= expand(config[:sftp, :path] || config[:local, :path] || ":kind/:id")
|
13
|
+
end
|
14
|
+
|
15
|
+
def save
|
16
|
+
raise RuntimeError, "pipe-streaming not supported for SFTP." unless @backup.path
|
17
|
+
|
18
|
+
puts "Uploading #{host}:#{full_path} via SFTP" if $_VERBOSE || $DRY_RUN
|
19
|
+
|
20
|
+
unless $DRY_RUN || $LOCAL
|
21
|
+
opts = {}
|
22
|
+
opts[:password] = password if password
|
23
|
+
opts[:port] = port if port
|
24
|
+
Net::SFTP.start(host, user, opts) do |sftp|
|
25
|
+
puts "Sending #{@backup.path} to #{full_path}" if $_VERBOSE
|
26
|
+
begin
|
27
|
+
sftp.upload! @backup.path, full_path
|
28
|
+
rescue Net::SFTP::StatusException
|
29
|
+
puts "Ensuring remote path (#{path}) exists" if $_VERBOSE
|
30
|
+
# mkdir -p
|
31
|
+
folders = path.split('/')
|
32
|
+
folders.each_index do |i|
|
33
|
+
folder = folders[0..i].join('/')
|
34
|
+
puts "Creating #{folder} on remote" if $_VERBOSE
|
35
|
+
sftp.mkdir!(folder) rescue Net::SFTP::StatusException
|
36
|
+
end
|
37
|
+
retry
|
38
|
+
end
|
39
|
+
end
|
40
|
+
puts "...done" if $_VERBOSE
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def cleanup
|
45
|
+
return if $LOCAL || $DRY_RUN
|
46
|
+
|
47
|
+
return unless keep = @config[:keep, :sftp]
|
48
|
+
|
49
|
+
puts "listing files: #{host}:#{base}*" if $_VERBOSE
|
50
|
+
opts = {}
|
51
|
+
opts[:password] = password if password
|
52
|
+
opts[:port] = port if port
|
53
|
+
Net::SFTP.start(host, user, opts) do |sftp|
|
54
|
+
files = sftp.dir.glob(path, File.basename("#{base}*"))
|
55
|
+
|
56
|
+
puts files.collect {|x| x.name } if $_VERBOSE
|
57
|
+
|
58
|
+
files = files.
|
59
|
+
collect {|x| x.name }.
|
60
|
+
sort
|
61
|
+
|
62
|
+
cleanup_with_limit(files, keep) do |f|
|
63
|
+
file = File.join(path, f)
|
64
|
+
puts "removing sftp file #{host}:#{file}" if $DRY_RUN || $_VERBOSE
|
65
|
+
sftp.remove!(file) unless $DRY_RUN || $LOCAL
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
def host
|
71
|
+
@config[:sftp, :host]
|
72
|
+
end
|
73
|
+
|
74
|
+
def user
|
75
|
+
@config[:sftp, :user]
|
76
|
+
end
|
77
|
+
|
78
|
+
def password
|
79
|
+
@config[:sftp, :password]
|
80
|
+
end
|
81
|
+
|
82
|
+
def port
|
83
|
+
@config[:sftp, :port]
|
84
|
+
end
|
85
|
+
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
module Astrails
|
2
|
+
module Safe
|
3
|
+
class Sink < Stream
|
4
|
+
|
5
|
+
def process
|
6
|
+
return unless active?
|
7
|
+
|
8
|
+
save
|
9
|
+
cleanup
|
10
|
+
end
|
11
|
+
|
12
|
+
protected
|
13
|
+
|
14
|
+
# path is defined in subclass
|
15
|
+
# base is used in 'cleanup' to find all files that begin with base. the '.'
|
16
|
+
# at the end is essential to distinguish b/w foo.* and foobar.* archives for example
|
17
|
+
def base
|
18
|
+
@base ||= File.join(path, File.basename(@backup.filename).split(".").first + '.')
|
19
|
+
end
|
20
|
+
|
21
|
+
def full_path(filename = @backup.filename, extension = @backup.extension)
|
22
|
+
File.join(path, filename) + extension
|
23
|
+
end
|
24
|
+
|
25
|
+
# call block on files to be removed (all except for the LAST 'limit' files
|
26
|
+
def cleanup_with_limit(files, limit, &block)
|
27
|
+
return unless files.size > limit
|
28
|
+
|
29
|
+
to_remove = files[0..(files.size - limit - 1)]
|
30
|
+
# TODO: validate here
|
31
|
+
to_remove.each(&block)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
module Astrails
|
2
|
+
module Safe
|
3
|
+
class Source < Stream
|
4
|
+
|
5
|
+
attr_accessor :id
|
6
|
+
def initialize(id, config)
|
7
|
+
@id, @config = id.to_s, config
|
8
|
+
end
|
9
|
+
|
10
|
+
def timestamp
|
11
|
+
Time.now.strftime("%y%m%d-%H%M")
|
12
|
+
end
|
13
|
+
|
14
|
+
def kind
|
15
|
+
self.class.human_name
|
16
|
+
end
|
17
|
+
|
18
|
+
def filename
|
19
|
+
@filename ||= expand(":kind-:id.:timestamp")
|
20
|
+
end
|
21
|
+
|
22
|
+
def backup
|
23
|
+
return @backup if @backup
|
24
|
+
@backup = Backup.new(
|
25
|
+
:id => @id,
|
26
|
+
:kind => kind,
|
27
|
+
:extension => extension,
|
28
|
+
:command => command,
|
29
|
+
:timestamp => timestamp
|
30
|
+
)
|
31
|
+
# can't do this in the initializer hash above since
|
32
|
+
# filename() calls expand() which requires @backup
|
33
|
+
# FIXME: move expansion to the backup (last step in ctor) assign :tags here
|
34
|
+
@backup.filename = filename
|
35
|
+
@backup
|
36
|
+
end
|
37
|
+
|
38
|
+
protected
|
39
|
+
|
40
|
+
def self.human_name
|
41
|
+
name.split('::').last.downcase
|
42
|
+
end
|
43
|
+
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
@@ -0,0 +1,20 @@
|
|
1
|
+
module Astrails
|
2
|
+
module Safe
|
3
|
+
class Stream
|
4
|
+
|
5
|
+
attr_accessor :config, :backup
|
6
|
+
def initialize(config, backup)
|
7
|
+
@config, @backup = config, backup
|
8
|
+
end
|
9
|
+
|
10
|
+
# FIXME: move to Backup
|
11
|
+
def expand(path)
|
12
|
+
path .
|
13
|
+
gsub(/:kind\b/, @backup.kind.to_s) .
|
14
|
+
gsub(/:id\b/, @backup.id.to_s) .
|
15
|
+
gsub(/:timestamp\b/, @backup.timestamp)
|
16
|
+
end
|
17
|
+
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
require 'tmpdir'
|
2
|
+
module Astrails
|
3
|
+
module Safe
|
4
|
+
module TmpFile
|
5
|
+
@keep_files = []
|
6
|
+
|
7
|
+
def self.tmproot
|
8
|
+
@tmproot ||= Dir.mktmpdir
|
9
|
+
end
|
10
|
+
|
11
|
+
def self.cleanup
|
12
|
+
begin
|
13
|
+
FileUtils.remove_entry_secure tmproot
|
14
|
+
rescue ArgumentError => e
|
15
|
+
if e.message =~ /parent directory is world writable/
|
16
|
+
puts <<-ERR
|
17
|
+
|
18
|
+
|
19
|
+
********************************************************************************
|
20
|
+
It looks like you have wrong permissions on your TEMP directory. The usual
|
21
|
+
case is when you have world writable TEMP directory withOUT the sticky bit.
|
22
|
+
|
23
|
+
Try "chmod +t" on it.
|
24
|
+
|
25
|
+
********************************************************************************
|
26
|
+
|
27
|
+
ERR
|
28
|
+
else
|
29
|
+
raise
|
30
|
+
end
|
31
|
+
end
|
32
|
+
@tmproot = nil
|
33
|
+
end
|
34
|
+
|
35
|
+
def self.create(name)
|
36
|
+
# create temp directory
|
37
|
+
|
38
|
+
file = Tempfile.new(name, tmproot)
|
39
|
+
|
40
|
+
yield file
|
41
|
+
|
42
|
+
file.close
|
43
|
+
@keep_files << file # so that it will not get gcollected and removed from filesystem until the end
|
44
|
+
file.path
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
require 'tmpdir'
|
2
|
+
|
3
|
+
unless Dir.respond_to?(:mktmpdir)
|
4
|
+
# backward compat for 1.8.6
|
5
|
+
class Dir
|
6
|
+
def Dir.mktmpdir(prefix_suffix=nil, tmpdir=nil)
|
7
|
+
case prefix_suffix
|
8
|
+
when nil
|
9
|
+
prefix = "d"
|
10
|
+
suffix = ""
|
11
|
+
when String
|
12
|
+
prefix = prefix_suffix
|
13
|
+
suffix = ""
|
14
|
+
when Array
|
15
|
+
prefix = prefix_suffix[0]
|
16
|
+
suffix = prefix_suffix[1]
|
17
|
+
else
|
18
|
+
raise ArgumentError, "unexpected prefix_suffix: #{prefix_suffix.inspect}"
|
19
|
+
end
|
20
|
+
tmpdir ||= Dir.tmpdir
|
21
|
+
t = Time.now.strftime("%Y%m%d")
|
22
|
+
n = nil
|
23
|
+
begin
|
24
|
+
path = "#{tmpdir}/#{prefix}#{t}-#{$$}-#{rand(0x100000000).to_s(36)}"
|
25
|
+
path << "-#{n}" if n
|
26
|
+
path << suffix
|
27
|
+
Dir.mkdir(path, 0700)
|
28
|
+
rescue Errno::EEXIST
|
29
|
+
n ||= 0
|
30
|
+
n += 1
|
31
|
+
retry
|
32
|
+
end
|
33
|
+
|
34
|
+
if block_given?
|
35
|
+
begin
|
36
|
+
yield path
|
37
|
+
ensure
|
38
|
+
FileUtils.remove_entry_secure path
|
39
|
+
end
|
40
|
+
else
|
41
|
+
path
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,88 @@
|
|
1
|
+
require File.expand_path(File.dirname(__FILE__) + '/../spec_helper')
|
2
|
+
|
3
|
+
require "fileutils"
|
4
|
+
include FileUtils
|
5
|
+
|
6
|
+
describe "tar backup" do
|
7
|
+
before(:all) do
|
8
|
+
# need both local and instance vars
|
9
|
+
# instance variables are used in tests
|
10
|
+
# local variables are used in the backup definition (instance vars can't be seen)
|
11
|
+
@root = root = "tmp/archive_backup_example"
|
12
|
+
|
13
|
+
# clean state
|
14
|
+
rm_rf @root
|
15
|
+
mkdir_p @root
|
16
|
+
|
17
|
+
# create source tree
|
18
|
+
@src = src = "#{@root}/src"
|
19
|
+
mkdir_p "#{@src}/q/w/e"
|
20
|
+
mkdir_p "#{@src}/a/s/d"
|
21
|
+
|
22
|
+
File.open("#{@src}/qwe1", "w") {|f| f.write("qwe") }
|
23
|
+
File.open("#{@src}/q/qwe2", "w") {|f| f.write("qwe"*2) }
|
24
|
+
File.open("#{@src}/q/w/qwe3", "w") {|f| f.write("qwe"*3) }
|
25
|
+
File.open("#{@src}/q/w/e/qwe4", "w") {|f| f.write("qwe"*4) }
|
26
|
+
|
27
|
+
File.open("#{@src}/asd1", "w") {|f| f.write("asd") }
|
28
|
+
File.open("#{@src}/a/asd2", "w") {|f| f.write("asd" * 2) }
|
29
|
+
File.open("#{@src}/a/s/asd3", "w") {|f| f.write("asd" * 3) }
|
30
|
+
|
31
|
+
@dst = dst = "#{@root}/backup"
|
32
|
+
mkdir_p @dst
|
33
|
+
|
34
|
+
@now = Time.now
|
35
|
+
@timestamp = @now.strftime("%y%m%d-%H%M")
|
36
|
+
|
37
|
+
stub(Time).now {@now} # Freeze
|
38
|
+
|
39
|
+
Astrails::Safe.safe do
|
40
|
+
local :path => "#{dst}/:kind"
|
41
|
+
tar do
|
42
|
+
archive :test1 do
|
43
|
+
files src
|
44
|
+
exclude "#{src}/q/w"
|
45
|
+
exclude "#{src}/q/w/e"
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
@backup = "#{dst}/archive/archive-test1.#{@timestamp}.tar.gz"
|
51
|
+
end
|
52
|
+
|
53
|
+
it "should create backup file" do
|
54
|
+
File.exists?(@backup).should be_true
|
55
|
+
end
|
56
|
+
|
57
|
+
describe "after extracting" do
|
58
|
+
before(:all) do
|
59
|
+
# prepare target dir
|
60
|
+
@target = "#{@root}/test"
|
61
|
+
mkdir_p @target
|
62
|
+
system "tar -zxvf #{@backup} -C #{@target}"
|
63
|
+
|
64
|
+
@test = "#{@target}/#{@root}/src"
|
65
|
+
puts @test
|
66
|
+
end
|
67
|
+
|
68
|
+
it "should include asd1/2/3" do
|
69
|
+
File.exists?("#{@test}/asd1").should be_true
|
70
|
+
File.exists?("#{@test}/a/asd2").should be_true
|
71
|
+
File.exists?("#{@test}/a/s/asd3").should be_true
|
72
|
+
end
|
73
|
+
|
74
|
+
it "should only include qwe 1 and 2 (no 3)" do
|
75
|
+
File.exists?("#{@test}/qwe1").should be_true
|
76
|
+
File.exists?("#{@test}/q/qwe2").should be_true
|
77
|
+
File.exists?("#{@test}/q/w/qwe3").should be_false
|
78
|
+
File.exists?("#{@test}/q/w/e/qwe4").should be_false
|
79
|
+
end
|
80
|
+
|
81
|
+
it "should preserve file content" do
|
82
|
+
File.read("#{@test}/qwe1").should == "qwe"
|
83
|
+
File.read("#{@test}/q/qwe2").should == "qweqwe"
|
84
|
+
File.read("#{@test}/a/s/asd3").should == "asdasdasd"
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
end
|