markmansour-safe 0.1.7
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 +171 -0
- data/Rakefile +52 -0
- data/VERSION.yml +4 -0
- data/bin/astrails-safe +59 -0
- data/examples/example_helper.rb +19 -0
- data/examples/unit/config_example.rb +175 -0
- data/examples/unit/stream_example.rb +33 -0
- data/lib/astrails/safe.rb +43 -0
- data/lib/astrails/safe/archive.rb +24 -0
- data/lib/astrails/safe/config/builder.rb +60 -0
- data/lib/astrails/safe/config/node.rb +67 -0
- data/lib/astrails/safe/gpg.rb +42 -0
- data/lib/astrails/safe/gzip.rb +25 -0
- data/lib/astrails/safe/local.rb +54 -0
- data/lib/astrails/safe/mysqldump.rb +31 -0
- data/lib/astrails/safe/pgdump.rb +43 -0
- data/lib/astrails/safe/pipe.rb +15 -0
- data/lib/astrails/safe/s3.rb +68 -0
- data/lib/astrails/safe/sink.rb +33 -0
- data/lib/astrails/safe/source.rb +31 -0
- data/lib/astrails/safe/stream.rb +45 -0
- data/lib/astrails/safe/tmp_file.rb +25 -0
- data/lib/extensions/mktmpdir.rb +45 -0
- data/templates/script.rb +122 -0
- metadata +94 -0
@@ -0,0 +1,33 @@
|
|
1
|
+
require File.expand_path(File.dirname(__FILE__) + '/../example_helper')
|
2
|
+
|
3
|
+
describe Astrails::Safe::Stream do
|
4
|
+
|
5
|
+
before(:each) do
|
6
|
+
@parent = Object.new
|
7
|
+
@stream = Astrails::Safe::Stream.new(@parent)
|
8
|
+
@r = rand(10)
|
9
|
+
end
|
10
|
+
|
11
|
+
def self.it_delegates_to_parent(prop)
|
12
|
+
it "delegates #{prop} to parent if not set" do
|
13
|
+
mock(@parent).__send__(prop) {@r}
|
14
|
+
@stream.send(prop).should == @r
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.it_delegates_to_parent_with_cache(prop)
|
19
|
+
it_delegates_to_parent(prop)
|
20
|
+
|
21
|
+
it "uses cached value for #{prop}" do
|
22
|
+
dont_allow(@parent).__send__(prop)
|
23
|
+
@stream.instance_variable_set "@#{prop}", @r + 1
|
24
|
+
@stream.send(prop).should == @r + 1
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
it_delegates_to_parent_with_cache :id
|
29
|
+
it_delegates_to_parent_with_cache :config
|
30
|
+
|
31
|
+
it_delegates_to_parent :filename
|
32
|
+
|
33
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
require 'extensions/mktmpdir'
|
2
|
+
require 'astrails/safe/tmp_file'
|
3
|
+
|
4
|
+
require 'astrails/safe/config/node'
|
5
|
+
require 'astrails/safe/config/builder'
|
6
|
+
|
7
|
+
require 'astrails/safe/stream'
|
8
|
+
|
9
|
+
require 'astrails/safe/source'
|
10
|
+
require 'astrails/safe/mysqldump'
|
11
|
+
require 'astrails/safe/pgdump'
|
12
|
+
require 'astrails/safe/archive'
|
13
|
+
|
14
|
+
require 'astrails/safe/pipe'
|
15
|
+
require 'astrails/safe/gpg'
|
16
|
+
require 'astrails/safe/gzip'
|
17
|
+
|
18
|
+
require 'astrails/safe/sink'
|
19
|
+
require 'astrails/safe/local'
|
20
|
+
require 'astrails/safe/s3'
|
21
|
+
|
22
|
+
|
23
|
+
module Astrails
|
24
|
+
module Safe
|
25
|
+
ROOT = File.join(File.dirname(__FILE__), "..", "..")
|
26
|
+
|
27
|
+
def timestamp
|
28
|
+
@timestamp ||= Time.now.strftime("%y%m%d-%H%M")
|
29
|
+
end
|
30
|
+
|
31
|
+
def safe(&block)
|
32
|
+
config = Config::Node.new(&block)
|
33
|
+
#config.dump
|
34
|
+
|
35
|
+
Astrails::Safe::Mysqldump.run(config[:mysqldump, :databases])
|
36
|
+
Astrails::Safe::Pgdump.run(config[:pgdump, :databases])
|
37
|
+
Astrails::Safe::Archive.run(config[:tar, :archives])
|
38
|
+
|
39
|
+
Astrails::Safe::TmpFile.cleanup
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
@@ -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]] * " "
|
20
|
+
end
|
21
|
+
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
module Astrails
|
2
|
+
module Safe
|
3
|
+
module Config
|
4
|
+
class Builder
|
5
|
+
COLLECTIONS = %w/database archive/
|
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/
|
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
|
+
die "#{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
|
+
die "#{sym}: unexpected: #{args.inspect}" unless args.empty?
|
36
|
+
die "#{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,67 @@
|
|
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
|
+
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
module Astrails
|
2
|
+
module Safe
|
3
|
+
class Gpg < Pipe
|
4
|
+
|
5
|
+
def compressed?
|
6
|
+
active? || @parent.compressed?
|
7
|
+
end
|
8
|
+
|
9
|
+
protected
|
10
|
+
|
11
|
+
def pipe
|
12
|
+
if key
|
13
|
+
rise RuntimeError, "can't use both gpg password and pubkey" if password
|
14
|
+
"|gpg -e -r #{key}"
|
15
|
+
elsif password
|
16
|
+
"|gpg -c --passphrase-file #{gpg_password_file(password)}"
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def extension
|
21
|
+
".gpg" if active?
|
22
|
+
end
|
23
|
+
|
24
|
+
def active?
|
25
|
+
password || key
|
26
|
+
end
|
27
|
+
|
28
|
+
def password
|
29
|
+
@password ||= config[:gpg, :password]
|
30
|
+
end
|
31
|
+
|
32
|
+
def key
|
33
|
+
@key ||= config[:gpg, :key]
|
34
|
+
end
|
35
|
+
|
36
|
+
def gpg_password_file(pass)
|
37
|
+
return "TEMP_GENERATED_FILENAME" if $DRY_RUN
|
38
|
+
Astrails::Safe::TmpFile.create("gpg-pass") { |file| file.write(pass) }
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
module Astrails
|
2
|
+
module Safe
|
3
|
+
class Gzip < Pipe
|
4
|
+
|
5
|
+
def compressed?
|
6
|
+
true
|
7
|
+
end
|
8
|
+
|
9
|
+
protected
|
10
|
+
|
11
|
+
def pipe
|
12
|
+
"|gzip" if active?
|
13
|
+
end
|
14
|
+
|
15
|
+
def extension
|
16
|
+
".gz" if active?
|
17
|
+
end
|
18
|
+
|
19
|
+
def active?
|
20
|
+
!@parent.compressed?
|
21
|
+
end
|
22
|
+
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
module Astrails
|
2
|
+
module Safe
|
3
|
+
class Local < Sink
|
4
|
+
|
5
|
+
def open(&block)
|
6
|
+
return @parent.open(&block) unless active?
|
7
|
+
run
|
8
|
+
File.open(path, &block) unless $DRY_RUN
|
9
|
+
end
|
10
|
+
|
11
|
+
protected
|
12
|
+
|
13
|
+
def active?
|
14
|
+
# S3 can't upload from pipe. it needs to know file size, so we must pass through :local
|
15
|
+
# will change once we add SSH sink
|
16
|
+
true
|
17
|
+
end
|
18
|
+
|
19
|
+
def prefix
|
20
|
+
@prefix ||= File.expand_path(expand(@config[:local, :path] || raise(RuntimeError, "missing :local/:path in configuration")))
|
21
|
+
end
|
22
|
+
|
23
|
+
def command
|
24
|
+
"#{@parent.command} > #{path}"
|
25
|
+
end
|
26
|
+
|
27
|
+
def save
|
28
|
+
puts "command: #{command}" if $_VERBOSE
|
29
|
+
unless $DRY_RUN
|
30
|
+
FileUtils.mkdir_p(prefix) unless File.directory?(prefix)
|
31
|
+
system command
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def cleanup
|
36
|
+
return unless keep = @config[:keep, :local]
|
37
|
+
|
38
|
+
base = File.basename(filename).split(".").first
|
39
|
+
|
40
|
+
pattern = File.join(prefix, "#{base}*")
|
41
|
+
puts "listing files #{pattern.inspect}" if $_VERBOSE
|
42
|
+
files = Dir[pattern] .
|
43
|
+
select{|f| File.file?(f)} .
|
44
|
+
sort
|
45
|
+
|
46
|
+
cleanup_with_limit(files, keep) do |f|
|
47
|
+
puts "removing local file #{f}" if $DRY_RUN || $_VERBOSE
|
48
|
+
File.unlink(f) unless $DRY_RUN
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
module Astrails
|
2
|
+
module Safe
|
3
|
+
class Mysqldump < Source
|
4
|
+
|
5
|
+
def command
|
6
|
+
@commanbd ||= "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,43 @@
|
|
1
|
+
module Astrails
|
2
|
+
module Safe
|
3
|
+
class Pgdump < Source
|
4
|
+
|
5
|
+
def command
|
6
|
+
@commanbd ||= "pg_dump #{postgres_options} #{postgres_username} #{postgres_password} #{postgres_host} #{postgres_port} #{@id}"
|
7
|
+
# @commanbd ||= "pg_dump -U #{@config["user"]} #{@config[:option]} #{@config["database"]} -f #{filename}"
|
8
|
+
# @commanbd ||= "mysqldump --defaults-extra-file=#{mysql_password_file} #{@config[:options]} #{mysql_skip_tables} #{@id}"
|
9
|
+
end
|
10
|
+
|
11
|
+
def extension; '.sql'; end
|
12
|
+
|
13
|
+
protected
|
14
|
+
|
15
|
+
def postgres_options
|
16
|
+
@config[:options]
|
17
|
+
end
|
18
|
+
|
19
|
+
def postgres_host
|
20
|
+
@config["host"] ? "--host='#{@config["port"]}'" : ""
|
21
|
+
end
|
22
|
+
|
23
|
+
def postgres_port
|
24
|
+
@config["port"] ? "--port='#{@config["port"]}'" : ""
|
25
|
+
end
|
26
|
+
|
27
|
+
def postgres_username
|
28
|
+
@config["user"] ? "--username='#{@config["user"]}'" : ""
|
29
|
+
end
|
30
|
+
|
31
|
+
def postgres_password
|
32
|
+
`export PGPASSWORD=#{@config["password"]}` if @config["password"]
|
33
|
+
end
|
34
|
+
|
35
|
+
def postgres_skip_tables
|
36
|
+
if skip_tables = @config[:skip_tables]
|
37
|
+
[*skip_tables].map { |t| "--exclude-table=#{@id}.#{t}" } * " "
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|