webtranslateit-safe 0.4.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.
Files changed (59) hide show
  1. checksums.yaml +7 -0
  2. data/.autotest +3 -0
  3. data/.document +5 -0
  4. data/.github/dependabot.yml +26 -0
  5. data/.github/release-drafter.yml +36 -0
  6. data/.github/workflows/ci.yml +51 -0
  7. data/.github/workflows/release-drafter.yml +29 -0
  8. data/.gitignore +18 -0
  9. data/.rspec +3 -0
  10. data/.rubocop.yml +8 -0
  11. data/.rubocop_todo.yml +552 -0
  12. data/CHANGELOG +42 -0
  13. data/Gemfile +11 -0
  14. data/Gemfile.lock +89 -0
  15. data/LICENSE.txt +22 -0
  16. data/README.markdown +237 -0
  17. data/Rakefile +8 -0
  18. data/TODO +31 -0
  19. data/bin/webtranslateit-safe +64 -0
  20. data/lib/extensions/mktmpdir.rb +45 -0
  21. data/lib/webtranslateit/safe/archive.rb +29 -0
  22. data/lib/webtranslateit/safe/backup.rb +27 -0
  23. data/lib/webtranslateit/safe/cloudfiles.rb +77 -0
  24. data/lib/webtranslateit/safe/config/builder.rb +100 -0
  25. data/lib/webtranslateit/safe/config/node.rb +79 -0
  26. data/lib/webtranslateit/safe/ftp.rb +85 -0
  27. data/lib/webtranslateit/safe/gpg.rb +52 -0
  28. data/lib/webtranslateit/safe/gzip.rb +29 -0
  29. data/lib/webtranslateit/safe/local.rb +55 -0
  30. data/lib/webtranslateit/safe/mongodump.rb +30 -0
  31. data/lib/webtranslateit/safe/mysqldump.rb +36 -0
  32. data/lib/webtranslateit/safe/pgdump.rb +36 -0
  33. data/lib/webtranslateit/safe/pipe.rb +23 -0
  34. data/lib/webtranslateit/safe/s3.rb +80 -0
  35. data/lib/webtranslateit/safe/sftp.rb +96 -0
  36. data/lib/webtranslateit/safe/sink.rb +40 -0
  37. data/lib/webtranslateit/safe/source.rb +51 -0
  38. data/lib/webtranslateit/safe/stream.rb +40 -0
  39. data/lib/webtranslateit/safe/svndump.rb +17 -0
  40. data/lib/webtranslateit/safe/tmp_file.rb +53 -0
  41. data/lib/webtranslateit/safe/version.rb +9 -0
  42. data/lib/webtranslateit/safe.rb +70 -0
  43. data/spec/integration/archive_integration_spec.rb +89 -0
  44. data/spec/integration/cleanup_spec.rb +62 -0
  45. data/spec/spec_helper.rb +7 -0
  46. data/spec/webtranslateit/safe/archive_spec.rb +67 -0
  47. data/spec/webtranslateit/safe/cloudfiles_spec.rb +175 -0
  48. data/spec/webtranslateit/safe/config_spec.rb +307 -0
  49. data/spec/webtranslateit/safe/gpg_spec.rb +148 -0
  50. data/spec/webtranslateit/safe/gzip_spec.rb +64 -0
  51. data/spec/webtranslateit/safe/local_spec.rb +109 -0
  52. data/spec/webtranslateit/safe/mongodump_spec.rb +54 -0
  53. data/spec/webtranslateit/safe/mysqldump_spec.rb +83 -0
  54. data/spec/webtranslateit/safe/pgdump_spec.rb +45 -0
  55. data/spec/webtranslateit/safe/s3_spec.rb +168 -0
  56. data/spec/webtranslateit/safe/svndump_spec.rb +39 -0
  57. data/templates/script.rb +183 -0
  58. data/webtranslateit-safe.gemspec +32 -0
  59. metadata +149 -0
