backupper 0.6.1 → 0.7.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1dd44245219b3c9f3a84381ec2be375ad988d94ba7f33d90c248510bf88b6c6a
4
- data.tar.gz: 2b24cf425b081d25dbed494d5200322ed982472fb76eb74b90bee7c89a527984
3
+ metadata.gz: 6bcd9c73d5e46de14f981d0fc742f5142c9f66d5c4553ebe19face5c98d95626
4
+ data.tar.gz: '0708bc10f83d92061a9c16febaf56319b5bba9eed397529f7ce0bc00ec1e7bcc'
5
5
  SHA512:
6
- metadata.gz: 6a6f0feda34408f60ff4a7353a0556dad3d79030b3cd2e5d0a5b90b06b562938631456ab60682c967ed91728f20e5fd39c2ab4e52d95311b413d1af8f36d51ba
7
- data.tar.gz: a6d9390747c3cdd90bede2c7c83a3adb8af97aa05009cadffdbed0b1f523ea798c504c8ce4a1f45f1e15787182432b5d031a8256eb61d1a9476be4f3a9f0bf1d
6
+ metadata.gz: 77c91c30b33c1ea38a07537027e021f6be9ecd91878858701caaf452321d1a8244551360327f6e80d412996833cf69402021eb5d07b693fb6af4f1ed3491b79f
7
+ data.tar.gz: c56d8476dd7b7eea3f4fcc307a1311020630b132a564159759d763d0ea8b3ac180c2582c9c7f808a424ff6e32c57436974fa79eca1d2e8a4588adb24eca0d516
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- backupper (0.6.1)
4
+ backupper (0.7.0)
5
5
  mail (~> 2.9)
6
6
  sshkit (~> 1.25)
7
7
 
data/README.md CHANGED
@@ -33,7 +33,7 @@ db1:
33
33
  port: 22 # server ssh port
34
34
  password: Pa$$w0rD # server ssh password (also used as sudo password when sudo is true)
35
35
  sudo: false # true to run the dump command with sudo
36
- adapter: mysql # adapter to use to perform the backup (mysql, postgresql or mydumper)
36
+ adapter: mysql # adapter to use to perform the backup (built-in: mysql, postgresql, mydumper — see "Custom adapters")
37
37
  database: db_name # database name
38
38
  db_username: db_user # database username
39
39
  db_password: db_Pa$$w0rD # database password
@@ -62,15 +62,44 @@ ignoring the others configuration keys.
62
62
 
63
63
  ## Custom adapters
64
64
 
65
- An adapter is just a module method of `DumpCommand` that returns the shell command used to dump the database:
65
+ An adapter is a subclass of `Backupper::Adapter` that declares the extension of the file it produces and builds the shell command used to dump the database:
66
66
 
67
67
  ```ruby
68
- def self.myadapter(database:, username: 'root', password: nil, dump_options: nil, working_dir: nil, outfile:)
69
- return "mydump #{database} -u #{username} > #{outfile}"
68
+ require 'backupper'
69
+
70
+ class Foodump < Backupper::Adapter
71
+ # Automatically registered as 'foodump' (the underscored class name).
72
+ # Uncomment to use a different name for the `adapter:` configuration key:
73
+ # register 'foo'
74
+
75
+ def extension
76
+ return '.sql.gz'
77
+ end
78
+
79
+ def command
80
+ params = []
81
+ params << "-u #{username}"
82
+ params << "-p #{password}" if password
83
+ params << dump_options if dump_options
84
+ return "foodump #{database} #{params.join(' ')} | gzip > #{outfile}"
85
+ end
70
86
  end
71
87
  ```
72
88
 
73
- All the values arrive already shell-escaped: interpolate them as they are, without wrapping them in quotes. `dump_options` is parsed as shell-style arguments, then each argument is escaped before it reaches the adapter. The command must write the dump to `outfile`; `working_dir` is an empty temporary directory you can use as scratch space (both are removed from the server after the download). If your adapter produces a file format different from `.sql.bz2`, add its extension to `DumpCommand::EXTENSIONS`.
89
+ Inside `command` you never deal with shell escaping: every accessor already returns a shell-escaped value, so interpolate them directly, without wrapping them in quotes.
90
+
91
+ | accessor | value |
92
+ |----------------|--------------------------------------------------------------------------|
93
+ | `database` | database name |
94
+ | `username` | database username |
95
+ | `password` | database password, or `nil` if not configured |
96
+ | `dump_options` | extra options as a single escaped string, or `nil` if not configured |
97
+ | `outfile` | remote path where the command must write the dump (extension included) |
98
+ | `working_dir` | empty temporary directory you can use as scratch space |
99
+
100
+ `command` must return a single command string (it is run with `set -o pipefail`, so chaining with `|` and `&&` is fine) and must write the dump to `outfile`. `working_dir` is created before the command runs; both `working_dir` and `outfile` are removed from the server after the download. If two adapters register the same name, the last one loaded wins, so a custom adapter can also replace a built-in one.
101
+
102
+ ⚠️ Upgrading from 0.6: custom adapters written as `DumpCommand` module methods no longer work; port each method to a `Backupper::Adapter` subclass as shown above. Also, `mydumper` archives now store the dump under its full remote path (`tmp/<key>__<database>/...`) instead of a top-level `<key>__<database>/` directory.
74
103
 
