kamal-backup 0.1.0.pre.1
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 +7 -0
- data/LICENSE +21 -0
- data/README.md +345 -0
- data/exe/kamal-backup +7 -0
- data/lib/kamal_backup/cli.rb +156 -0
- data/lib/kamal_backup/command.rb +49 -0
- data/lib/kamal_backup/config.rb +273 -0
- data/lib/kamal_backup/databases/base.rb +74 -0
- data/lib/kamal_backup/databases/mysql.rb +109 -0
- data/lib/kamal_backup/databases/postgres.rb +125 -0
- data/lib/kamal_backup/databases/sqlite.rb +73 -0
- data/lib/kamal_backup/errors.rb +16 -0
- data/lib/kamal_backup/evidence.rb +80 -0
- data/lib/kamal_backup/redactor.rb +53 -0
- data/lib/kamal_backup/restic.rb +239 -0
- data/lib/kamal_backup/scheduler.rb +50 -0
- data/lib/kamal_backup/version.rb +3 -0
- data/lib/kamal_backup.rb +13 -0
- metadata +68 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
require "pathname"
|
|
3
|
+
require "time"
|
|
4
|
+
require "uri"
|
|
5
|
+
require_relative "errors"
|
|
6
|
+
|
|
7
|
+
module KamalBackup
|
|
8
|
+
class Config
|
|
9
|
+
DEFAULT_RETENTION = {
|
|
10
|
+
"RESTIC_KEEP_LAST" => "7",
|
|
11
|
+
"RESTIC_KEEP_DAILY" => "7",
|
|
12
|
+
"RESTIC_KEEP_WEEKLY" => "4",
|
|
13
|
+
"RESTIC_KEEP_MONTHLY" => "6",
|
|
14
|
+
"RESTIC_KEEP_YEARLY" => "2"
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
SUSPICIOUS_BACKUP_PATHS = %w[/ /var /etc /root /usr /bin /sbin /boot /dev /proc /sys /run].freeze
|
|
18
|
+
|
|
19
|
+
attr_reader :env
|
|
20
|
+
|
|
21
|
+
def initialize(env: ENV)
|
|
22
|
+
@env = env.to_h
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def app_name
|
|
26
|
+
value("APP_NAME")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def app_name!
|
|
30
|
+
required!("APP_NAME")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def restic_repository
|
|
34
|
+
value("RESTIC_REPOSITORY")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def restic_password
|
|
38
|
+
value("RESTIC_PASSWORD")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def restic_init_if_missing?
|
|
42
|
+
truthy?("RESTIC_INIT_IF_MISSING")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def check_after_backup?
|
|
46
|
+
truthy?("RESTIC_CHECK_AFTER_BACKUP")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def forget_after_backup?
|
|
50
|
+
!falsey?("RESTIC_FORGET_AFTER_BACKUP")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def check_read_data_subset
|
|
54
|
+
value("RESTIC_CHECK_READ_DATA_SUBSET")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def allow_restore?
|
|
58
|
+
truthy?("KAMAL_BACKUP_ALLOW_RESTORE")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def allow_production_restore?
|
|
62
|
+
truthy?("KAMAL_BACKUP_ALLOW_PRODUCTION_RESTORE")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def allow_in_place_file_restore?
|
|
66
|
+
truthy?("KAMAL_BACKUP_ALLOW_IN_PLACE_FILE_RESTORE")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def allow_suspicious_backup_paths?
|
|
70
|
+
truthy?("KAMAL_BACKUP_ALLOW_SUSPICIOUS_PATHS")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def backup_schedule_seconds
|
|
74
|
+
integer("BACKUP_SCHEDULE_SECONDS", 86_400, minimum: 1)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def backup_start_delay_seconds
|
|
78
|
+
integer("BACKUP_START_DELAY_SECONDS", 0, minimum: 0)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def state_dir
|
|
82
|
+
value("KAMAL_BACKUP_STATE_DIR") || "/var/lib/kamal-backup"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def last_check_path
|
|
86
|
+
File.join(state_dir, "last_check.json")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def backup_paths
|
|
90
|
+
raw = value("BACKUP_PATHS").to_s
|
|
91
|
+
raw.split(/[\n:]+/).map(&:strip).reject(&:empty?)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def backup_path_label(path)
|
|
95
|
+
label = path.to_s.sub(%r{\A/+}, "").gsub(%r{[^A-Za-z0-9_.-]+}, "-")
|
|
96
|
+
label.empty? ? "root" : label
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def database_adapter
|
|
100
|
+
explicit = value("DATABASE_ADAPTER")
|
|
101
|
+
return normalize_adapter(explicit) if explicit
|
|
102
|
+
|
|
103
|
+
url = value("DATABASE_URL")
|
|
104
|
+
if url
|
|
105
|
+
scheme = URI.parse(url).scheme rescue nil
|
|
106
|
+
detected = normalize_adapter(scheme)
|
|
107
|
+
return detected if detected
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
return "sqlite" if value("SQLITE_DATABASE_PATH")
|
|
111
|
+
|
|
112
|
+
nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def retention
|
|
116
|
+
DEFAULT_RETENTION.each_with_object({}) do |(key, default), result|
|
|
117
|
+
result[key] = value(key) || default
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def retention_args
|
|
122
|
+
retention.each_with_object([]) do |(key, raw), args|
|
|
123
|
+
next if raw.to_s.empty?
|
|
124
|
+
|
|
125
|
+
number = Integer(raw)
|
|
126
|
+
next if number <= 0
|
|
127
|
+
|
|
128
|
+
flag = "--#{key.sub("RESTIC_KEEP_", "keep-").downcase.tr("_", "-")}"
|
|
129
|
+
args.concat([flag, number.to_s])
|
|
130
|
+
rescue ArgumentError
|
|
131
|
+
raise ConfigurationError, "#{key} must be an integer"
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def validate_for_restic!
|
|
136
|
+
app_name!
|
|
137
|
+
required!("RESTIC_REPOSITORY")
|
|
138
|
+
required!("RESTIC_PASSWORD")
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def validate_for_backup!
|
|
142
|
+
validate_for_restic!
|
|
143
|
+
validate_database_backup!
|
|
144
|
+
validate_backup_paths!
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def validate_database_backup!
|
|
148
|
+
case database_adapter
|
|
149
|
+
when "postgres"
|
|
150
|
+
unless value("DATABASE_URL") || value("PGDATABASE")
|
|
151
|
+
raise ConfigurationError, "PostgreSQL backup requires DATABASE_URL or PGDATABASE/libpq environment"
|
|
152
|
+
end
|
|
153
|
+
when "mysql"
|
|
154
|
+
unless value("DATABASE_URL") || value("MYSQL_DATABASE") || value("MARIADB_DATABASE")
|
|
155
|
+
raise ConfigurationError, "MySQL backup requires DATABASE_URL or MYSQL_DATABASE/MARIADB_DATABASE"
|
|
156
|
+
end
|
|
157
|
+
when "sqlite"
|
|
158
|
+
path = required!("SQLITE_DATABASE_PATH")
|
|
159
|
+
raise ConfigurationError, "SQLITE_DATABASE_PATH does not exist: #{path}" unless File.file?(path)
|
|
160
|
+
else
|
|
161
|
+
raise ConfigurationError, "DATABASE_ADAPTER is required or must be detectable from DATABASE_URL/SQLITE_DATABASE_PATH"
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def validate_backup_paths!
|
|
166
|
+
paths = backup_paths
|
|
167
|
+
raise ConfigurationError, "BACKUP_PATHS must contain at least one path" if paths.empty?
|
|
168
|
+
|
|
169
|
+
paths.each do |path|
|
|
170
|
+
expanded = File.expand_path(path)
|
|
171
|
+
if SUSPICIOUS_BACKUP_PATHS.include?(expanded) && !allow_suspicious_backup_paths?
|
|
172
|
+
raise ConfigurationError, "refusing suspicious backup path #{expanded}; set KAMAL_BACKUP_ALLOW_SUSPICIOUS_PATHS=true to override"
|
|
173
|
+
end
|
|
174
|
+
raise ConfigurationError, "backup path does not exist: #{path}" unless File.exist?(path)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def validate_restore_allowed!
|
|
179
|
+
return if allow_restore?
|
|
180
|
+
|
|
181
|
+
raise ConfigurationError, "restore commands require KAMAL_BACKUP_ALLOW_RESTORE=true"
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def validate_file_restore_target!(target)
|
|
185
|
+
raise ConfigurationError, "restore target cannot be empty" if target.to_s.strip.empty?
|
|
186
|
+
|
|
187
|
+
expanded_target = File.expand_path(target)
|
|
188
|
+
raise ConfigurationError, "refusing to restore files to /" if expanded_target == "/"
|
|
189
|
+
|
|
190
|
+
in_place = backup_paths.any? do |path|
|
|
191
|
+
expanded_path = File.expand_path(path)
|
|
192
|
+
expanded_target == expanded_path || expanded_path.start_with?(expanded_target + "/") || expanded_target.start_with?(expanded_path + "/")
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
if in_place && !allow_in_place_file_restore?
|
|
196
|
+
raise ConfigurationError, "refusing in-place file restore to #{expanded_target}; set KAMAL_BACKUP_ALLOW_IN_PLACE_FILE_RESTORE=true to override"
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
expanded_target
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def validate_database_restore_target!(target)
|
|
203
|
+
raise ConfigurationError, "restore database target is required" if target.to_s.strip.empty?
|
|
204
|
+
|
|
205
|
+
if production_like_target?(target) && !allow_production_restore?
|
|
206
|
+
raise ConfigurationError, "refusing production-looking restore target #{target}; set KAMAL_BACKUP_ALLOW_PRODUCTION_RESTORE=true to override"
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def production_like_target?(target)
|
|
211
|
+
target = target.to_s
|
|
212
|
+
source_targets = [
|
|
213
|
+
value("DATABASE_URL"),
|
|
214
|
+
value("SQLITE_DATABASE_PATH"),
|
|
215
|
+
value("PGDATABASE"),
|
|
216
|
+
value("MYSQL_DATABASE"),
|
|
217
|
+
value("MARIADB_DATABASE")
|
|
218
|
+
].compact
|
|
219
|
+
|
|
220
|
+
return true if source_targets.include?(target)
|
|
221
|
+
|
|
222
|
+
lowered = target.downcase
|
|
223
|
+
lowered.include?("production") ||
|
|
224
|
+
lowered.match?(%r{(^|[/_.:-])prod([/_.:-]|$)}) ||
|
|
225
|
+
lowered.match?(%r{(^|[/_.:-])live([/_.:-]|$)})
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def value(key)
|
|
229
|
+
raw = env[key]
|
|
230
|
+
return nil if raw.nil?
|
|
231
|
+
|
|
232
|
+
stripped = raw.to_s.strip
|
|
233
|
+
stripped.empty? ? nil : stripped
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def required!(key)
|
|
237
|
+
value(key) || raise(ConfigurationError, "#{key} is required")
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def truthy?(key)
|
|
241
|
+
%w[1 true yes y on].include?(value(key).to_s.downcase)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def falsey?(key)
|
|
245
|
+
%w[0 false no n off].include?(value(key).to_s.downcase)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
private
|
|
249
|
+
|
|
250
|
+
def integer(key, default, minimum:)
|
|
251
|
+
raw = value(key)
|
|
252
|
+
number = raw ? Integer(raw) : default
|
|
253
|
+
raise ConfigurationError, "#{key} must be >= #{minimum}" if number < minimum
|
|
254
|
+
|
|
255
|
+
number
|
|
256
|
+
rescue ArgumentError
|
|
257
|
+
raise ConfigurationError, "#{key} must be an integer"
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def normalize_adapter(value)
|
|
261
|
+
case value.to_s.downcase
|
|
262
|
+
when "postgres", "postgresql"
|
|
263
|
+
"postgres"
|
|
264
|
+
when "mysql", "mysql2", "mariadb"
|
|
265
|
+
"mysql"
|
|
266
|
+
when "sqlite", "sqlite3"
|
|
267
|
+
"sqlite"
|
|
268
|
+
else
|
|
269
|
+
nil
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
require_relative "../command"
|
|
2
|
+
require_relative "../errors"
|
|
3
|
+
|
|
4
|
+
module KamalBackup
|
|
5
|
+
module Databases
|
|
6
|
+
class Base
|
|
7
|
+
attr_reader :config, :redactor
|
|
8
|
+
|
|
9
|
+
def initialize(config, redactor:)
|
|
10
|
+
@config = config
|
|
11
|
+
@redactor = redactor
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def backup(restic, timestamp)
|
|
15
|
+
restic.backup_command_output(
|
|
16
|
+
dump_command,
|
|
17
|
+
filename: database_filename(timestamp),
|
|
18
|
+
tags: backup_tags(timestamp)
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def restore(restic, snapshot, filename)
|
|
23
|
+
validate_restore_target!
|
|
24
|
+
restic.dump_file_to_command(snapshot, filename, restore_command)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def database_filename(timestamp)
|
|
28
|
+
app = config.app_name.gsub(/[^A-Za-z0-9_.-]+/, "-")
|
|
29
|
+
"databases-#{app}-#{adapter_name}-#{timestamp}.#{dump_extension}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def backup_tags(timestamp)
|
|
33
|
+
["type:database", "adapter:#{adapter_name}", "run:#{timestamp}"]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def adapter_name
|
|
37
|
+
raise NotImplementedError
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def dump_extension
|
|
41
|
+
raise NotImplementedError
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def dump_command
|
|
45
|
+
raise NotImplementedError
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def restore_command
|
|
49
|
+
raise NotImplementedError
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def validate_restore_target!
|
|
53
|
+
config.validate_database_restore_target!(restore_target_identifier)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def restore_target_identifier
|
|
57
|
+
raise NotImplementedError
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def value(key)
|
|
63
|
+
config.value(key)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def executable_available?(name)
|
|
67
|
+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |dir|
|
|
68
|
+
path = File.join(dir, name)
|
|
69
|
+
File.executable?(path) && !File.directory?(path)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
require_relative "base"
|
|
3
|
+
|
|
4
|
+
module KamalBackup
|
|
5
|
+
module Databases
|
|
6
|
+
class Mysql < Base
|
|
7
|
+
def adapter_name
|
|
8
|
+
"mysql"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def dump_extension
|
|
12
|
+
"sql"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def dump_command
|
|
16
|
+
connection = backup_connection
|
|
17
|
+
argv = [
|
|
18
|
+
dump_binary,
|
|
19
|
+
"--single-transaction",
|
|
20
|
+
"--quick",
|
|
21
|
+
"--routines",
|
|
22
|
+
"--triggers",
|
|
23
|
+
"--events"
|
|
24
|
+
] + connection_args(connection)
|
|
25
|
+
argv << connection.fetch(:database)
|
|
26
|
+
CommandSpec.new(argv: argv, env: password_env(connection))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def restore_command
|
|
30
|
+
connection = restore_connection
|
|
31
|
+
argv = [client_binary] + connection_args(connection)
|
|
32
|
+
argv << connection.fetch(:database)
|
|
33
|
+
CommandSpec.new(argv: argv, env: password_env(connection))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def restore_target_identifier
|
|
37
|
+
connection = restore_connection
|
|
38
|
+
[connection[:host], connection[:database]].compact.join("/")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def dump_binary
|
|
44
|
+
value("MYSQL_DUMP_BIN") || (executable_available?("mariadb-dump") ? "mariadb-dump" : "mysqldump")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def client_binary
|
|
48
|
+
value("MYSQL_CLIENT_BIN") || (executable_available?("mariadb") ? "mariadb" : "mysql")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def backup_connection
|
|
52
|
+
if value("DATABASE_URL")
|
|
53
|
+
parse_url(value("DATABASE_URL"))
|
|
54
|
+
else
|
|
55
|
+
connection_from_env("")
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def restore_connection
|
|
60
|
+
if value("RESTORE_DATABASE_URL")
|
|
61
|
+
parse_url(value("RESTORE_DATABASE_URL"))
|
|
62
|
+
else
|
|
63
|
+
connection_from_env("RESTORE_")
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def connection_from_env(prefix)
|
|
68
|
+
database = value("#{prefix}MYSQL_DATABASE") || value("#{prefix}MARIADB_DATABASE")
|
|
69
|
+
raise ConfigurationError, "#{prefix}MYSQL_DATABASE or #{prefix}MARIADB_DATABASE is required" unless database
|
|
70
|
+
|
|
71
|
+
{
|
|
72
|
+
host: value("#{prefix}MYSQL_HOST") || value("#{prefix}MARIADB_HOST"),
|
|
73
|
+
port: value("#{prefix}MYSQL_PORT") || value("#{prefix}MARIADB_PORT"),
|
|
74
|
+
user: value("#{prefix}MYSQL_USER") || value("#{prefix}MARIADB_USER"),
|
|
75
|
+
password: value("#{prefix}MYSQL_PWD") || value("#{prefix}MYSQL_PASSWORD") || value("#{prefix}MARIADB_PASSWORD"),
|
|
76
|
+
database: database
|
|
77
|
+
}
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def parse_url(url)
|
|
81
|
+
uri = URI.parse(url)
|
|
82
|
+
database = uri.path.to_s.sub(%r{\A/}, "")
|
|
83
|
+
raise ConfigurationError, "database name is missing in #{uri.scheme} DATABASE_URL" if database.empty?
|
|
84
|
+
|
|
85
|
+
{
|
|
86
|
+
host: uri.host,
|
|
87
|
+
port: uri.port,
|
|
88
|
+
user: uri.user ? URI.decode_www_form_component(uri.user) : nil,
|
|
89
|
+
password: uri.password ? URI.decode_www_form_component(uri.password) : nil,
|
|
90
|
+
database: URI.decode_www_form_component(database)
|
|
91
|
+
}
|
|
92
|
+
rescue URI::InvalidURIError => e
|
|
93
|
+
raise ConfigurationError, "invalid database URL: #{e.message}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def connection_args(connection)
|
|
97
|
+
args = []
|
|
98
|
+
args.concat(["--host", connection[:host]]) if connection[:host]
|
|
99
|
+
args.concat(["--port", connection[:port].to_s]) if connection[:port]
|
|
100
|
+
args.concat(["--user", connection[:user]]) if connection[:user]
|
|
101
|
+
args
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def password_env(connection)
|
|
105
|
+
connection[:password] ? { "MYSQL_PWD" => connection[:password] } : {}
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
require_relative "base"
|
|
3
|
+
|
|
4
|
+
module KamalBackup
|
|
5
|
+
module Databases
|
|
6
|
+
class Postgres < Base
|
|
7
|
+
SOURCE_ENV_KEYS = %w[
|
|
8
|
+
PGHOST
|
|
9
|
+
PGPORT
|
|
10
|
+
PGUSER
|
|
11
|
+
PGPASSWORD
|
|
12
|
+
PGDATABASE
|
|
13
|
+
PGSSLMODE
|
|
14
|
+
PGSSLROOTCERT
|
|
15
|
+
PGSSLCERT
|
|
16
|
+
PGSSLKEY
|
|
17
|
+
PGCONNECT_TIMEOUT
|
|
18
|
+
PGSERVICE
|
|
19
|
+
PGPASSFILE
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
RESTORE_ENV_MAP = {
|
|
23
|
+
"RESTORE_PGHOST" => "PGHOST",
|
|
24
|
+
"RESTORE_PGPORT" => "PGPORT",
|
|
25
|
+
"RESTORE_PGUSER" => "PGUSER",
|
|
26
|
+
"RESTORE_PGPASSWORD" => "PGPASSWORD",
|
|
27
|
+
"RESTORE_PGDATABASE" => "PGDATABASE",
|
|
28
|
+
"RESTORE_PGSSLMODE" => "PGSSLMODE"
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
def adapter_name
|
|
32
|
+
"postgres"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def dump_extension
|
|
36
|
+
"pgdump"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def dump_command
|
|
40
|
+
argv = %w[pg_dump --format=custom --no-owner --no-privileges]
|
|
41
|
+
CommandSpec.new(argv: argv, env: backup_env)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def restore_command
|
|
45
|
+
connection = restore_connection
|
|
46
|
+
database = connection.fetch("PGDATABASE")
|
|
47
|
+
|
|
48
|
+
argv = %w[pg_restore --clean --if-exists --no-owner --no-privileges --dbname]
|
|
49
|
+
argv << database
|
|
50
|
+
CommandSpec.new(argv: argv, env: connection)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def restore_target_identifier
|
|
54
|
+
value("RESTORE_DATABASE_URL") || value("RESTORE_PGDATABASE")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def backup_env
|
|
60
|
+
if value("DATABASE_URL")
|
|
61
|
+
connection_from_url(value("DATABASE_URL"), "DATABASE_URL")
|
|
62
|
+
else
|
|
63
|
+
prefixed_env("", SOURCE_ENV_KEYS)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def restore_connection
|
|
68
|
+
if value("RESTORE_DATABASE_URL")
|
|
69
|
+
connection_from_url(value("RESTORE_DATABASE_URL"), "RESTORE_DATABASE_URL")
|
|
70
|
+
else
|
|
71
|
+
connection = restore_env
|
|
72
|
+
raise ConfigurationError, "RESTORE_DATABASE_URL or RESTORE_PGDATABASE is required for PostgreSQL restore" unless connection["PGDATABASE"]
|
|
73
|
+
|
|
74
|
+
connection
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def restore_env
|
|
79
|
+
RESTORE_ENV_MAP.each_with_object({}) do |(source, target), env|
|
|
80
|
+
env[target] = value(source) if value(source)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def prefixed_env(prefix, keys)
|
|
85
|
+
keys.each_with_object({}) do |key, env|
|
|
86
|
+
env[key] = value("#{prefix}#{key}") if value("#{prefix}#{key}")
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def connection_from_url(url, name)
|
|
91
|
+
uri = URI.parse(url)
|
|
92
|
+
unless %w[postgres postgresql].include?(uri.scheme)
|
|
93
|
+
raise ConfigurationError, "#{name} must use postgres:// or postgresql://"
|
|
94
|
+
end
|
|
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]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
env
|
|
119
|
+
rescue URI::InvalidURIError => e
|
|
120
|
+
raise ConfigurationError, "invalid #{name}: #{e.message}"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
require "tempfile"
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module KamalBackup
|
|
6
|
+
module Databases
|
|
7
|
+
class Sqlite < Base
|
|
8
|
+
def adapter_name
|
|
9
|
+
"sqlite"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def dump_extension
|
|
13
|
+
"sqlite3"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def backup(restic, timestamp)
|
|
17
|
+
source = sqlite_source
|
|
18
|
+
Tempfile.create(["kamal-backup-", ".sqlite3"]) do |tempfile|
|
|
19
|
+
tempfile.close
|
|
20
|
+
Command.capture(
|
|
21
|
+
CommandSpec.new(argv: ["sqlite3", source, ".backup #{sqlite_literal(tempfile.path)}"]),
|
|
22
|
+
redactor: redactor
|
|
23
|
+
)
|
|
24
|
+
restic.backup_file_content(
|
|
25
|
+
tempfile.path,
|
|
26
|
+
filename: database_filename(timestamp),
|
|
27
|
+
tags: backup_tags(timestamp)
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def restore(restic, snapshot, filename)
|
|
33
|
+
validate_restore_target!
|
|
34
|
+
restic.dump_file_to_path(snapshot, filename, restore_target)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def dump_command
|
|
38
|
+
raise NotImplementedError, "SQLite backup uses .backup into a temporary file"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def restore_command
|
|
42
|
+
raise NotImplementedError, "SQLite restore writes the database file directly"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def restore_target_identifier
|
|
46
|
+
restore_target
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def sqlite_source
|
|
52
|
+
config.required!("SQLITE_DATABASE_PATH")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def restore_target
|
|
56
|
+
config.required!("RESTORE_SQLITE_DATABASE_PATH")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def validate_restore_target!
|
|
60
|
+
source = File.expand_path(sqlite_source)
|
|
61
|
+
target = File.expand_path(restore_target)
|
|
62
|
+
if source == target && !config.allow_in_place_file_restore?
|
|
63
|
+
raise ConfigurationError, "refusing in-place SQLite restore to #{target}; set KAMAL_BACKUP_ALLOW_IN_PLACE_FILE_RESTORE=true to override"
|
|
64
|
+
end
|
|
65
|
+
super
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def sqlite_literal(value)
|
|
69
|
+
"'#{value.to_s.gsub("'", "''")}'"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module KamalBackup
|
|
2
|
+
class Error < StandardError; end
|
|
3
|
+
class ConfigurationError < Error; end
|
|
4
|
+
|
|
5
|
+
class CommandError < Error
|
|
6
|
+
attr_reader :command, :status, :stdout, :stderr
|
|
7
|
+
|
|
8
|
+
def initialize(message, command:, status: nil, stdout: "", stderr: "")
|
|
9
|
+
super(message)
|
|
10
|
+
@command = command
|
|
11
|
+
@status = status
|
|
12
|
+
@stdout = stdout.to_s
|
|
13
|
+
@stderr = stderr.to_s
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|