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.
Files changed (54) hide show
  1. data/.autotest +42 -0
  2. data/.document +5 -0
  3. data/.gitignore +11 -0
  4. data/CHANGELOG +25 -0
  5. data/Gemfile +4 -0
  6. data/Gemfile.lock +44 -0
  7. data/LICENSE +20 -0
  8. data/README.markdown +237 -0
  9. data/Rakefile +32 -0
  10. data/TODO +11 -0
  11. data/VERSION +1 -0
  12. data/astrails-safe.gemspec +37 -0
  13. data/bin/astrails-safe +53 -0
  14. data/examples/example_helper.rb +19 -0
  15. data/lib/astrails/safe.rb +73 -0
  16. data/lib/astrails/safe/archive.rb +24 -0
  17. data/lib/astrails/safe/backup.rb +20 -0
  18. data/lib/astrails/safe/cloudfiles.rb +77 -0
  19. data/lib/astrails/safe/config/builder.rb +60 -0
  20. data/lib/astrails/safe/config/node.rb +76 -0
  21. data/lib/astrails/safe/gpg.rb +46 -0
  22. data/lib/astrails/safe/gzip.rb +25 -0
  23. data/lib/astrails/safe/local.rb +51 -0
  24. data/lib/astrails/safe/mongodump.rb +23 -0
  25. data/lib/astrails/safe/mysqldump.rb +32 -0
  26. data/lib/astrails/safe/pgdump.rb +36 -0
  27. data/lib/astrails/safe/pipe.rb +17 -0
  28. data/lib/astrails/safe/s3.rb +75 -0
  29. data/lib/astrails/safe/sftp.rb +88 -0
  30. data/lib/astrails/safe/sink.rb +35 -0
  31. data/lib/astrails/safe/source.rb +47 -0
  32. data/lib/astrails/safe/stream.rb +20 -0
  33. data/lib/astrails/safe/svndump.rb +13 -0
  34. data/lib/astrails/safe/tmp_file.rb +48 -0
  35. data/lib/astrails/safe/version.rb +5 -0
  36. data/lib/extensions/mktmpdir.rb +45 -0
  37. data/spec/integration/airbrake_integration_spec.rb +76 -0
  38. data/spec/integration/archive_integration_spec.rb +88 -0
  39. data/spec/integration/cleanup_spec.rb +61 -0
  40. data/spec/spec.opts +5 -0
  41. data/spec/spec_helper.rb +13 -0
  42. data/spec/unit/archive_spec.rb +67 -0
  43. data/spec/unit/cloudfiles_spec.rb +177 -0
  44. data/spec/unit/config_spec.rb +234 -0
  45. data/spec/unit/gpg_spec.rb +148 -0
  46. data/spec/unit/gzip_spec.rb +64 -0
  47. data/spec/unit/local_spec.rb +110 -0
  48. data/spec/unit/mongodump_spec.rb +54 -0
  49. data/spec/unit/mysqldump_spec.rb +83 -0
  50. data/spec/unit/pgdump_spec.rb +45 -0
  51. data/spec/unit/s3_spec.rb +163 -0
  52. data/spec/unit/svndump_spec.rb +39 -0
  53. data/templates/script.rb +160 -0
  54. metadata +222 -0