75
104
  ## Contributing
76
105
 
@@ -0,0 +1,118 @@
1
+ require 'shellwords'
2
+
3
+ class Backupper
4
+ # Base class for dump adapters. A subclass must implement `#extension` and
5
+ # `#command`; it is automatically registered under its underscored class
6
+ # name (use `register 'name'` to pick a different one).
7
+ class Adapter
8
+ @subclasses = []
9
+
10
+ class << self
11
+ def inherited(subclass)
12
+ super
13
+ Backupper::Adapter.subclasses << subclass
14
+ end
15
+
16
+ def register(name)
17
+ @adapter_name = name.to_s
18
+ end
19
+
20
+ def adapter_name
21
+ return @adapter_name if defined?(@adapter_name) && @adapter_name
22
+ # On Ruby < 3.0 `inherited` fires before the constant is assigned, so
23
+ # the name must be derived lazily, at lookup time.
24
+ return nil if name.nil?
25
+
26
+ basename = name.split('::').last
27
+ return basename.gsub(/([A-Z\d]+)([A-Z][a-z])/, '\1_\2').gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
28
+ end
29
+
30
+ def find(name)
31
+ # Reverse scan: a later-loaded custom adapter can shadow a built-in name.
32
+ return Backupper::Adapter.subclasses.reverse_each.find { |klass| klass.adapter_name == name.to_s }
33
+ end
34
+
35
+ def names
36
+ return Backupper::Adapter.subclasses.map(&:adapter_name).compact.uniq.sort
37
+ end
38
+
39
+ def subclasses
40
+ return @subclasses ||= []
41
+ end
42
+
43
+ def shellescape_dump_options(dump_options)
44
+ return nil if dump_options.nil?
45
+
46
+ options =
47
+ if dump_options.is_a?(Array)
48
+ dump_options.map(&:to_s)
49
+ else
50
+ Shellwords.split(dump_options.to_s)
51
+ end
52
+ return nil if options.empty?
53
+
54
+ return options.map { |option| Shellwords.escape(option) }.join(' ')
55
+ rescue ArgumentError => e
56
+ raise ArgumentError, "Invalid dump_options: #{e.message}"
57
+ end
58
+ end
59
+
60
+ # All values arrive raw; escaping happens in the accessors below.
61
+ def initialize(database:, username: 'root', password: nil, dump_options: nil, basename:, tmp_dir: '/tmp')
62
+ @raw_database = database
63
+ @raw_username = username
64
+ @raw_password = password
65
+ @raw_dump_options = dump_options
66
+ @raw_basename = basename
67
+ @raw_tmp_dir = tmp_dir
68
+ end
69
+
70
+ # Extension of the file produced by `#command` (e.g. '.sql.bz2').
71
+ def extension
72
+ raise NotImplementedError, "#{self.class} must implement #extension"
73
+ end
74
+
75
+ # Shell command that writes the dump to `outfile`.
76
+ def command
77
+ raise NotImplementedError, "#{self.class} must implement #command"
78
+ end
79
+
80
+ # Raw paths, for the orchestrator (SSHKit download!, report).
81
+ def filename
82
+ return "#{@raw_basename}#{extension}"
83
+ end
84
+
85
+ def outfile_path
86
+ return File.join(@raw_tmp_dir, filename)
87
+ end
88
+
89
+ def working_dir_path
90
+ return File.join(@raw_tmp_dir, @raw_basename)
91
+ end
92
+
93
+ # Shell-escaped accessors: interpolate them directly in `#command`.
94
+ def database
95
+ return Shellwords.escape(@raw_database)
96
+ end
97
+
98
+ def username
99
+ return Shellwords.escape(@raw_username)
100
+ end
101
+
102
+ def password
103
+ return @raw_password && Shellwords.escape(@raw_password)
104
+ end
105
+
106
+ def dump_options
107
+ return Backupper::Adapter.shellescape_dump_options(@raw_dump_options)
108
+ end
109
+
110
+ def outfile
111
+ return Shellwords.escape(outfile_path)
112
+ end
113
+
114
+ def working_dir
115
+ return Shellwords.escape(working_dir_path)
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,19 @@
1
+ require_relative '../adapter'
2
+
3
+ class Backupper
4
+ module Adapters
5
+ class Mydumper < Backupper::Adapter
6
+ def extension
7
+ return '.tar'
8
+ end
9
+
10
+ def command
11
+ params = []
12
+ params << "--user #{username}"
13
+ params << "--password #{password}" if password
14
+ params << dump_options if dump_options
15
+ return "mydumper --database #{database} #{params.join(' ')} --outputdir #{working_dir} && tar -cf #{outfile} #{working_dir}"
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,19 @@
1
+ require_relative '../adapter'
2
+
3
+ class Backupper
4
+ module Adapters
5
+ class Mysql < Backupper::Adapter
6
+ def extension
7
+ return '.sql.bz2'
8
+ end
9
+
10
+ def command
11
+ params = []
12
+ params << "-u#{username}"
13
+ params << "-p#{password}" if password
14
+ params << dump_options if dump_options
15
+ return "mysqldump #{database} #{params.join(' ')} | bzip2 > #{outfile}"
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,18 @@
1
+ require_relative '../adapter'
2
+
3
+ class Backupper
4
+ module Adapters
5
+ class Postgresql < Backupper::Adapter
6
+ def extension
7
+ return '.sql.bz2'
8
+ end
9
+
10
+ def command
11
+ params = []
12
+ params << "-U #{username}"
13
+ params << dump_options if dump_options
14
+ return "PGPASSWORD=#{password} pg_dump #{database} #{params.join(' ')} | bzip2 > #{outfile}"
15
+ end
16
+ end
17
+ end
18
+ end
@@ -1,10 +1,12 @@
1
1
  require 'fileutils'
