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.
@@ -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