@@ -0,0 +1,79 @@
1
+ require 'webtranslateit/safe/config/builder'
2
+ module WebTranslateIt
3
+
4
+ module Safe
5
+
6
+ module Config
7
+
8
+ class Node
9
+
10
+ attr_reader :parent, :data
11
+
12
+ def initialize(parent = nil, data = {}, &)
13
+ @parent = parent
14
+ @data = {}
15
+ merge(data, &)
16
+ end
17
+
18
+ def merge(data = {}, &block)
19
+ builder = Builder.new(self, data)
20
+ builder.instance_eval(&block) if block
21
+ self
22
+ end
23
+
24
+ # looks for the path from this node DOWN. will not delegate to parent
25
+ def get(*path)
26
+ key = path.shift
27
+ value = @data[key.to_s]
28
+ return value if !value.nil? && path.empty?
29
+
30
+ value&.get(*path)
31
+ end
32
+
33
+ # recursive find
34
+ # starts at the node and continues to the parent
35
+ def find(*path)
36
+ get(*path) || @parent&.find(*path)
37
+ end
38
+ alias [] find
39
+
40
+ def set_multi(key, value)
41
+ @data[key.to_s] ||= []
42
+ @data[key.to_s].push(*value)
43
+ end
44
+
45
+ def set(key, value)
46
+ @data[key.to_s] = value
47
+ end
48
+ alias []= set
49
+
50
+ def each(&)
51
+ @data.each(&)
52
+ end
53
+ include Enumerable
54
+
55
+ def to_hash
56
+ @data.keys.each_with_object({}) do |key, res|
57
+ value = @data[key]
58
+ res[key] = value.is_a?(Node) ? value.to_hash : value
59
+ end
60
+ end
61
+
62
+ def dump(indent = '')
63
+ @data.each do |key, value|
64
+ if value.is_a?(Node)
65
+ puts "#{indent}#{key}:"
66
+ value.dump("#{indent} ")
67
+ else
68
+ puts "#{indent}#{key}: #{value.inspect}"
69
+ end
70
+ end
71
+ end
72
+
73
+ end
74
+
75
+ end
76
+
77
+ end
78
+
79
+ end
@@ -0,0 +1,85 @@
1
+ module WebTranslateIt
2
+
3
+ module Safe
4
+
5
+ class Ftp < Sink
6
+
7
+ protected
8
+
9
+ def active?
10
+ host && user
11
+ end
12
+
13
+ def path
14
+ @path ||= expand(config[:ftp, :path] || config[:local, :path] || ':kind/:id')
15
+ end
16
+
17
+ def save
18
+ raise 'pipe-streaming not supported for FTP.' unless @backup.path
19
+
20
+ puts "Uploading #{host}:#{full_path} via FTP" if verbose? || dry_run?
21
+
22
+ return if dry_run? || local_only?
23
+
24
+ port ||= 21
25
+ Net::FTP.open(host) do |ftp|
26
+ ftp.connect(host, port)
27
+ ftp.login(user, password)
28
+ puts "Sending #{@backup.path} to #{full_path}" if verbose?
29
+ begin
30
+ ftp.put(@backup.path, full_path)
31
+ rescue Net::FTPPermError
32
+ puts "Ensuring remote path (#{path}) exists" if verbose?
33
+ end
34
+ end
35
+ puts '...done' if verbose?
36
+ end
37
+
38
+ def cleanup
39
+ return if local_only? || dry_run?
40
+
41
+ return unless (keep = config[:keep, :ftp])
42
+
43
+ puts "listing files: #{host}:#{base}*" if verbose?
44
+ port ||= 21
45
+ Net::FTP.open(host) do |ftp|
46
+ ftp.connect(host, port)
47
+ ftp.login(user, password)
48
+ files = ftp.nlst(path)
49
+ pattern = File.basename(base.to_s)
50
+ files = files.select { |x| x.start_with?(pattern) }
51
+ puts(files.collect { |x| x }) if verbose?
52
+
53
+ files = files
54
+ .collect { |x| x }
55
+ .sort
56
+
57
+ cleanup_with_limit(files, keep) do |f|
58
+ file = File.join(path, f)
59
+ puts "removing ftp file #{host}:#{file}" if dry_run? || verbose?
60
+ ftp.delete(file) unless dry_run? || local_only?
61
+ end
62
+ end
63
+ end
64
+
65
+ def host
66
+ config[:ftp, :host]
67
+ end
68
+
69
+ def user
70
+ config[:ftp, :user]
71
+ end
72
+
73
+ def password
74
+ config[:ftp, :password]
75
+ end
76
+
77
+ def port
78
+ config[:ftp, :port]
79
+ end
80
+
81
+ end
82
+
83
+ end
84
+
85
+ end
@@ -0,0 +1,52 @@
1
+ module WebTranslateIt
2
+
3
+ module Safe
4
+
5
+ class Gpg < Pipe
6
+
7
+ def active?
8
+ raise "can't use both gpg password and pubkey" if key && password
9
+
10
+ !!(password || key)
11
+ end
12
+
13
+ protected
14
+
15
+ def post_process
16
+ @backup.compressed = true
17
+ end
18
+
19
+ def pipe
20
+ command = config[:gpg, :command] || 'gpg'
21
+ if key
22
+ "|#{command} #{config[:gpg, :options]} -e -r #{key}"
23
+ elsif password
24
+ "|#{command} #{config[:gpg, :options]} -c --passphrase-file #{gpg_password_file(password)}"
25
+ end
26
+ end
27
+
28
+ def extension
29
+ '.gpg'
30
+ end
31
+
32
+ private
33
+
34
+ def password
35
+ @password ||= config[:gpg, :password]
36
+ end
37
+
38
+ def key
39
+ @key ||= config[:gpg, :key]
40
+ end
41
+
42
+ def gpg_password_file(pass)
43
+ return 'TEMP_GENERATED_FILENAME' if dry_run?
44
+
45
+ WebTranslateIt::Safe::TmpFile.create('gpg-pass') { |file| file.write(pass) }
46
+ end
47
+
48
+ end
49
+
50
+ end
51
+
52
+ end
@@ -0,0 +1,29 @@
1
+ module WebTranslateIt
2
+
3
+ module Safe
4
+
5
+ class Gzip < Pipe
6
+
7
+ protected
8
+
9
+ def post_process
10
+ @backup.compressed = true
11
+ end
12
+
13
+ def pipe
14
+ '|gzip'
15
+ end
16
+
17
+ def extension
18
+ '.gz'
19
+ end
20
+
21
+ def active?
22
+ !@backup.compressed
23
+ end
24
+
25
+ end
26
+
27
+ end
28
+
29
+ end
@@ -0,0 +1,55 @@
1
+ module WebTranslateIt
2
+
3
+ module Safe
4
+
5
+ class Local < Sink
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
+ protected
14
+
15
+ def path
16
+ @path ||= File.expand_path(expand(config[:local, :path] || raise('missing :local/:path')))
17
+ end
18
+
19
+ def save
20
+ puts "command: #{@backup.command}" if verbose?
21
+
22
+ # FIXME: probably need to change this to smth like @backup.finalize!
23
+ @backup.path = full_path # need to do it outside DRY_RUN so that it will be avialable for S3 DRY_RUN
24
+
25
+ return if dry_run?
26
+
27
+ FileUtils.mkdir_p(path) unless File.directory?(path)
28
+ benchmark = Benchmark.realtime do
29
+ system "#{@backup.command}>#{@backup.path}"
30
+ end
31
+ puts("command took #{format('%.2f', benchmark)} second(s).") if verbose?
32
+ end
33
+
34
+ def cleanup
35
+ return unless (keep = config[:keep, :local])
36
+
37
+ puts "listing files #{base}" if verbose?
38
+
39
+ # TODO: cleanup ALL zero-length files
40
+
41
+ files = Dir["#{base}*"]
42
+ .select { |f| File.file?(f) && File.size(f).positive? }
43
+ .sort
44
+
45
+ cleanup_with_limit(files, keep) do |f|
46
+ puts "removing local file #{f}" if dry_run? || verbose?
47
+ File.unlink(f) unless dry_run?
48
+ end
49
+ end
50
+
51
+ end
52
+
53
+ end
54
+
55
+ end
@@ -0,0 +1,30 @@
1
+ module WebTranslateIt
2
+
3
+ module Safe
4
+
5
+ class Mongodump < Source
6
+
7
+
8
+ def command
9
+ opts = []
10
+ opts << "--host #{config[:host]}" if config[:host]
11
+ opts << "-u #{config[:user]}" if config[:user]
12
+ opts << "-p #{config[:password]}" if config[:password]
13
+ opts << "--out #{output_directory}"
14
+
15
+ "mongodump -q \"{xxxx : { \\$ne : 0 } }\" --db #{@id} #{opts.join(' ')} && cd #{output_directory} && tar cf - ."
16
+ end
17
+
18
+ def extension = '.tar'
19
+
20
+ protected
21
+
22
+ def output_directory
23
+ File.join(TmpFile.tmproot, 'mongodump')
24
+ end
25
+
26
+ end
27
+
28
+ end
29
+
30
+ end
@@ -0,0 +1,36 @@
1
+ module WebTranslateIt
2
+
3
+ module Safe
4
+
5
+ class Mysqldump < Source
6
+
7
+ def command
8
+ "mysqldump --defaults-extra-file=#{mysql_password_file} #{config[:options]} #{mysql_skip_tables} #{@id}"
9
+ end
10
+
11
+ def extension = '.sql'
12
+
13
+ protected
14
+
15
+ def mysql_password_file
16
+ WebTranslateIt::Safe::TmpFile.create('mysqldump') do |file|
17
+ file.puts '[mysqldump]'
18
+ %w[user password socket host port].each do |k|
19
+ v = config[k]
20
+ # values are quoted if needed
21
+ file.puts "#{k} = #{v.inspect}" if v
22
+ end
23
+ end
24
+ end
25
+
26
+ def mysql_skip_tables
27
+ return unless (skip_tables = config[:skip_tables])
28
+
29
+ [*skip_tables].map { |t| "--ignore-table=#{@id}.#{t}" }.join(' ')
30
+ end
31
+
32
+ end
33
+
34
+ end
35
+
36
+ end
@@ -0,0 +1,36 @@
1
+ module WebTranslateIt
2
+
3
+ module Safe
4
+
5
+ class Pgdump < Source
6
+
7
+ def command
8
+ ENV['PGPASSWORD'] = (config['password'] || nil)
9
+ "pg_dump #{postgres_options} #{postgres_username} #{postgres_host} #{postgres_port} #{@id}"
10
+ end
11
+
12
+ def extension = '.sql'
13
+
14
+ protected
15
+
16
+ def postgres_options
17
+ config[:options]
18
+ end
19
+
20
+ def postgres_host
21
+ config['host'] && "--host='#{config['host']}'"
22
+ end
23
+
24
+ def postgres_port
25
+ config['port'] && "--port='#{config['port']}'"
26
+ end
27
+
28
+ def postgres_username
29
+ config['user'] && "--username='#{config['user']}'"
30
+ end
31
+
32
+ end
33
+
34
+ end
35
+
36
+ end
@@ -0,0 +1,23 @@
1
+ module WebTranslateIt
2
+
3
+ module Safe
4
+
5
+ class Pipe < Stream
6
+
7
+ # process adds required commands to the current
8
+ # shell command string
9
+ # :active?, :pipe, :extension and :post_process are
10
+ # defined in inheriting pipe classes
11
+ def process
12
+ return unless active?
13
+
14
+ @backup.command << pipe
15
+ @backup.extension << extension
16
+ post_process
17
+ end
18
+
19
+ end
20
+
21
+ end
22
+
23
+ end
@@ -0,0 +1,80 @@
1
+ module WebTranslateIt
2
+ module Safe
3
+ class S3 < Sink
4
+ MAX_S3_FILE_SIZE = 5368709120
5
+
6
+ def active?
7
+ bucket && key && secret
8
+ end
9
+
10
+ protected
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_only?
22
+
23
+ puts "Uploading #{bucket}:#{full_path}" if verbose? || dry_run?
24
+ unless dry_run? || local_only?
25
+ if File.stat(@backup.path).size > MAX_S3_FILE_SIZE
26
+ STDERR.puts "ERROR: File size exceeds maximum allowed for upload to S3 (#{MAX_S3_FILE_SIZE}): #{@backup.path}"
27
+ return
28
+ end
29
+ benchmark = Benchmark.realtime do
30
+ AWS::S3::Bucket.create(bucket) unless bucket_exists?(bucket)
31
+ File.open(@backup.path) do |file|
32
+ AWS::S3::S3Object.store(full_path, file, bucket)
33
+ end
34
+ end
35
+ puts '...done' if verbose?
36
+ puts('Upload took ' + sprintf('%.2f', benchmark) + ' second(s).') if verbose?
37
+ end
38
+ end
39
+
40
+ def cleanup
41
+ return if local_only?
42
+
43
+ return unless keep = config[:keep, :s3]
44
+
45
+ puts "listing files: #{bucket}:#{base}*" if verbose?
46
+ files = AWS::S3::Bucket.objects(bucket, :prefix => base, :max_keys => keep * 2)
47
+ puts files.collect {|x| x.key} if verbose?
48
+
49
+ files = files.
50
+ collect {|x| x.key}.
51
+ sort
52
+
53
+ cleanup_with_limit(files, keep) do |f|
54
+ puts "removing s3 file #{bucket}:#{f}" if dry_run? || verbose?
55
+ AWS::S3::Bucket.objects(bucket, :prefix => f)[0].delete unless dry_run? || local_only?
56
+ end
57
+ end
58
+
59
+ def bucket
60
+ config[:s3, :bucket]
61
+ end
62
+
63
+ def key
64
+ config[:s3, :key]
65
+ end
66
+
67
+ def secret
68
+ config[:s3, :secret]
69
+ end
70
+
71
+ private
72
+
73
+ def bucket_exists?(bucket)
74
+ true if AWS::S3::Bucket.find(bucket)
75
+ rescue AWS::S3::NoSuchBucket
76
+ false
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,96 @@
1
+ module WebTranslateIt
2
+
3
+ module Safe
4
+
5
+ class Sftp < Sink
6
+
7
+ protected
8
+
9
+ def active?
10
+ host && user
11
+ end
12
+
13
+ def path
14
+ @path ||= expand(config[:sftp, :path] || config[:local, :path] || ':kind/:id')
15
+ end
16
+
17
+ def save
18
+ raise 'pipe-streaming not supported for SFTP.' unless @backup.path
19
+
20
+ puts "Uploading #{host}:#{full_path} via SFTP" if verbose? || dry_run?
21
+
22
+ return if dry_run? || local_only?
23
+
24
+ opts = {}
25
+ opts[:password] = password if password
26
+ opts[:port] = port if port
27
+ Net::SFTP.start(host, user, opts) do |sftp|
28
+ puts "Sending #{@backup.path} to #{full_path}" if verbose?
29
+ begin
30
+ sftp.upload! @backup.path, full_path
31
+ rescue Net::SFTP::StatusException
32
+ puts "Ensuring remote path (#{path}) exists" if verbose?
33
+ # mkdir -p
34
+ folders = path.split('/')
35
+ folders.each_index do |i|
36
+ folder = folders[0..i].join('/')
37
+ puts "Creating #{folder} on remote" if verbose?
38
+ begin
39
+ sftp.mkdir!(folder)
40
+ rescue StandardError
41
+ Net::SFTP::StatusException
42
+ end
43
+ end
44
+ retry
45
+ end
46
+ end
47
+ puts '...done' if verbose?
48
+ end
49
+
50
+ def cleanup
51
+ return if local_only? || dry_run?
52
+
53
+ return unless (keep = config[:keep, :sftp])
54
+
55
+ puts "listing files: #{host}:#{base}*" if verbose?
56
+ opts = {}
57
+ opts[:password] = password if password
58
+ opts[:port] = port if port
59
+ Net::SFTP.start(host, user, opts) do |sftp|
60
+ files = sftp.dir.glob(path, File.basename("#{base}*"))
61
+
62
+ puts(files.collect(&:name)) if verbose?
63
+
64
+ files = files
65
+ .collect(&:name)
66
+ .sort
67
+
68
+ cleanup_with_limit(files, keep) do |f|
69
+ file = File.join(path, f)
70
+ puts "removing sftp file #{host}:#{file}" if dry_run? || verbose?
71
+ sftp.remove!(file) unless dry_run? || local_only?
72
+ end
73
+ end
74
+ end
75
+
76
+ def host
77
+ config[:sftp, :host]
78
+ end
79
+
80
+ def user
81
+ config[:sftp, :user]
82
+ end
83
+
84
+ def password
85
+ config[:sftp, :password]
86
+ end
87
+
88
+ def port
89
+ config[:sftp, :port]
90
+ end
91
+
92
+ end
93
+
94
+ end
95
+
96
+ end
@@ -0,0 +1,40 @@
1
+ module WebTranslateIt
2
+
3
+ module Safe
4
+
5
+ class Sink < Stream
6
+
7
+ def process
8
+ return unless active?
9
+
10
+ save
11
+ cleanup
12
+ end
13
+
14
+ protected
15
+
16
+ # path is defined in subclass
17
+ # base is used in 'cleanup' to find all files that begin with base. the '.'
18
+ # at the end is essential to distinguish b/w foo.* and foobar.* archives for example
19
+ def base
20
+ @base ||= File.join(path, "#{File.basename(@backup.filename).split('.').first}.")
21
+ end
22
+
23
+ def full_path
24
+ @full_path ||= File.join(path, @backup.filename) + @backup.extension
25
+ end
26
+
27
+ # call block on files to be removed (all except for the LAST 'limit' files
28
+ def cleanup_with_limit(files, limit, &)
29
+ return unless files.size > limit
30
+
31
+ to_remove = files[0..(files.size - limit - 1)]
32
+ # TODO: validate here
33
+ to_remove.each(&)
34
+ end
35
+
36
+ end
37
+
38
+ end
39
+
40
+ end
@@ -0,0 +1,51 @@
1
+ module WebTranslateIt
2
+
3
+ module Safe
4
+
5
+ class Source < Stream
6
+
7
+ attr_accessor :id
8
+
9
+ def initialize(id, config)
10
+ @id = id.to_s
11
+ @config = config
12
+ end
13
+
14
+ def timestamp
15
+ Time.now.strftime('%y%m%d-%H%M')
16
+ end
17
+
18
+ def kind
19
+ self.class.human_name
20
+ end
21
+
22
+ def filename
23
+ @filename ||= expand(':kind-:id.:timestamp')
24
+ end
25
+
26
+ def backup
27
+ return @backup if @backup
28
+
29
+ @backup = Backup.new(
30
+ id: @id,
31
+ kind: kind,
32
+ extension: extension,
33
+ command: command,
34
+ timestamp: timestamp
35
+ )
36
+ # can't do this in the initializer hash above since
37
+ # filename() calls expand() which requires @backup
38
+ # FIXME: move expansion to the backup (last step in ctor) assign :tags here
39
+ @backup.filename = filename
40
+ @backup
41
+ end
42
+
43
+ def self.human_name
44
+ name.split('::').last.downcase
45
+ end
46
+
47
+ end
48
+
49
+ end
50
+
51
+ end