2
- require 'shellwords'
3
2
  require 'sshkit'
4
3
  require 'sshkit/dsl'
5
4
  require 'yaml'
6
5
 
7
- require_relative 'dump_command'
6
+ require_relative 'adapter'
7
+ require_relative 'adapters/mysql'
8
+ require_relative 'adapters/postgresql'
9
+ require_relative 'adapters/mydumper'
8
10
  require_relative 'mailer'
9
11
  require_relative 'sshkit'
10
12
 
@@ -60,7 +62,7 @@ class Backupper
60
62
  outdir: o['outdir'],
61
63
  extra_copy: o['extra_copy']
62
64
  )
63
- rescue SSHKit::Runner::ExecuteError => e
65
+ rescue SSHKit::Runner::ExecuteError, ArgumentError, NotImplementedError => e
64
66
  error(k, e.to_s)
65
67
  @logger.error e
66
68
  end
@@ -72,30 +74,23 @@ class Backupper
72
74
 
73
75
  def do_and_download_dump(key:, adapter: 'mysql', url:, password: nil, database:, db_username: 'root', db_password: nil, dump_options: nil, sudo: false, outdir:, extra_copy: nil)
74
76
  t = Process.clock_gettime(Process::CLOCK_MONOTONIC)
75
- filename = "#{key}__#{database}#{DumpCommand::EXTENSIONS[adapter]}"
76
- temp_file = File.join('/tmp', filename)
77
- working_dirname = "#{key}__#{database}"
78
- temp_working_dir = File.join('/tmp', working_dirname)
79
- dump_name = "#{Time.now.strftime('%Y-%m-%d_%H-%M-%S')}__#{filename}"
77
+ adapter = Adapter.find(adapter).new(
78
+ database: database,
79
+ username: db_username,
80
+ password: db_password,
81
+ dump_options: dump_options,
82
+ basename: "#{key}__#{database}"
83
+ )
84
+ dump_name = "#{Time.now.strftime('%Y-%m-%d_%H-%M-%S')}__#{adapter.filename}"
80
85
  path = File.join(outdir, dump_name)
81
- outfile = Shellwords.escape(temp_file)
82
- working_dir = Shellwords.escape(temp_working_dir)
86
+ command = adapter.command
83
87
  on(url) do |host|
84
88
  host.password = password
85
- sudo_execute :mkdir, '-p', working_dir, password: password, sudo: sudo
86
- command = DumpCommand.public_send(
87
- adapter,
88
- database: Shellwords.escape(database),
89
- username: Shellwords.escape(db_username),
90
- password: db_password && Shellwords.escape(db_password),
91
- dump_options: dump_options,
92
- working_dir: working_dir,
93
- outfile: outfile
94
- )
89
+ sudo_execute :mkdir, '-p', adapter.working_dir, password: password, sudo: sudo
95
90
  sudo_execute 'set -o pipefail; ' + command, password: password, sudo: sudo
