kamal-backup 0.3.0.beta21 → 0.3.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/exe/kamal-backup +7 -6
- data/lib/kamal_backup/app.rb +350 -356
- data/lib/kamal_backup/cli.rb +107 -111
- data/lib/kamal_backup/command.rb +165 -161
- data/lib/kamal_backup/config.rb +533 -511
- data/lib/kamal_backup/databases/base.rb +17 -14
- data/lib/kamal_backup/databases/mysql.rb +68 -67
- data/lib/kamal_backup/databases/postgres.rb +59 -58
- data/lib/kamal_backup/databases/sqlite.rb +21 -20
- data/lib/kamal_backup/errors.rb +3 -1
- data/lib/kamal_backup/evidence.rb +62 -61
- data/lib/kamal_backup/kamal_bridge.rb +254 -250
- data/lib/kamal_backup/rails_app.rb +102 -101
- data/lib/kamal_backup/redactor.rb +18 -13
- data/lib/kamal_backup/restic.rb +195 -167
- data/lib/kamal_backup/scheduler.rb +17 -14
- data/lib/kamal_backup/schema.rb +2 -0
- data/lib/kamal_backup/version.rb +3 -1
- data/lib/kamal_backup.rb +19 -17
- metadata +30 -2
|
@@ -1,16 +1,18 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../command'
|
|
4
|
+
require_relative '../errors'
|
|
3
5
|
|
|
4
6
|
module KamalBackup
|
|
5
7
|
module Databases
|
|
6
8
|
class Base
|
|
7
9
|
def self.build(config, redactor:)
|
|
8
10
|
case config.database_adapter
|
|
9
|
-
when
|
|
11
|
+
when 'postgres'
|
|
10
12
|
Postgres.new(config, redactor: redactor)
|
|
11
|
-
when
|
|
13
|
+
when 'mysql'
|
|
12
14
|
Mysql.new(config, redactor: redactor)
|
|
13
|
-
when
|
|
15
|
+
when 'sqlite'
|
|
14
16
|
Sqlite.new(config, redactor: redactor)
|
|
15
17
|
else
|
|
16
18
|
raise ConfigurationError, "unsupported DATABASE_ADAPTER: #{config.database_adapter.inspect}"
|
|
@@ -42,13 +44,13 @@ module KamalBackup
|
|
|
42
44
|
end
|
|
43
45
|
|
|
44
46
|
def database_filename
|
|
45
|
-
app = config.app_name.gsub(/[^A-Za-z0-9_.-]+/,
|
|
46
|
-
database = config.database_name.gsub(/[^A-Za-z0-9_.-]+/,
|
|
47
|
+
app = config.app_name.gsub(/[^A-Za-z0-9_.-]+/, '-')
|
|
48
|
+
database = config.database_name.gsub(/[^A-Za-z0-9_.-]+/, '-')
|
|
47
49
|
"databases/#{app}/#{database}/#{adapter_name}.#{dump_extension}"
|
|
48
50
|
end
|
|
49
51
|
|
|
50
52
|
def backup_tags
|
|
51
|
-
[
|
|
53
|
+
['type:database', "database:#{config.database_name}", "adapter:#{adapter_name}"]
|
|
52
54
|
end
|
|
53
55
|
|
|
54
56
|
def adapter_name
|
|
@@ -84,13 +86,14 @@ module KamalBackup
|
|
|
84
86
|
end
|
|
85
87
|
|
|
86
88
|
private
|
|
87
|
-
def value(key)
|
|
88
|
-
config.value(key)
|
|
89
|
-
end
|
|
90
89
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
90
|
+
def value(key)
|
|
91
|
+
config.value(key)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def executable_available?(name)
|
|
95
|
+
Command.available?(name)
|
|
96
|
+
end
|
|
94
97
|
end
|
|
95
98
|
end
|
|
96
99
|
end
|
|
@@ -1,26 +1,28 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'uri'
|
|
4
|
+
require_relative 'base'
|
|
3
5
|
|
|
4
6
|
module KamalBackup
|
|
5
7
|
module Databases
|
|
6
8
|
class Mysql < Base
|
|
7
9
|
def adapter_name
|
|
8
|
-
|
|
10
|
+
'mysql'
|
|
9
11
|
end
|
|
10
12
|
|
|
11
13
|
def dump_extension
|
|
12
|
-
|
|
14
|
+
'sql'
|
|
13
15
|
end
|
|
14
16
|
|
|
15
17
|
def dump_command
|
|
16
18
|
connection = current_connection
|
|
17
19
|
argv = [
|
|
18
20
|
dump_binary,
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
'--single-transaction',
|
|
22
|
+
'--quick',
|
|
23
|
+
'--routines',
|
|
24
|
+
'--triggers',
|
|
25
|
+
'--events'
|
|
24
26
|
] + connection_args(connection)
|
|
25
27
|
argv << connection.fetch(:database)
|
|
26
28
|
CommandSpec.new(argv: argv, env: password_env(connection))
|
|
@@ -42,80 +44,79 @@ module KamalBackup
|
|
|
42
44
|
|
|
43
45
|
def current_target_identifier
|
|
44
46
|
connection = current_connection
|
|
45
|
-
[connection[:host], connection[:database]].compact.join(
|
|
47
|
+
[connection[:host], connection[:database]].compact.join('/')
|
|
46
48
|
end
|
|
47
49
|
|
|
48
50
|
def scratch_target_identifier(target)
|
|
49
|
-
[current_connection[:host], target].compact.join(
|
|
51
|
+
[current_connection[:host], target].compact.join('/')
|
|
50
52
|
end
|
|
51
53
|
|
|
52
54
|
private
|
|
53
|
-
def validate_scratch_restore_target(target)
|
|
54
|
-
if current_connection.fetch(:database) == target
|
|
55
|
-
raise ConfigurationError, "scratch database must differ from the current MySQL database"
|
|
56
|
-
end
|
|
57
55
|
|
|
58
|
-
|
|
59
|
-
|
|
56
|
+
def validate_scratch_restore_target(target)
|
|
57
|
+
raise ConfigurationError, 'scratch database must differ from the current MySQL database' if current_connection.fetch(:database) == target
|
|
60
58
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
end
|
|
59
|
+
super
|
|
60
|
+
end
|
|
64
61
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
62
|
+
def dump_binary
|
|
63
|
+
value('MYSQL_DUMP_BIN') || (executable_available?('mariadb-dump') ? 'mariadb-dump' : 'mysqldump')
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def client_binary
|
|
67
|
+
value('MYSQL_CLIENT_BIN') || (executable_available?('mariadb') ? 'mariadb' : 'mysql')
|
|
68
|
+
end
|
|
68
69
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
end
|
|
74
|
-
else
|
|
75
|
-
connection_from_env("")
|
|
70
|
+
def current_connection
|
|
71
|
+
if value('DATABASE_URL')
|
|
72
|
+
parse_url(value('DATABASE_URL')).tap do |connection|
|
|
73
|
+
connection[:password] ||= value('MYSQL_PWD') || value('MYSQL_PASSWORD') || value('MARIADB_PASSWORD')
|
|
76
74
|
end
|
|
75
|
+
else
|
|
76
|
+
connection_from_env('')
|
|
77
77
|
end
|
|
78
|
+
end
|
|
78
79
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
80
|
+
def connection_from_env(prefix)
|
|
81
|
+
database = value("#{prefix}MYSQL_DATABASE") || value("#{prefix}MARIADB_DATABASE")
|
|
82
|
+
raise ConfigurationError, "#{prefix}MYSQL_DATABASE or #{prefix}MARIADB_DATABASE is required" unless database
|
|
83
|
+
|
|
84
|
+
{
|
|
85
|
+
host: value("#{prefix}MYSQL_HOST") || value("#{prefix}MARIADB_HOST"),
|
|
86
|
+
port: value("#{prefix}MYSQL_PORT") || value("#{prefix}MARIADB_PORT"),
|
|
87
|
+
user: value("#{prefix}MYSQL_USER") || value("#{prefix}MARIADB_USER"),
|
|
88
|
+
password: value("#{prefix}MYSQL_PWD") || value("#{prefix}MYSQL_PASSWORD") || value("#{prefix}MARIADB_PASSWORD"),
|
|
89
|
+
database: database
|
|
90
|
+
}
|
|
91
|
+
end
|
|
91
92
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
93
|
+
def parse_url(url)
|
|
94
|
+
uri = URI.parse(url)
|
|
95
|
+
database = uri.path.to_s.sub(%r{\A/}, '')
|
|
96
|
+
raise ConfigurationError, "database name is missing in #{uri.scheme} DATABASE_URL" if database.empty?
|
|
97
|
+
|
|
98
|
+
{
|
|
99
|
+
host: uri.host,
|
|
100
|
+
port: uri.port,
|
|
101
|
+
user: uri.user ? URI.decode_www_form_component(uri.user) : nil,
|
|
102
|
+
password: uri.password ? URI.decode_www_form_component(uri.password) : nil,
|
|
103
|
+
database: URI.decode_www_form_component(database)
|
|
104
|
+
}
|
|
105
|
+
rescue URI::InvalidURIError => e
|
|
106
|
+
raise ConfigurationError, "invalid database URL: #{e.message}"
|
|
107
|
+
end
|
|
107
108
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
109
|
+
def connection_args(connection)
|
|
110
|
+
args = []
|
|
111
|
+
args.concat(['--host', connection[:host]]) if connection[:host]
|
|
112
|
+
args.concat(['--port', connection[:port].to_s]) if connection[:port]
|
|
113
|
+
args.concat(['--user', connection[:user]]) if connection[:user]
|
|
114
|
+
args
|
|
115
|
+
end
|
|
115
116
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
117
|
+
def password_env(connection)
|
|
118
|
+
connection[:password] ? { 'MYSQL_PWD' => connection[:password] } : {}
|
|
119
|
+
end
|
|
119
120
|
end
|
|
120
121
|
end
|
|
121
122
|
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'uri'
|
|
4
|
+
require_relative 'base'
|
|
3
5
|
|
|
4
6
|
module KamalBackup
|
|
5
7
|
module Databases
|
|
@@ -20,11 +22,11 @@ module KamalBackup
|
|
|
20
22
|
].freeze
|
|
21
23
|
|
|
22
24
|
def adapter_name
|
|
23
|
-
|
|
25
|
+
'postgres'
|
|
24
26
|
end
|
|
25
27
|
|
|
26
28
|
def dump_extension
|
|
27
|
-
|
|
29
|
+
'pgdump'
|
|
28
30
|
end
|
|
29
31
|
|
|
30
32
|
def dump_command
|
|
@@ -34,7 +36,7 @@ module KamalBackup
|
|
|
34
36
|
|
|
35
37
|
def current_restore_command
|
|
36
38
|
connection = current_connection
|
|
37
|
-
database = connection.fetch(
|
|
39
|
+
database = connection.fetch('PGDATABASE')
|
|
38
40
|
|
|
39
41
|
argv = %w[pg_restore --clean --if-exists --no-owner --no-privileges --dbname]
|
|
40
42
|
argv << database
|
|
@@ -42,7 +44,7 @@ module KamalBackup
|
|
|
42
44
|
end
|
|
43
45
|
|
|
44
46
|
def scratch_restore_command(target)
|
|
45
|
-
connection = current_connection.merge(
|
|
47
|
+
connection = current_connection.merge('PGDATABASE' => target)
|
|
46
48
|
|
|
47
49
|
argv = %w[pg_restore --clean --if-exists --no-owner --no-privileges --dbname]
|
|
48
50
|
argv << target
|
|
@@ -50,74 +52,73 @@ module KamalBackup
|
|
|
50
52
|
end
|
|
51
53
|
|
|
52
54
|
def current_target_identifier
|
|
53
|
-
value(
|
|
55
|
+
value('DATABASE_URL') || value('PGDATABASE')
|
|
54
56
|
end
|
|
55
57
|
|
|
56
58
|
def scratch_target_identifier(target)
|
|
57
|
-
[current_connection[
|
|
59
|
+
[current_connection['PGHOST'], target].compact.join('/')
|
|
58
60
|
end
|
|
59
61
|
|
|
60
62
|
private
|
|
61
|
-
def validate_scratch_restore_target(target)
|
|
62
|
-
if current_connection.fetch("PGDATABASE") == target
|
|
63
|
-
raise ConfigurationError, "scratch database must differ from the current PostgreSQL database"
|
|
64
|
-
end
|
|
65
63
|
|
|
66
|
-
|
|
67
|
-
|
|
64
|
+
def validate_scratch_restore_target(target)
|
|
65
|
+
raise ConfigurationError, 'scratch database must differ from the current PostgreSQL database' if current_connection.fetch('PGDATABASE') == target
|
|
68
66
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
connection_from_url(value("DATABASE_URL"), "DATABASE_URL").tap do |connection|
|
|
72
|
-
connection["PGPASSWORD"] ||= value("PGPASSWORD") if value("PGPASSWORD")
|
|
73
|
-
end
|
|
74
|
-
else
|
|
75
|
-
connection = prefixed_env("", SOURCE_ENV_KEYS)
|
|
76
|
-
raise ConfigurationError, "DATABASE_URL or PGDATABASE is required for PostgreSQL restore" unless connection["PGDATABASE"]
|
|
67
|
+
super
|
|
68
|
+
end
|
|
77
69
|
|
|
78
|
-
|
|
70
|
+
def current_connection
|
|
71
|
+
if value('DATABASE_URL')
|
|
72
|
+
connection_from_url(value('DATABASE_URL'), 'DATABASE_URL').tap do |connection|
|
|
73
|
+
connection['PGPASSWORD'] ||= value('PGPASSWORD') if value('PGPASSWORD')
|
|
79
74
|
end
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
75
|
+
else
|
|
76
|
+
connection = prefixed_env('', SOURCE_ENV_KEYS)
|
|
77
|
+
unless connection['PGDATABASE']
|
|
78
|
+
raise ConfigurationError,
|
|
79
|
+
'DATABASE_URL or PGDATABASE is required for PostgreSQL restore'
|
|
85
80
|
end
|
|
86
|
-
end
|
|
87
81
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
raise ConfigurationError, "#{name} must use postgres:// or postgresql://"
|
|
92
|
-
end
|
|
82
|
+
connection
|
|
83
|
+
end
|
|
84
|
+
end
|
|
93
85
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
"PGPORT" => uri.port&.to_s,
|
|
100
|
-
"PGUSER" => uri.user ? URI.decode_www_form_component(uri.user) : nil,
|
|
101
|
-
"PGPASSWORD" => uri.password ? URI.decode_www_form_component(uri.password) : nil,
|
|
102
|
-
"PGDATABASE" => database
|
|
103
|
-
}.compact
|
|
104
|
-
|
|
105
|
-
query = URI.decode_www_form(uri.query.to_s).to_h
|
|
106
|
-
{
|
|
107
|
-
"sslmode" => "PGSSLMODE",
|
|
108
|
-
"sslrootcert" => "PGSSLROOTCERT",
|
|
109
|
-
"sslcert" => "PGSSLCERT",
|
|
110
|
-
"sslkey" => "PGSSLKEY",
|
|
111
|
-
"connect_timeout" => "PGCONNECT_TIMEOUT"
|
|
112
|
-
}.each do |source, target|
|
|
113
|
-
env[target] = query[source] if query[source]
|
|
114
|
-
end
|
|
86
|
+
def prefixed_env(prefix, keys)
|
|
87
|
+
keys.each_with_object({}) do |key, env|
|
|
88
|
+
env[key] = value("#{prefix}#{key}") if value("#{prefix}#{key}")
|
|
89
|
+
end
|
|
90
|
+
end
|
|
115
91
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
92
|
+
def connection_from_url(url, name)
|
|
93
|
+
uri = URI.parse(url)
|
|
94
|
+
raise ConfigurationError, "#{name} must use postgres:// or postgresql://" unless %w[postgres postgresql].include?(uri.scheme)
|
|
95
|
+
|
|
96
|
+
database = URI.decode_www_form_component(uri.path.to_s.sub(%r{\A/}, ''))
|
|
97
|
+
raise ConfigurationError, "database name is missing in #{name}" if database.empty?
|
|
98
|
+
|
|
99
|
+
env = {
|
|
100
|
+
'PGHOST' => uri.host,
|
|
101
|
+
'PGPORT' => uri.port&.to_s,
|
|
102
|
+
'PGUSER' => uri.user ? URI.decode_www_form_component(uri.user) : nil,
|
|
103
|
+
'PGPASSWORD' => uri.password ? URI.decode_www_form_component(uri.password) : nil,
|
|
104
|
+
'PGDATABASE' => database
|
|
105
|
+
}.compact
|
|
106
|
+
|
|
107
|
+
query = URI.decode_www_form(uri.query.to_s).to_h
|
|
108
|
+
{
|
|
109
|
+
'sslmode' => 'PGSSLMODE',
|
|
110
|
+
'sslrootcert' => 'PGSSLROOTCERT',
|
|
111
|
+
'sslcert' => 'PGSSLCERT',
|
|
112
|
+
'sslkey' => 'PGSSLKEY',
|
|
113
|
+
'connect_timeout' => 'PGCONNECT_TIMEOUT'
|
|
114
|
+
}.each do |source, target|
|
|
115
|
+
env[target] = query[source] if query[source]
|
|
119
116
|
end
|
|
120
117
|
|
|
118
|
+
env
|
|
119
|
+
rescue URI::InvalidURIError => e
|
|
120
|
+
raise ConfigurationError, "invalid #{name}: #{e.message}"
|
|
121
|
+
end
|
|
121
122
|
end
|
|
122
123
|
end
|
|
123
124
|
end
|
|
@@ -1,24 +1,26 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'tempfile'
|
|
5
|
+
require_relative 'base'
|
|
4
6
|
|
|
5
7
|
module KamalBackup
|
|
6
8
|
module Databases
|
|
7
9
|
class Sqlite < Base
|
|
8
10
|
def adapter_name
|
|
9
|
-
|
|
11
|
+
'sqlite'
|
|
10
12
|
end
|
|
11
13
|
|
|
12
14
|
def dump_extension
|
|
13
|
-
|
|
15
|
+
'sqlite3'
|
|
14
16
|
end
|
|
15
17
|
|
|
16
18
|
def backup(restic)
|
|
17
19
|
source = sqlite_source
|
|
18
|
-
Tempfile.create([
|
|
20
|
+
Tempfile.create(['kamal-backup-', '.sqlite3']) do |tempfile|
|
|
19
21
|
tempfile.close
|
|
20
22
|
Command.capture(
|
|
21
|
-
CommandSpec.new(argv: [
|
|
23
|
+
CommandSpec.new(argv: ['sqlite3', source, ".backup #{sqlite_literal(tempfile.path)}"]),
|
|
22
24
|
redactor: redactor
|
|
23
25
|
)
|
|
24
26
|
restic.backup_file(
|
|
@@ -39,7 +41,7 @@ module KamalBackup
|
|
|
39
41
|
end
|
|
40
42
|
|
|
41
43
|
def dump_command
|
|
42
|
-
raise NotImplementedError,
|
|
44
|
+
raise NotImplementedError, 'SQLite backup uses .backup into a temporary file'
|
|
43
45
|
end
|
|
44
46
|
|
|
45
47
|
def current_target_identifier
|
|
@@ -51,21 +53,20 @@ module KamalBackup
|
|
|
51
53
|
end
|
|
52
54
|
|
|
53
55
|
private
|
|
54
|
-
def sqlite_source
|
|
55
|
-
config.required_value("SQLITE_DATABASE_PATH")
|
|
56
|
-
end
|
|
57
56
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
end
|
|
57
|
+
def sqlite_source
|
|
58
|
+
config.required_value('SQLITE_DATABASE_PATH')
|
|
59
|
+
end
|
|
62
60
|
|
|
63
|
-
|
|
64
|
-
|
|
61
|
+
def validate_scratch_restore_target(target)
|
|
62
|
+
raise ConfigurationError, 'scratch SQLite path must differ from SQLITE_DATABASE_PATH' if File.expand_path(sqlite_source) == File.expand_path(target)
|
|
65
63
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
64
|
+
super
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def sqlite_literal(value)
|
|
68
|
+
"'#{value.to_s.gsub("'", "''")}'"
|
|
69
|
+
end
|
|
69
70
|
end
|
|
70
71
|
end
|
|
71
72
|
end
|
data/lib/kamal_backup/errors.rb
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module KamalBackup
|
|
2
4
|
class Error < StandardError; end
|
|
3
5
|
class ConfigurationError < Error; end
|
|
@@ -5,7 +7,7 @@ module KamalBackup
|
|
|
5
7
|
class CommandError < Error
|
|
6
8
|
attr_reader :command, :status, :stdout, :stderr
|
|
7
9
|
|
|
8
|
-
def initialize(message, command:, status: nil, stdout:
|
|
10
|
+
def initialize(message, command:, status: nil, stdout: '', stderr: '')
|
|
9
11
|
super(message)
|
|
10
12
|
@command = command
|
|
11
13
|
@status = status
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
require_relative
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'time'
|
|
5
|
+
require_relative 'command'
|
|
6
|
+
require_relative 'schema'
|
|
7
|
+
require_relative 'version'
|
|
6
8
|
|
|
7
9
|
module KamalBackup
|
|
8
10
|
class Evidence
|
|
@@ -14,19 +16,21 @@ module KamalBackup
|
|
|
14
16
|
|
|
15
17
|
def to_h
|
|
16
18
|
Schema.record(
|
|
17
|
-
kind:
|
|
19
|
+
kind: 'evidence',
|
|
18
20
|
app_name: @config.app_name,
|
|
19
21
|
generated_at: Time.now.utc.iso8601,
|
|
20
22
|
database_adapter: @config.database_adapter,
|
|
21
|
-
databases: @config.databases.map
|
|
23
|
+
databases: @config.databases.map do |database|
|
|
24
|
+
{ name: database.database_name, adapter: database.database_adapter }
|
|
25
|
+
end,
|
|
22
26
|
restic_repository: @redactor.redact_string(@config.restic_repository.to_s),
|
|
23
27
|
backup_paths: @config.backup_paths,
|
|
24
28
|
paths: @config.backup_paths,
|
|
25
29
|
forget_after_backup: @config.forget_after_backup?,
|
|
26
30
|
retention: @config.retention,
|
|
27
|
-
latest_database_backup: latest_snapshot_summary([
|
|
31
|
+
latest_database_backup: latest_snapshot_summary(['type:database']),
|
|
28
32
|
latest_database_backups: latest_database_backups,
|
|
29
|
-
latest_file_backup: latest_snapshot_summary([
|
|
33
|
+
latest_file_backup: latest_snapshot_summary(['type:files']),
|
|
30
34
|
last_restic_check: last_check,
|
|
31
35
|
last_restore_drill: last_restore_drill,
|
|
32
36
|
image_version: VERSION,
|
|
@@ -39,67 +43,64 @@ module KamalBackup
|
|
|
39
43
|
end
|
|
40
44
|
|
|
41
45
|
private
|
|
42
|
-
def latest_snapshot_summary(tags)
|
|
43
|
-
snapshot = @restic.latest_snapshot(tags: tags)
|
|
44
46
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
id: snapshot["short_id"] || snapshot["id"],
|
|
48
|
-
time: snapshot["time"],
|
|
49
|
-
tags: snapshot["tags"]
|
|
50
|
-
}
|
|
51
|
-
end
|
|
52
|
-
rescue Error => e
|
|
53
|
-
{ error: @redactor.redact_string(e.message) }
|
|
54
|
-
end
|
|
47
|
+
def latest_snapshot_summary(tags)
|
|
48
|
+
snapshot = @restic.latest_snapshot(tags: tags)
|
|
55
49
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
])
|
|
63
|
-
end
|
|
50
|
+
if snapshot
|
|
51
|
+
{
|
|
52
|
+
id: snapshot['short_id'] || snapshot['id'],
|
|
53
|
+
time: snapshot['time'],
|
|
54
|
+
tags: snapshot['tags']
|
|
55
|
+
}
|
|
64
56
|
end
|
|
57
|
+
rescue Error => e
|
|
58
|
+
{ error: @redactor.redact_string(e.message) }
|
|
59
|
+
end
|
|
65
60
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
61
|
+
def latest_database_backups
|
|
62
|
+
@config.databases.each_with_object({}) do |database, backups|
|
|
63
|
+
backups[database.database_name] = latest_snapshot_summary([
|
|
64
|
+
'type:database',
|
|
65
|
+
"database:#{database.database_name}",
|
|
66
|
+
"adapter:#{database.database_adapter}"
|
|
67
|
+
])
|
|
72
68
|
end
|
|
69
|
+
end
|
|
73
70
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
{ error: @redactor.redact_string(e.message) }
|
|
80
|
-
end
|
|
71
|
+
def last_check
|
|
72
|
+
JSON.parse(File.read(@config.last_check_path)) if File.file?(@config.last_check_path)
|
|
73
|
+
rescue JSON::ParserError, SystemCallError => e
|
|
74
|
+
{ error: @redactor.redact_string(e.message) }
|
|
75
|
+
end
|
|
81
76
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
mysql_client: version_for(["mariadb", "--version"], ["mysql", "--version"]),
|
|
88
|
-
sqlite3: version_for(["sqlite3", "--version"]),
|
|
89
|
-
restic: version_for(["restic", "version"])
|
|
90
|
-
}
|
|
91
|
-
end
|
|
77
|
+
def last_restore_drill
|
|
78
|
+
JSON.parse(File.read(@config.last_restore_drill_path)) if File.file?(@config.last_restore_drill_path)
|
|
79
|
+
rescue JSON::ParserError, SystemCallError => e
|
|
80
|
+
{ error: @redactor.redact_string(e.message) }
|
|
81
|
+
end
|
|
92
82
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
83
|
+
def tool_versions
|
|
84
|
+
{
|
|
85
|
+
pg_dump: version_for(['pg_dump', '--version']),
|
|
86
|
+
pg_restore: version_for(['pg_restore', '--version']),
|
|
87
|
+
mysql_dump: version_for(['mariadb-dump', '--version'], ['mysqldump', '--version']),
|
|
88
|
+
mysql_client: version_for(['mariadb', '--version'], ['mysql', '--version']),
|
|
89
|
+
sqlite3: version_for(['sqlite3', '--version']),
|
|
90
|
+
restic: version_for(%w[restic version])
|
|
91
|
+
}
|
|
92
|
+
end
|
|
101
93
|
|
|
102
|
-
|
|
94
|
+
def version_for(*commands)
|
|
95
|
+
commands.each do |argv|
|
|
96
|
+
result = Command.capture(CommandSpec.new(argv: argv), redactor: @redactor)
|
|
97
|
+
output = result.stdout.empty? ? result.stderr : result.stdout
|
|
98
|
+
return @redactor.redact_string(output.strip)
|
|
99
|
+
rescue CommandError
|
|
100
|
+
next
|
|
103
101
|
end
|
|
102
|
+
|
|
103
|
+
'unavailable'
|
|
104
|
+
end
|
|
104
105
|
end
|
|
105
106
|
end
|