kamal-backup 0.4.2 → 0.5.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 +4 -4
- data/lib/kamal_backup/app.rb +17 -0
- data/lib/kamal_backup/cli/helpers.rb +7 -0
- data/lib/kamal_backup/config.rb +4 -0
- data/lib/kamal_backup/config_file.rb +1 -0
- data/lib/kamal_backup/databases/postgres.rb +54 -0
- data/lib/kamal_backup/scheduler.rb +15 -0
- 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: 0ba0b661680e0b8885935b4697cc40052f1584a2c39b0646d2e939d5874f043a
|
|
4
|
+
data.tar.gz: 3ac2499e70f17ac20b30eaba0bc507e6558fbf1406d8423e32fb1f44c642e3b8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 61ff158f87e67167f446b540af94a4193657bf02f8a435d7ffb9465092c32cde6a9b82b4a616fb8571454ddde085915a051be312748ef0d1e2c8647d80f159ee
|
|
7
|
+
data.tar.gz: e872f59bb07fa089fcf4477c6b2d1f315d8a6d1fed164366d52a8475753c90a951caf46b929ff71ec4b5056160b3f8f8661a833ce0c8ecfaa2a0269d2c03b253
|
data/lib/kamal_backup/app.rb
CHANGED
|
@@ -32,6 +32,7 @@ module KamalBackup
|
|
|
32
32
|
def backup(force: false)
|
|
33
33
|
started_at = Time.now.utc
|
|
34
34
|
config.validate_backup
|
|
35
|
+
return disabled_backup_result(started_at) unless config.backup_enabled? || force
|
|
35
36
|
return skipped_backup_result(started_at) unless force || backup_due?(started_at)
|
|
36
37
|
|
|
37
38
|
restic.ensure_repository
|
|
@@ -136,6 +137,18 @@ module KamalBackup
|
|
|
136
137
|
due_at.nil? || now >= due_at
|
|
137
138
|
end
|
|
138
139
|
|
|
140
|
+
def disabled_backup_result(now)
|
|
141
|
+
Schema.record(
|
|
142
|
+
kind: 'backup_result',
|
|
143
|
+
status: 'skipped',
|
|
144
|
+
reason: 'disabled',
|
|
145
|
+
last_backup_at: last_backup_finished_at&.iso8601,
|
|
146
|
+
next_backup_at: nil,
|
|
147
|
+
force_command: 'kamal-backup backup --force',
|
|
148
|
+
finished_at: now.iso8601
|
|
149
|
+
)
|
|
150
|
+
end
|
|
151
|
+
|
|
139
152
|
def skipped_backup_result(now)
|
|
140
153
|
Schema.record(
|
|
141
154
|
kind: 'backup_result',
|
|
@@ -467,6 +480,10 @@ module KamalBackup
|
|
|
467
480
|
config.validate_restic
|
|
468
481
|
config.validate_database_backup
|
|
469
482
|
config.validate_backup_paths
|
|
483
|
+
# Check writability up front. This used to be discovered only after
|
|
484
|
+
# restic had pulled the entire file snapshot, so a read-only mount cost a
|
|
485
|
+
# full download before failing — the wrong moment to learn about it.
|
|
486
|
+
config.backup_paths.each { |path| ensure_writable_restore_target(path) }
|
|
470
487
|
end
|
|
471
488
|
|
|
472
489
|
def validate_production_drill(file_target, database_name, sqlite_path)
|
|
@@ -140,6 +140,13 @@ module KamalBackup
|
|
|
140
140
|
end
|
|
141
141
|
|
|
142
142
|
def print_backup_result(result)
|
|
143
|
+
if result[:status] == 'skipped' && result[:reason] == 'disabled'
|
|
144
|
+
puts('Backups are disabled for this app (backup.enabled is false).')
|
|
145
|
+
puts('Set backup.enabled to true and redeploy the accessory to resume scheduled backups.')
|
|
146
|
+
puts("Run `#{result.fetch(:force_command)}` to take one anyway.")
|
|
147
|
+
return
|
|
148
|
+
end
|
|
149
|
+
|
|
143
150
|
if result[:status] == 'skipped'
|
|
144
151
|
puts("No backup due. Last backup finished at #{result.fetch(:last_backup_at)}.")
|
|
145
152
|
puts("Next backup is due at #{result.fetch(:next_backup_at)}.")
|
data/lib/kamal_backup/config.rb
CHANGED
|
@@ -168,6 +168,10 @@ module KamalBackup
|
|
|
168
168
|
truthy?('KAMAL_BACKUP_ALLOW_SUSPICIOUS_PATHS')
|
|
169
169
|
end
|
|
170
170
|
|
|
171
|
+
def backup_enabled?
|
|
172
|
+
!falsey?('BACKUP_ENABLED')
|
|
173
|
+
end
|
|
174
|
+
|
|
171
175
|
def backup_schedule_seconds
|
|
172
176
|
integer('BACKUP_SCHEDULE_SECONDS', 86_400, minimum: 1)
|
|
173
177
|
end
|
|
@@ -271,6 +271,7 @@ module KamalBackup
|
|
|
271
271
|
hash = require_mapping(raw_value, "#{@path} backup")
|
|
272
272
|
env = {}
|
|
273
273
|
env['BACKUP_SCHEDULE_SECONDS'] = normalize_duration(hash['schedule'], "#{@path} backup.schedule") if hash.key?('schedule')
|
|
274
|
+
env['BACKUP_ENABLED'] = normalize_value(hash['enabled']) if hash.key?('enabled')
|
|
274
275
|
env.compact
|
|
275
276
|
end
|
|
276
277
|
|
|
@@ -34,6 +34,28 @@ module KamalBackup
|
|
|
34
34
|
CommandSpec.new(argv: argv, env: current_connection)
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
+
# Replace the target schema before restoring.
|
|
38
|
+
#
|
|
39
|
+
# pg_restore --clean emits DROP TABLE for each object in the dump, but
|
|
40
|
+
# PostgreSQL refuses to drop a table another table still references, and
|
|
41
|
+
# the dump's ordering cannot account for constraints that only exist in
|
|
42
|
+
# the target. Restoring over a schema Rails already created — which is
|
|
43
|
+
# every restore onto a freshly deployed host, since Kamal runs db:prepare
|
|
44
|
+
# on boot — leaves the drops failing, the CREATEs failing as "already
|
|
45
|
+
# exists", the COPYs never running, and the foreign keys rejected because
|
|
46
|
+
# the tables they point at are still empty.
|
|
47
|
+
#
|
|
48
|
+
# Dropping the schema first sidesteps the ordering problem entirely. This
|
|
49
|
+
# only runs for restore-to-current, which already means "replace this
|
|
50
|
+
# database"; scratch restores target a separate database and are
|
|
51
|
+
# untouched.
|
|
52
|
+
def restore_to_current(restic, snapshot, filename)
|
|
53
|
+
reset_current_schema
|
|
54
|
+
result = super
|
|
55
|
+
ensure_restore_reported_no_errors(result)
|
|
56
|
+
result
|
|
57
|
+
end
|
|
58
|
+
|
|
37
59
|
def current_restore_command
|
|
38
60
|
connection = current_connection
|
|
39
61
|
database = connection.fetch('PGDATABASE')
|
|
@@ -61,6 +83,38 @@ module KamalBackup
|
|
|
61
83
|
|
|
62
84
|
private
|
|
63
85
|
|
|
86
|
+
# pg_restore exits 0 even when individual objects fail, reporting only
|
|
87
|
+
# "errors ignored on restore: N" on stderr. A restore that half-applied
|
|
88
|
+
# and claimed success is worse than one that failed, because the damage
|
|
89
|
+
# is only discovered later, from the data.
|
|
90
|
+
def ensure_restore_reported_no_errors(result)
|
|
91
|
+
stderr = result.respond_to?(:stderr) ? result.stderr.to_s : ''
|
|
92
|
+
match = stderr.match(/errors ignored on restore:\s*(\d+)/i)
|
|
93
|
+
return if match.nil? || match[1].to_i.zero?
|
|
94
|
+
|
|
95
|
+
raise CommandError.new(
|
|
96
|
+
"pg_restore reported #{match[1]} ignored error(s); the database is only partially restored. " \
|
|
97
|
+
'Inspect the output above, resolve the cause, and restore again.',
|
|
98
|
+
command: current_restore_command,
|
|
99
|
+
stderr: stderr
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def reset_current_schema
|
|
104
|
+
connection = current_connection
|
|
105
|
+
argv = ['psql', '--quiet', '--no-psqlrc', '--set', 'ON_ERROR_STOP=1', '--command', RESET_SCHEMA_SQL]
|
|
106
|
+
Command.capture(CommandSpec.new(argv: argv, env: connection), redactor: redactor)
|
|
107
|
+
rescue CommandError => e
|
|
108
|
+
raise CommandError.new(
|
|
109
|
+
"failed to reset the public schema before restoring: #{e.message}",
|
|
110
|
+
command: e.command,
|
|
111
|
+
stderr: e.stderr
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
RESET_SCHEMA_SQL = 'DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public;'
|
|
116
|
+
private_constant :RESET_SCHEMA_SQL
|
|
117
|
+
|
|
64
118
|
def validate_scratch_restore_target(target)
|
|
65
119
|
raise ConfigurationError, 'scratch database must differ from the current PostgreSQL database' if current_connection.fetch('PGDATABASE') == target
|
|
66
120
|
|
|
@@ -15,6 +15,17 @@ module KamalBackup
|
|
|
15
15
|
$stderr.sync = true
|
|
16
16
|
|
|
17
17
|
install_signal_handlers
|
|
18
|
+
|
|
19
|
+
unless @config.backup_enabled?
|
|
20
|
+
# Stay up rather than exit: the accessory is still how restore, list, check and
|
|
21
|
+
# evidence reach the repository, and exiting would restart-loop under Kamal's
|
|
22
|
+
# `--restart unless-stopped`. Set backup.enabled to true and redeploy to resume.
|
|
23
|
+
log('scheduled backups are disabled (backup.enabled is false); no backups will be taken')
|
|
24
|
+
idle_until_stopped
|
|
25
|
+
log("scheduler stopped at #{Time.now.utc.iso8601}")
|
|
26
|
+
return
|
|
27
|
+
end
|
|
28
|
+
|
|
18
29
|
sleep_interruptibly(@config.backup_start_delay_seconds)
|
|
19
30
|
|
|
20
31
|
until @stop
|
|
@@ -43,6 +54,10 @@ module KamalBackup
|
|
|
43
54
|
end
|
|
44
55
|
end
|
|
45
56
|
|
|
57
|
+
def idle_until_stopped
|
|
58
|
+
sleep 1 until @stop
|
|
59
|
+
end
|
|
60
|
+
|
|
46
61
|
def sleep_interruptibly(seconds)
|
|
47
62
|
deadline = Time.now + seconds
|
|
48
63
|
sleep([deadline - Time.now, 1].min) while !@stop && Time.now < deadline
|
data/lib/kamal_backup/version.rb
CHANGED