96
- download! temp_file, path
97
- sudo_execute :rm, outfile, password: password, sudo: sudo
98
- sudo_execute :rm, '-r', working_dir, password: password, sudo: sudo
91
+ download! adapter.outfile_path, path
92
+ sudo_execute :rm, adapter.outfile, password: password, sudo: sudo
93
+ sudo_execute :rm, '-r', adapter.working_dir, password: password, sudo: sudo
99
94
  end
100
95
  extra_copy = check_dir(extra_copy)
101
96
  FileUtils.cp(path, extra_copy) if extra_copy
@@ -118,11 +113,11 @@ class Backupper
118
113
  o['url'] = "#{o['username']}@#{o['url']}" if o['username']
119
114
  o['url'] = "#{o['url']}:#{o['port']}" if o['port']
120
115
  o['adapter'] ||= 'mysql'
121
- unless DumpCommand.singleton_methods(false).map(&:to_s).include?(o['adapter'])
122
- return nil, "Cannot handle adapter '#{o['adapter']}' (supported: #{DumpCommand.singleton_methods(false).join(', ')})"
116
+ if Adapter.find(o['adapter']).nil?
117
+ return nil, "Cannot handle adapter '#{o['adapter']}' (supported: #{Adapter.names.join(', ')})"
123
118
  end
124
119
  begin
125
- shellescape_dump_options(o['dump_options'])
120
+ Adapter.shellescape_dump_options(o['dump_options'])
126
121
  rescue ArgumentError => e
127
122
  return nil, e.message
128
123
  end
@@ -130,22 +125,6 @@ class Backupper
130
125
  return o, nil
131
126
  end
132
127
 
133
- def shellescape_dump_options(dump_options)
134
- return nil if dump_options.nil?
135
-
136
- options =
137
- if dump_options.is_a?(Array)
138
- dump_options.map(&:to_s)
139
- else
140
- Shellwords.split(dump_options.to_s)
141
- end
142
- return nil if options.empty?
143
-
144
- return options.map { |option| Shellwords.escape(option) }.join(' ')
145
- rescue ArgumentError => e
146
- raise ArgumentError, "Invalid dump_options: #{e.message}"
147
- end
148
-
149
128
  def send_report_email!
150
129
  if @report.any? && @mailer['from'] && @mailer['to'] && @mailer['password']
151
130
  begin
@@ -1,3 +1,3 @@
1
1
  class Backupper
2
- VERSION = '0.6.1'.freeze
2
+ VERSION = '0.7.0'.freeze
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: backupper
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.1
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - pioz
@@ -83,8 +83,11 @@ files:
83
83
  - backupper.gemspec
84
84
  - bin/backupper
85
85
  - lib/backupper.rb
86
+ - lib/backupper/adapter.rb
87
+ - lib/backupper/adapters/mydumper.rb
88
+ - lib/backupper/adapters/mysql.rb
89
+ - lib/backupper/adapters/postgresql.rb
86
90
  - lib/backupper/backupper.rb
87
- - lib/backupper/dump_command.rb
88
91
  - lib/backupper/mailer.rb
89
92
  - lib/backupper/sshkit.rb
90
93
  - lib/backupper/version.rb
@@ -1,27 +0,0 @@
1
- module DumpCommand
2
- # Extension of the file produced by each adapter ('.sql.bz2' if not listed here)
3
- EXTENSIONS = Hash.new('.sql.bz2').update('mydumper' => '.tar').freeze
4
-
5
- def self.mysql(database:, username: 'root', password: nil, dump_options: nil, working_dir: nil, outfile:)
6
- params = []
7
- params << "-u#{username}"
8
- params << "-p#{password}" if password
9
- params << dump_options if dump_options
10
- return "mysqldump #{database} #{params.join(' ')} | bzip2 > #{outfile}"
11
- end
12
-
13
- def self.postgresql(database:, username: 'root', password: nil, dump_options: nil, working_dir: nil, outfile:)
14
- params = []
15
- params << "-U #{username}"
16
- params << dump_options if dump_options
17
- return "PGPASSWORD=#{password} pg_dump #{database} #{params.join(' ')} | bzip2 > #{outfile}"
18
- end
19
-
20
- def self.mydumper(database:, username: 'root', password: nil, dump_options: nil, working_dir: nil, outfile:)
21
- params = []
22
- params << "--user #{username}"
23
- params << "--password #{password}" if password
24
- params << dump_options if dump_options
25
- return "mydumper --database #{database} #{params.join(' ')} --outputdir #{working_dir} && tar -cf #{outfile} -C #{File.dirname(working_dir)} #{File.basename(working_dir)}"
26
- end
27
- end