backupper 0.6.0 → 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 +4 -4
- data/Gemfile.lock +1 -1
- data/README.md +34 -5
- data/lib/backupper/adapter.rb +118 -0
- data/lib/backupper/adapters/mydumper.rb +19 -0
- data/lib/backupper/adapters/mysql.rb +19 -0
- data/lib/backupper/adapters/postgresql.rb +18 -0
- data/lib/backupper/backupper.rb +21 -42
- data/lib/backupper/version.rb +1 -1
- metadata +5 -2
- data/lib/backupper/dump_command.rb +0 -27
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6bcd9c73d5e46de14f981d0fc742f5142c9f66d5c4553ebe19face5c98d95626
|
|
4
|
+
data.tar.gz: '0708bc10f83d92061a9c16febaf56319b5bba9eed397529f7ce0bc00ec1e7bcc'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 77c91c30b33c1ea38a07537027e021f6be9ecd91878858701caaf452321d1a8244551360327f6e80d412996833cf69402021eb5d07b693fb6af4f1ed3491b79f
|
|
7
|
+
data.tar.gz: c56d8476dd7b7eea3f4fcc307a1311020630b132a564159759d763d0ea8b3ac180c2582c9c7f808a424ff6e32c57436974fa79eca1d2e8a4588adb24eca0d516
|
data/Gemfile.lock
CHANGED
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
|
|
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
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
data/lib/backupper/backupper.rb
CHANGED
|
@@ -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 '
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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!
|
|
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
|
-
|
|
122
|
-
return nil, "Cannot handle adapter '#{o['adapter']}' (supported: #{
|
|
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
|
data/lib/backupper/version.rb
CHANGED
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.
|
|
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.bz2').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 -cjf #{outfile} -C #{File.dirname(working_dir)} #{File.basename(working_dir)}"
|
|
26
|
-
end
|
|
27
|
-
end
|