kamal-backup 0.5.2 → 1.0.0.rc1
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/README.md +1 -1
- data/lib/kamal_backup/databases/mysql.rb +82 -0
- data/lib/kamal_backup/databases/postgres.rb +78 -16
- data/lib/kamal_backup/databases/sqlite.rb +27 -2
- data/lib/kamal_backup/evidence.rb +2 -1
- data/lib/kamal_backup/redactor.rb +2 -2
- data/lib/kamal_backup/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8e351aa7e872cd48344295084005c0cdb418169affa209b6f6c3b704372e96d5
|
|
4
|
+
data.tar.gz: ee83efba870cf740e9d95898e62191611ddaa8153711d8030c90045166d224e3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 739282c586403339edda3f475190355400f639c0c1eba0b8145531f964a8be6815689f6252a8d4d3f9fb7db42efd3413f1188ff0243503d15d27379bf32c4998
|
|
7
|
+
data.tar.gz: 6abaadf21b62c4d106b92fbce3c0f703d4bb42d03cc6e86c47f9eb90e24942beda433a98c6f05acfb3557da3323ff551a85ad7b63fe6a0db484a06a3813f650f
|
data/README.md
CHANGED
|
@@ -125,7 +125,7 @@ bundle exec kamal-backup evidence
|
|
|
125
125
|
|
|
126
126
|
- **Scheduled backups:** the accessory runs continuously and backs up on `backup.schedule`.
|
|
127
127
|
- **Database and Active Storage coverage:** database dumps plus file-backed Active Storage files from mounted volumes.
|
|
128
|
-
- **Restic underneath:** encrypted, deduplicated snapshots in
|
|
128
|
+
- **Restic underneath:** encrypted, deduplicated snapshots in native restic backends or any rclone remote; the accessory includes both rclone and an SSH client.
|
|
129
129
|
- **Local restores:** inspect production data safely in your local Rails app.
|
|
130
130
|
- **Restore drills:** restore into scratch production-side targets, run verification commands, and record the result.
|
|
131
131
|
- **Security review evidence:** `kamal-backup evidence` prints redacted JSON with latest snapshots, `kamal-backup check` results, drills, retention, and tool versions.
|
|
@@ -20,6 +20,8 @@ module KamalBackup
|
|
|
20
20
|
dump_binary,
|
|
21
21
|
'--single-transaction',
|
|
22
22
|
'--quick',
|
|
23
|
+
'--skip-comments',
|
|
24
|
+
'--no-tablespaces',
|
|
23
25
|
'--routines',
|
|
24
26
|
'--triggers',
|
|
25
27
|
'--events'
|
|
@@ -28,6 +30,17 @@ module KamalBackup
|
|
|
28
30
|
CommandSpec.new(argv: argv, env: password_env(connection))
|
|
29
31
|
end
|
|
30
32
|
|
|
33
|
+
def restore_to_current(restic, snapshot, filename)
|
|
34
|
+
reset_database(current_connection)
|
|
35
|
+
super
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def restore_to_scratch(restic, snapshot, filename, target:)
|
|
39
|
+
validate_scratch_restore_target(target)
|
|
40
|
+
reset_database(current_connection.merge(database: target))
|
|
41
|
+
restic.pipe_dump_to_command(snapshot, filename, scratch_restore_command(target))
|
|
42
|
+
end
|
|
43
|
+
|
|
31
44
|
def current_restore_command
|
|
32
45
|
connection = current_connection
|
|
33
46
|
argv = [client_binary] + connection_args(connection)
|
|
@@ -53,6 +66,68 @@ module KamalBackup
|
|
|
53
66
|
|
|
54
67
|
private
|
|
55
68
|
|
|
69
|
+
DATABASE_OBJECTS_SQL = <<~SQL
|
|
70
|
+
SELECT 'VIEW', HEX(TABLE_NAME)
|
|
71
|
+
FROM information_schema.VIEWS
|
|
72
|
+
WHERE TABLE_SCHEMA = DATABASE()
|
|
73
|
+
UNION ALL
|
|
74
|
+
SELECT CASE WHEN TABLE_TYPE = 'SEQUENCE' THEN 'SEQUENCE' ELSE 'TABLE' END, HEX(TABLE_NAME)
|
|
75
|
+
FROM information_schema.TABLES
|
|
76
|
+
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_TYPE <> 'VIEW'
|
|
77
|
+
UNION ALL
|
|
78
|
+
SELECT ROUTINE_TYPE, HEX(ROUTINE_NAME)
|
|
79
|
+
FROM information_schema.ROUTINES
|
|
80
|
+
WHERE ROUTINE_SCHEMA = DATABASE()
|
|
81
|
+
UNION ALL
|
|
82
|
+
SELECT 'EVENT', HEX(EVENT_NAME)
|
|
83
|
+
FROM information_schema.EVENTS
|
|
84
|
+
WHERE EVENT_SCHEMA = DATABASE()
|
|
85
|
+
SQL
|
|
86
|
+
private_constant :DATABASE_OBJECTS_SQL
|
|
87
|
+
|
|
88
|
+
def reset_database(connection)
|
|
89
|
+
objects = database_objects(connection)
|
|
90
|
+
statements = ['SET FOREIGN_KEY_CHECKS=0;']
|
|
91
|
+
%w[VIEW TABLE SEQUENCE PROCEDURE FUNCTION EVENT].each do |type|
|
|
92
|
+
objects.fetch(type, []).each do |name|
|
|
93
|
+
statements << "DROP #{type} IF EXISTS #{quote_identifier(name)};"
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
Command.capture(
|
|
98
|
+
CommandSpec.new(argv: client_argv(connection), env: password_env(connection)),
|
|
99
|
+
redactor: redactor,
|
|
100
|
+
input: statements.join("\n")
|
|
101
|
+
)
|
|
102
|
+
rescue CommandError => e
|
|
103
|
+
raise CommandError.new(
|
|
104
|
+
"failed to reset the MySQL database before restoring: #{e.message}",
|
|
105
|
+
command: e.command,
|
|
106
|
+
stderr: e.stderr
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def database_objects(connection)
|
|
111
|
+
command = CommandSpec.new(
|
|
112
|
+
argv: client_argv(
|
|
113
|
+
connection,
|
|
114
|
+
options: ['--batch', '--skip-column-names', '--raw', '--execute', DATABASE_OBJECTS_SQL]
|
|
115
|
+
),
|
|
116
|
+
env: password_env(connection)
|
|
117
|
+
)
|
|
118
|
+
output = Command.capture(command, redactor: redactor, log_output: false).stdout
|
|
119
|
+
output.lines.each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |line, objects|
|
|
120
|
+
type, hex_name = line.chomp.split("\t", 2)
|
|
121
|
+
next if type.to_s.empty? || hex_name.to_s.empty?
|
|
122
|
+
|
|
123
|
+
objects[type] << [hex_name].pack('H*')
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def quote_identifier(value)
|
|
128
|
+
"`#{value.to_s.gsub('`', '``')}`"
|
|
129
|
+
end
|
|
130
|
+
|
|
56
131
|
def validate_scratch_restore_target(target)
|
|
57
132
|
raise ConfigurationError, 'scratch database must differ from the current MySQL database' if current_connection.fetch(:database) == target
|
|
58
133
|
|
|
@@ -92,6 +167,9 @@ module KamalBackup
|
|
|
92
167
|
|
|
93
168
|
def parse_url(url)
|
|
94
169
|
uri = URI.parse(url)
|
|
170
|
+
supported_schemes = %w[mysql mysql2 mariadb]
|
|
171
|
+
raise ConfigurationError, 'DATABASE_URL must use mysql://, mysql2://, or mariadb://' unless supported_schemes.include?(uri.scheme)
|
|
172
|
+
|
|
95
173
|
database = uri.path.to_s.sub(%r{\A/}, '')
|
|
96
174
|
raise ConfigurationError, "database name is missing in #{uri.scheme} DATABASE_URL" if database.empty?
|
|
97
175
|
|
|
@@ -114,6 +192,10 @@ module KamalBackup
|
|
|
114
192
|
args
|
|
115
193
|
end
|
|
116
194
|
|
|
195
|
+
def client_argv(connection, options: [])
|
|
196
|
+
[client_binary] + connection_args(connection) + options + [connection.fetch(:database)]
|
|
197
|
+
end
|
|
198
|
+
|
|
117
199
|
def password_env(connection)
|
|
118
200
|
connection[:password] ? { 'MYSQL_PWD' => connection[:password] } : {}
|
|
119
201
|
end
|
|
@@ -30,11 +30,12 @@ module KamalBackup
|
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
def dump_command
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
connection = current_connection
|
|
34
|
+
argv = [postgres_binary('pg_dump', connection)] + %w[--format=custom --no-owner --no-privileges]
|
|
35
|
+
CommandSpec.new(argv: argv, env: connection)
|
|
35
36
|
end
|
|
36
37
|
|
|
37
|
-
# Replace the target
|
|
38
|
+
# Replace the target's user schemas before restoring.
|
|
38
39
|
#
|
|
39
40
|
# pg_restore --clean emits DROP TABLE for each object in the dump, but
|
|
40
41
|
# PostgreSQL refuses to drop a table another table still references, and
|
|
@@ -45,22 +46,31 @@ module KamalBackup
|
|
|
45
46
|
# exists", the COPYs never running, and the foreign keys rejected because
|
|
46
47
|
# the tables they point at are still empty.
|
|
47
48
|
#
|
|
48
|
-
# Dropping
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
#
|
|
49
|
+
# Dropping every non-system schema first sidesteps the ordering problem
|
|
50
|
+
# entirely and removes target-only custom schemas as well as public.
|
|
51
|
+
# Current and scratch restores both promise that the target represents
|
|
52
|
+
# the selected snapshot, so both paths reset their target schema.
|
|
52
53
|
def restore_to_current(restic, snapshot, filename)
|
|
53
|
-
|
|
54
|
+
reset_schema(current_connection)
|
|
54
55
|
result = super
|
|
55
56
|
ensure_restore_reported_no_errors(result)
|
|
56
57
|
result
|
|
57
58
|
end
|
|
58
59
|
|
|
60
|
+
def restore_to_scratch(restic, snapshot, filename, target:)
|
|
61
|
+
validate_scratch_restore_target(target)
|
|
62
|
+
reset_schema(current_connection.merge('PGDATABASE' => target))
|
|
63
|
+
result = restic.pipe_dump_to_command(snapshot, filename, scratch_restore_command(target))
|
|
64
|
+
ensure_restore_reported_no_errors(result)
|
|
65
|
+
result
|
|
66
|
+
end
|
|
67
|
+
|
|
59
68
|
def current_restore_command
|
|
60
69
|
connection = current_connection
|
|
61
70
|
database = connection.fetch('PGDATABASE')
|
|
62
71
|
|
|
63
|
-
argv =
|
|
72
|
+
argv = [postgres_binary('pg_restore', connection)] +
|
|
73
|
+
%w[--clean --if-exists --no-owner --no-privileges --exit-on-error --dbname]
|
|
64
74
|
argv << database
|
|
65
75
|
CommandSpec.new(argv: argv, env: connection)
|
|
66
76
|
end
|
|
@@ -68,7 +78,8 @@ module KamalBackup
|
|
|
68
78
|
def scratch_restore_command(target)
|
|
69
79
|
connection = current_connection.merge('PGDATABASE' => target)
|
|
70
80
|
|
|
71
|
-
argv =
|
|
81
|
+
argv = [postgres_binary('pg_restore', connection)] +
|
|
82
|
+
%w[--clean --if-exists --no-owner --no-privileges --exit-on-error --dbname]
|
|
72
83
|
argv << target
|
|
73
84
|
CommandSpec.new(argv: argv, env: connection)
|
|
74
85
|
end
|
|
@@ -100,20 +111,71 @@ module KamalBackup
|
|
|
100
111
|
)
|
|
101
112
|
end
|
|
102
113
|
|
|
103
|
-
def
|
|
104
|
-
connection =
|
|
105
|
-
|
|
114
|
+
def reset_schema(connection)
|
|
115
|
+
argv = [postgres_binary('psql', connection), '--quiet', '--no-psqlrc', '--set', 'ON_ERROR_STOP=1',
|
|
116
|
+
'--command', RESET_SCHEMAS_SQL]
|
|
106
117
|
Command.capture(CommandSpec.new(argv: argv, env: connection), redactor: redactor)
|
|
107
118
|
rescue CommandError => e
|
|
108
119
|
raise CommandError.new(
|
|
109
|
-
"failed to reset
|
|
120
|
+
"failed to reset PostgreSQL schemas before restoring: #{e.message}",
|
|
110
121
|
command: e.command,
|
|
111
122
|
stderr: e.stderr
|
|
112
123
|
)
|
|
113
124
|
end
|
|
114
125
|
|
|
115
|
-
|
|
116
|
-
|
|
126
|
+
RESET_SCHEMAS_SQL = <<~SQL
|
|
127
|
+
DO $$
|
|
128
|
+
DECLARE
|
|
129
|
+
schema_name text;
|
|
130
|
+
BEGIN
|
|
131
|
+
FOR schema_name IN
|
|
132
|
+
SELECT nspname
|
|
133
|
+
FROM pg_namespace
|
|
134
|
+
WHERE nspname !~ '^pg_'
|
|
135
|
+
AND nspname <> 'information_schema'
|
|
136
|
+
LOOP
|
|
137
|
+
EXECUTE format('DROP SCHEMA %I CASCADE', schema_name);
|
|
138
|
+
END LOOP;
|
|
139
|
+
END
|
|
140
|
+
$$;
|
|
141
|
+
CREATE SCHEMA public;
|
|
142
|
+
SQL
|
|
143
|
+
private_constant :RESET_SCHEMAS_SQL
|
|
144
|
+
|
|
145
|
+
def postgres_binary(name, connection)
|
|
146
|
+
root = value('KAMAL_BACKUP_POSTGRES_CLIENT_ROOT')
|
|
147
|
+
return name if root.to_s.empty?
|
|
148
|
+
|
|
149
|
+
major = postgres_server_major(connection)
|
|
150
|
+
binary = File.join(root, major.to_s, 'bin', name)
|
|
151
|
+
return binary if File.executable?(binary)
|
|
152
|
+
|
|
153
|
+
supported = Dir[File.join(root, '*', 'bin', name)].filter_map do |path|
|
|
154
|
+
path.split(File::SEPARATOR)[-3] if File.executable?(path)
|
|
155
|
+
end.sort
|
|
156
|
+
raise ConfigurationError,
|
|
157
|
+
"PostgreSQL #{major} is not supported by this accessory image; available client versions: #{supported.join(', ')}"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def postgres_server_major(connection)
|
|
161
|
+
@postgres_server_majors ||= {}
|
|
162
|
+
key = connection.values_at('PGHOST', 'PGPORT', 'PGUSER', 'PGDATABASE', 'PGSERVICE')
|
|
163
|
+
@postgres_server_majors[key] ||= begin
|
|
164
|
+
probe = value('KAMAL_BACKUP_POSTGRES_PROBE_BIN') || 'psql'
|
|
165
|
+
result = Command.capture(
|
|
166
|
+
CommandSpec.new(
|
|
167
|
+
argv: [probe, '--no-psqlrc', '--tuples-only', '--no-align', '--command', 'SHOW server_version_num'],
|
|
168
|
+
env: connection
|
|
169
|
+
),
|
|
170
|
+
redactor: redactor,
|
|
171
|
+
log: false
|
|
172
|
+
)
|
|
173
|
+
version_number = Integer(result.stdout.strip, 10)
|
|
174
|
+
version_number / 10_000
|
|
175
|
+
rescue ArgumentError
|
|
176
|
+
raise ConfigurationError, 'could not determine the PostgreSQL server version'
|
|
177
|
+
end
|
|
178
|
+
end
|
|
117
179
|
|
|
118
180
|
def validate_scratch_restore_target(target)
|
|
119
181
|
raise ConfigurationError, 'scratch database must differ from the current PostgreSQL database' if current_connection.fetch('PGDATABASE') == target
|
|
@@ -32,12 +32,12 @@ module KamalBackup
|
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
def restore_to_current(restic, snapshot, filename)
|
|
35
|
-
restic
|
|
35
|
+
restore_database(restic, snapshot, filename, target: sqlite_source)
|
|
36
36
|
end
|
|
37
37
|
|
|
38
38
|
def restore_to_scratch(restic, snapshot, filename, target:)
|
|
39
39
|
validate_scratch_restore_target(target)
|
|
40
|
-
restic
|
|
40
|
+
restore_database(restic, snapshot, filename, target: target)
|
|
41
41
|
end
|
|
42
42
|
|
|
43
43
|
def dump_command
|
|
@@ -67,6 +67,31 @@ module KamalBackup
|
|
|
67
67
|
def sqlite_literal(value)
|
|
68
68
|
"'#{value.to_s.gsub("'", "''")}'"
|
|
69
69
|
end
|
|
70
|
+
|
|
71
|
+
def restore_database(restic, snapshot, filename, target:)
|
|
72
|
+
Tempfile.create(['kamal-backup-restore-', '.sqlite3']) do |tempfile|
|
|
73
|
+
tempfile.close
|
|
74
|
+
restic.write_dump_to_path(snapshot, filename, tempfile.path)
|
|
75
|
+
validate_database_file(tempfile.path)
|
|
76
|
+
FileUtils.mkdir_p(File.dirname(File.expand_path(target)))
|
|
77
|
+
Command.capture(
|
|
78
|
+
CommandSpec.new(argv: ['sqlite3', '-bail', target, ".restore #{sqlite_literal(tempfile.path)}"]),
|
|
79
|
+
redactor: redactor
|
|
80
|
+
)
|
|
81
|
+
validate_database_file(target)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def validate_database_file(path)
|
|
86
|
+
result = Command.capture(
|
|
87
|
+
CommandSpec.new(argv: ['sqlite3', '-bail', path, 'PRAGMA quick_check;']),
|
|
88
|
+
redactor: redactor,
|
|
89
|
+
log_output: false
|
|
90
|
+
)
|
|
91
|
+
return if result.stdout.strip == 'ok'
|
|
92
|
+
|
|
93
|
+
raise ConfigurationError, "SQLite integrity check failed for #{path}"
|
|
94
|
+
end
|
|
70
95
|
end
|
|
71
96
|
end
|
|
72
97
|
end
|
|
@@ -84,7 +84,8 @@ module KamalBackup
|
|
|
84
84
|
mysql_dump: version_for(['mariadb-dump', '--version'], ['mysqldump', '--version']),
|
|
85
85
|
mysql_client: version_for(['mariadb', '--version'], ['mysql', '--version']),
|
|
86
86
|
sqlite3: version_for(['sqlite3', '--version']),
|
|
87
|
-
restic: version_for(%w[restic version])
|
|
87
|
+
restic: version_for(%w[restic version]),
|
|
88
|
+
rclone: version_for(%w[rclone version])
|
|
88
89
|
}
|
|
89
90
|
end
|
|
90
91
|
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
module KamalBackup
|
|
4
4
|
class Redactor
|
|
5
|
-
SECRET_KEY_PATTERN = /(pass|password|secret|token|key|credential|authorization)/i
|
|
6
|
-
SENSITIVE_KEY_PATTERN = /(?:pass|password|secret|token|key|credential|authorization)|\A(?:user|username|pguser|.*_user|.*_username)\z/i
|
|
5
|
+
SECRET_KEY_PATTERN = /(?:pass|password|secret|token|key|credential|authorization)|(?:\A|_)pwd(?:\z|_)/i
|
|
6
|
+
SENSITIVE_KEY_PATTERN = /(?:pass|password|secret|token|key|credential|authorization)|(?:\A|_)pwd(?:\z|_)|\A(?:user|username|pguser|.*_user|.*_username)\z/i
|
|
7
7
|
REDACTED = '[REDACTED]'
|
|
8
8
|
|
|
9
9
|
def initialize(secret_values: [], env: ENV)
|
data/lib/kamal_backup/version.rb
CHANGED