@@ -0,0 +1,46 @@
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
+ command = @config[:gpg, :command] || 'gpg'
13
+ if key
14
+ "|#{command} #{@config[:gpg, :options]} -e -r #{key}"
15
+ elsif password
16
+ "|#{command} #{@config[:gpg, :options]} -c --passphrase-file #{gpg_password_file(password)}"
17
+ end
18
+ end
19
+
20
+ def extension
21
+ ".gpg"
22
+ end
23
+
24
+ def active?
25
+ raise RuntimeError, "can't use both gpg password and pubkey" if key && password
26
+
27
+ !!(password || key)
28
+ end
29
+
30
+ private
31
+
32
+ def password
33
+ @password ||= @config[:gpg, :password]
34
+ end
35
+
36
+ def key
37
+ @key ||= @config[:gpg, :key]
38
+ end
39
+
40
+ def gpg_password_file(pass)
41
+ return "TEMP_GENERATED_FILENAME" if $DRY_RUN
42
+ Astrails::Safe::TmpFile.create("gpg-pass") { |file| file.write(pass) }
43
+ end
44
+ end
45
+ end
46
+ 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,51 @@
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
+ # FIXME: probably need to change this to smth like @backup.finalize!
21
+ @backup.path = full_path # need to do it outside DRY_RUN so that it will be avialable for S3 DRY_RUN
22
+
23
+ unless $DRY_RUN
24
+ FileUtils.mkdir_p(path) unless File.directory?(path)
25
+ benchmark = Benchmark.realtime do
26
+ system "#{@backup.command}>#{@backup.path}"
27
+ end
28
+ puts("command took " + sprintf("%.2f", benchmark) + " second(s).") if $_VERBOSE
29
+ end
30
+
31
+ end
32
+
33
+ def cleanup
34
+ return unless keep = @config[:keep, :local]
35
+
36
+ puts "listing files #{base}" if $_VERBOSE
37
+
38
+ # TODO: cleanup ALL zero-length files
39
+
40
+ files = Dir["#{base}*"] .
41
+ select{|f| File.file?(f) && File.size(f) > 0} .
42
+ sort
43
+
44
+ cleanup_with_limit(files, keep) do |f|
45
+ puts "removing local file #{f}" if $DRY_RUN || $_VERBOSE
46
+ File.unlink(f) unless $DRY_RUN
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,23 @@
1
+ module Astrails
2
+ module Safe
3
+ class Mongodump < Source
4
+
5
+ def command
6
+ opts = []
7
+ opts << "--host #{@config[:host]}" if @config[:host]
8
+ opts << "-u #{@config[:user]}" if @config[:user]
9
+ opts << "-p #{@config[:password]}" if @config[:password]
10
+ opts << "--out #{output_directory}"
11
+
12
+ "mongodump -q \"{xxxx : { \\$ne : 0 } }\" --db #{@id} #{opts.join(" ")} && cd #{output_directory} && tar cf - ."
13
+ end
14
+
15
+ def extension; '.tar'; end
16
+
17
+ protected
18
+ def output_directory
19
+ File.join(TmpFile.tmproot, "mongodump")
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,32 @@
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
+ # values are quoted if needed
19
+ file.puts "#{k} = #{v.inspect}" if v
20
+ end
21
+ end
22
+ end
23
+
24
+ def mysql_skip_tables
25
+ if skip_tables = @config[:skip_tables]
26
+ [*skip_tables].map{ |t| "--ignore-table=#{@id}.#{t}" }.join(" ")
27
+ end
28
+ end
29
+
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,36 @@
1
+ module Astrails
2
+ module Safe
3
+ class Pgdump < Source
4
+
5
+ def command
6
+ if @config["password"]
7
+ ENV['PGPASSWORD'] = @config["password"]
8
+ else
9
+ ENV['PGPASSWORD'] = nil
10
+ end
11
+ "pg_dump #{postgres_options} #{postgres_username} #{postgres_host} #{postgres_port} #{@id}"
12
+ end
13
+
14
+ def extension; '.sql'; end
15
+
16
+ protected
17
+
18
+ def postgres_options
19
+ @config[:options]
20
+ end
21
+
22
+ def postgres_host
23
+ @config["host"] && "--host='#{@config["host"]}'"
24
+ end
25
+
26
+ def postgres_port
27
+ @config["port"] && "--port='#{@config["port"]}'"
28
+ end
29
+
30
+ def postgres_username
31
+ @config["user"] && "--username='#{@config["user"]}'"
32
+ end
33
+
34
+ end
35
+ end
36
+ end
@@ -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,75 @@
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
+ puts "Uploading #{bucket}:#{full_path}" if $_VERBOSE || $DRY_RUN
21
+ unless $DRY_RUN || $LOCAL
22
+ if File.stat(@backup.path).size > MAX_S3_FILE_SIZE
23
+ STDERR.puts "ERROR: File size exceeds maximum allowed for upload to S3 (#{MAX_S3_FILE_SIZE}): #{@backup.path}"
24
+ return
25
+ end
26
+ benchmark = Benchmark.realtime do
27
+ File.open(@backup.path) do |file|
28
+ remote_bucket.objects.create(full_path, :data => file)
29
+ end
30
+ end
31
+ puts "...done" if $_VERBOSE
32
+ puts("Upload took " + sprintf("%.2f", benchmark) + " second(s).") if $_VERBOSE
33
+ end
34
+ end
35
+
36
+ def cleanup
37
+ return if $LOCAL
38
+
39
+ return unless keep = @config[:keep, :s3]
40
+
41
+ puts "listing files: #{bucket}:#{base}*" if $_VERBOSE
42
+ files = remote_bucket.objects.with_prefix(:prefix => base)
43
+ puts files.collect {|x| x.key} if $_VERBOSE
44
+
45
+ files = files.sort { |x,y| x.key <=> y.key }
46
+
47
+ cleanup_with_limit(files, keep) do |f|
48
+ puts "removing s3 file #{bucket}:#{f}" if $DRY_RUN || $_VERBOSE
49
+ f.delete unless $DRY_RUN || $LOCAL
50
+ end
51
+ end
52
+
53
+ def remote_bucket
54
+ unless @remote_bucket
55
+ s3 = AWS::S3.new(:access_key_id => key, :secret_access_key => secret)
56
+ @remote_bucket = s3.buckets.create(bucket)
57
+ end
58
+ @remote_bucket
59
+ end
60
+
61
+ def bucket
62
+ @config[:s3, :bucket]
63
+ end
64
+
65
+ def key
66
+ @config[:s3, :key]
67
+ end
68
+
69
+ def secret
70
+ @config[:s3, :secret]
71
+ end
72
+
73
+ end
74
+ end
75
+ 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
22
+ @full_path ||= File.join(path, @backup.filename) + @backup.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
+