boring-backup 0.4.0 → 0.5.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/CHANGELOG.md +10 -0
- data/README.md +7 -0
- data/lib/boring_backup/cli.rb +334 -3
- data/lib/boring_backup/commands/backup.rb +7 -6
- data/lib/boring_backup/commands/doctor.rb +135 -0
- data/lib/boring_backup/configuration.rb +17 -4
- data/lib/boring_backup/stores/s3.rb +6 -0
- data/lib/boring_backup/stores/store.rb +8 -0
- data/lib/boring_backup/stores.rb +0 -1
- data/lib/boring_backup/tee.rb +10 -1
- data/lib/boring_backup/version.rb +1 -1
- data/lib/boring_backup.rb +11 -0
- metadata +35 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 90fcf205730a58bf45284b04cd2a7be2604158b5bcd983c2bd3bdaada0fb06c0
|
|
4
|
+
data.tar.gz: b17bae14e94295a24d68da3592b3baa238bc1a10fb76bcad464ae515bd7cc46c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0317b2fbf1d5768a0b0cfe28e351817d51065579dca37007d6236956c07f474c529297237358c36d4cd572d017bcb1f79209b5c5c4827fb036b169bb2ebc1339
|
|
7
|
+
data.tar.gz: a3481f64a9c95894830fb1bc92964c154a79796febe716c8e8c27ea09b5de110ce0613386adcdf3703d619a8794730a7470dd09189d504b55cc682b64bea3695
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.5.0] - 2026-07-14
|
|
4
|
+
|
|
5
|
+
- **`aws-sdk-s3` is no longer a dependency of the gem.** apps using the `:s3` store must add `gem
|
|
6
|
+
"aws-sdk-s3"` to their own `Gemfile`.
|
|
7
|
+
- New `bb install` command - wires up the initializer, the Solid Queue schedule and a `bin/bb` binstub
|
|
8
|
+
- New `bb doctor` command - checks pg_dump, versions, database, stores and schedule; exits non-zero on failure
|
|
9
|
+
- `bb backup` now shows a boot spinner, a live progress meter and a coloured summary
|
|
10
|
+
- `config.register(:s3)` no longer requires a block - the store configures itself from the environment
|
|
11
|
+
- `Commands::Backup.execute` accepts a `progress:` callback with the running byte count
|
|
12
|
+
|
|
3
13
|
## [0.4.0] - 2026-07-13
|
|
4
14
|
|
|
5
15
|
- **Renamed the gem from `noop-backup` to `boring-backup`.** Breaking, with no shims:
|
data/README.md
CHANGED
|
@@ -20,6 +20,13 @@ gem install boring-backup
|
|
|
20
20
|
|
|
21
21
|
The gem requires `pg_dump` to be installed on the machine that is running it.
|
|
22
22
|
|
|
23
|
+
Storage backends bring their own dependencies, which are not installed by default. To use
|
|
24
|
+
the `:s3` store, add the AWS SDK to your `Gemfile` as well:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
bundle add aws-sdk-s3
|
|
28
|
+
```
|
|
29
|
+
|
|
23
30
|
## Usage
|
|
24
31
|
|
|
25
32
|
### Automatic
|
data/lib/boring_backup/cli.rb
CHANGED
|
@@ -1,17 +1,348 @@
|
|
|
1
1
|
require "thor"
|
|
2
|
+
require "pastel"
|
|
3
|
+
require "tty-prompt"
|
|
4
|
+
require "tty-spinner"
|
|
2
5
|
|
|
3
6
|
module BoringBackup
|
|
4
7
|
class CLI < Thor
|
|
8
|
+
include Thor::Actions
|
|
9
|
+
|
|
10
|
+
INITIALIZER = "config/initializers/boring_backup.rb"
|
|
11
|
+
RECURRING = "config/recurring.yml"
|
|
12
|
+
BINSTUB = "bin/bb"
|
|
13
|
+
|
|
14
|
+
DEFAULT_SCHEDULE = "every day at 3am"
|
|
15
|
+
PRODUCTION_ANCHOR = /^production:[ \t]*\r?\n/
|
|
16
|
+
|
|
17
|
+
SPINNER = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
|
|
18
|
+
REDRAW_INTERVAL = 0.1
|
|
19
|
+
|
|
20
|
+
add_runtime_options!
|
|
21
|
+
|
|
5
22
|
def self.exit_on_failure? = true
|
|
6
23
|
|
|
24
|
+
def self.source_root = File.expand_path("templates", __dir__)
|
|
25
|
+
|
|
7
26
|
desc "backup", "Create and store a new backup"
|
|
8
27
|
def backup
|
|
9
|
-
BoringBackup.prepare!
|
|
10
|
-
|
|
28
|
+
spinner("Booting") { BoringBackup.prepare! }
|
|
29
|
+
|
|
30
|
+
say " #{pastel.bold("Boring Backup")} #{pastel.dim(BoringBackup.config.pg_env["PGDATABASE"])}"
|
|
31
|
+
|
|
32
|
+
result = stream_backup
|
|
33
|
+
|
|
34
|
+
result.store_results.each { |store_result| say " #{store_line(store_result)}" }
|
|
35
|
+
say "\n #{verdict(result)}\n"
|
|
36
|
+
|
|
37
|
+
exit 1 unless result.success?
|
|
38
|
+
rescue => e
|
|
39
|
+
say " #{pastel.red("✗")} #{e.message}\n"
|
|
40
|
+
exit 1
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
desc "doctor", "Check that backups are set up correctly"
|
|
44
|
+
def doctor
|
|
45
|
+
spinner("Booting") { BoringBackup.prepare! }
|
|
46
|
+
|
|
47
|
+
say " #{pastel.bold("Boring Backup")} #{pastel.dim("doctor · #{BoringBackup.environment}")}"
|
|
48
|
+
|
|
49
|
+
result = spinner("Running checks") { BoringBackup::Commands::Doctor.execute }
|
|
50
|
+
|
|
51
|
+
width = result.checks.map { |check| check.name.length }.max
|
|
52
|
+
|
|
53
|
+
result.checks.each { |check| say " #{check_line(check, width)}" }
|
|
54
|
+
say "\n #{doctor_verdict(result)}\n"
|
|
55
|
+
|
|
11
56
|
exit 1 unless result.success?
|
|
12
57
|
rescue => e
|
|
13
|
-
|
|
58
|
+
say " #{pastel.red("✗")} #{e.message}\n"
|
|
14
59
|
exit 1
|
|
15
60
|
end
|
|
61
|
+
|
|
62
|
+
desc "install", "Wire up recurring backups in this app"
|
|
63
|
+
def install
|
|
64
|
+
say "\n #{pastel.bold("Boring Backup")}\n\n"
|
|
65
|
+
|
|
66
|
+
preflight
|
|
67
|
+
|
|
68
|
+
store = configure_store
|
|
69
|
+
write_initializer(store)
|
|
70
|
+
install_schedule
|
|
71
|
+
install_binstub
|
|
72
|
+
|
|
73
|
+
say_next_steps
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def stream_backup
|
|
79
|
+
BoringBackup.config.silence_stdout!
|
|
80
|
+
|
|
81
|
+
@started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
82
|
+
@frame = 0
|
|
83
|
+
|
|
84
|
+
BoringBackup::Commands::Backup.execute(progress: meter)
|
|
85
|
+
ensure
|
|
86
|
+
clear_line
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def meter
|
|
90
|
+
return unless $stdout.tty?
|
|
91
|
+
|
|
92
|
+
->(bytes) { draw_meter(bytes) }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def draw_meter(bytes)
|
|
96
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
97
|
+
|
|
98
|
+
return if @drawn_at && now - @drawn_at < REDRAW_INTERVAL
|
|
99
|
+
|
|
100
|
+
@drawn_at = now
|
|
101
|
+
elapsed = now - @started
|
|
102
|
+
rate = elapsed.zero? ? 0 : bytes / elapsed
|
|
103
|
+
tick = SPINNER[@frame = (@frame + 1) % SPINNER.size]
|
|
104
|
+
|
|
105
|
+
$stdout.print "\r #{pastel.cyan(tick)} Dumping #{pastel.bold(human(bytes))} " \
|
|
106
|
+
"#{pastel.dim("· #{human(rate)}/s · #{elapsed.round}s")}\e[K"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def clear_line
|
|
110
|
+
$stdout.print "\r\e[K" if $stdout.tty?
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def store_line(store_result)
|
|
114
|
+
if store_result.success
|
|
115
|
+
"#{pastel.green("✓")} #{pastel.bold(store_result.store.to_s.ljust(6))} " \
|
|
116
|
+
"#{human(store_result.bytes)} in #{store_result.duration.round(1)}s #{pastel.dim("→ /#{store_result.key}")}"
|
|
117
|
+
else
|
|
118
|
+
"#{pastel.red("✗")} #{pastel.bold(store_result.store.to_s.ljust(6))} #{pastel.red(store_result.error.message)}"
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def verdict(result)
|
|
123
|
+
case result.status
|
|
124
|
+
when :success then pastel.green.bold("Backed up to #{result.store_results.size} store(s).")
|
|
125
|
+
when :partial_success then pastel.yellow.bold("Partial: #{result.store_results.count(&:success)}/#{result.store_results.size} stores succeeded.")
|
|
126
|
+
else pastel.red.bold("Backup failed. #{result.error&.message}")
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def human(bytes)
|
|
131
|
+
BoringBackup.utils.human_size(bytes)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
CHECK_MARKS = {
|
|
135
|
+
ok: ["✓", :green],
|
|
136
|
+
fail: ["✗", :red],
|
|
137
|
+
skip: ["–", :yellow]
|
|
138
|
+
}.freeze
|
|
139
|
+
|
|
140
|
+
def check_line(check, width)
|
|
141
|
+
mark, colour = CHECK_MARKS.fetch(check.status)
|
|
142
|
+
|
|
143
|
+
"#{pastel.decorate(mark, colour)} #{pastel.bold(check.name.ljust(width))} #{pastel.dim(check.detail.to_s)}"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def doctor_verdict(result)
|
|
147
|
+
return pastel.red.bold("#{result.failures.size} check(s) failed.") unless result.success?
|
|
148
|
+
|
|
149
|
+
skipped = result.checks.count { |check| check.status == :skip }
|
|
150
|
+
|
|
151
|
+
if skipped.zero?
|
|
152
|
+
pastel.green.bold("Everything checks out.")
|
|
153
|
+
else
|
|
154
|
+
pastel.green.bold("Checks passed.") + pastel.dim(" #{skipped} skipped — run this where the credentials live to verify the bucket.")
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def preflight
|
|
159
|
+
check "Rails app", rails? && "config/environment.rb"
|
|
160
|
+
check "Scheduler", solid_queue? && "Solid Queue (#{RECURRING})", hint: "no supported scheduler found"
|
|
161
|
+
check "Database", database, hint: "could not read ActiveRecord config"
|
|
162
|
+
check "Store", configured_store_summary, hint: "none configured"
|
|
163
|
+
|
|
164
|
+
say ""
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def check(label, value, hint: "not found")
|
|
168
|
+
mark = value ? pastel.green("✓") : pastel.yellow("✗")
|
|
169
|
+
detail = value ? pastel.dim(value.to_s) : pastel.dim(hint)
|
|
170
|
+
|
|
171
|
+
say " #{mark} #{label.to_s.ljust(10)} #{detail}"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def rails?
|
|
175
|
+
File.exist?("config/environment.rb")
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def solid_queue?
|
|
179
|
+
File.exist?(RECURRING) && gemfile_lock.include?("solid_queue")
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def gemfile_lock
|
|
183
|
+
@gemfile_lock ||= File.exist?("Gemfile.lock") ? read("Gemfile.lock") : ""
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def read(path)
|
|
187
|
+
File.read(path, mode: "r:UTF-8")
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def database
|
|
191
|
+
return @database if defined?(@database)
|
|
192
|
+
|
|
193
|
+
@database = spinner("Reading database config") do
|
|
194
|
+
BoringBackup.prepare!
|
|
195
|
+
BoringBackup.config.pg_env["PGDATABASE"]
|
|
196
|
+
rescue
|
|
197
|
+
nil
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def spinner(message)
|
|
202
|
+
return yield unless $stdout.tty?
|
|
203
|
+
|
|
204
|
+
spinner = TTY::Spinner.new(" #{pastel.dim(message)} :spinner", format: :dots, clear: true, output: $stdout)
|
|
205
|
+
spinner.auto_spin
|
|
206
|
+
|
|
207
|
+
yield
|
|
208
|
+
ensure
|
|
209
|
+
spinner&.stop
|
|
210
|
+
clear_line
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def env_store?
|
|
214
|
+
!ENV["AWS_S3_BUCKET"].to_s.empty?
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def configured_store_summary
|
|
218
|
+
return "S3 — s3://#{ENV["AWS_S3_BUCKET"]} (from ENV)" if env_store?
|
|
219
|
+
|
|
220
|
+
"S3 — #{INITIALIZER}" if File.exist?(INITIALIZER)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def configure_store
|
|
224
|
+
return if File.exist?(INITIALIZER)
|
|
225
|
+
return if env_store? && prompt.yes?("Use the S3 bucket already in ENV (#{ENV["AWS_S3_BUCKET"]})?")
|
|
226
|
+
|
|
227
|
+
prompt.select("Where should backups go?") do |menu|
|
|
228
|
+
menu.choice "S3 (or S3-compatible: R2, MinIO, Spaces)", :s3
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
{
|
|
232
|
+
bucket: prompt.ask("Bucket name:", required: true),
|
|
233
|
+
region: prompt.ask("Region:", default: ENV.fetch("AWS_REGION", "us-east-1"))
|
|
234
|
+
}
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def write_initializer(store)
|
|
238
|
+
unless store
|
|
239
|
+
if File.exist?(INITIALIZER)
|
|
240
|
+
say_status :identical, INITIALIZER, :blue
|
|
241
|
+
else
|
|
242
|
+
say_status :skip, "#{INITIALIZER} not needed — the S3 store reads its config from ENV", :blue
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
return
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
create_file INITIALIZER, initializer_body(store)
|
|
249
|
+
|
|
250
|
+
say_credentials_note
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def initializer_body(store)
|
|
254
|
+
<<~RUBY
|
|
255
|
+
BoringBackup.configure do |config|
|
|
256
|
+
config.register(:s3) do |store|
|
|
257
|
+
store.bucket = #{store[:bucket].inspect}
|
|
258
|
+
store.region = #{store[:region].inspect}
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
RUBY
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def say_credentials_note
|
|
265
|
+
say_status :note, "credentials come from AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY in the backup environment, or an IAM role", :blue
|
|
266
|
+
say " #{pastel.dim("Keep them out of #{INITIALIZER} — it's committed.")}\n"
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def install_schedule
|
|
270
|
+
return print_manual_schedule unless solid_queue?
|
|
271
|
+
|
|
272
|
+
recurring = read(RECURRING)
|
|
273
|
+
|
|
274
|
+
if recurring.include?("BoringBackup::BackupJob")
|
|
275
|
+
say_status :identical, "#{RECURRING} already runs BoringBackup::BackupJob", :blue
|
|
276
|
+
return
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
unless recurring.match?(PRODUCTION_ANCHOR)
|
|
280
|
+
say_status :skip, "#{RECURRING} has no `production:` block", :yellow
|
|
281
|
+
return print_manual_schedule
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
schedule = prompt.ask("Schedule:", default: DEFAULT_SCHEDULE)
|
|
285
|
+
|
|
286
|
+
return unless prompt.yes?("Add the backup job to #{RECURRING} under `production:`?")
|
|
287
|
+
|
|
288
|
+
inject_schedule(recurring, schedule)
|
|
289
|
+
|
|
290
|
+
say_status :insert, RECURRING, :green
|
|
291
|
+
say_status :note, "backups run in production only — dev and staging are untouched", :blue
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def inject_schedule(recurring, schedule)
|
|
295
|
+
entry = <<~YAML.gsub(/^(?=.)/, " ")
|
|
296
|
+
boring_backup:
|
|
297
|
+
class: BoringBackup::BackupJob
|
|
298
|
+
schedule: #{schedule}
|
|
299
|
+
YAML
|
|
300
|
+
|
|
301
|
+
updated = recurring.sub(PRODUCTION_ANCHOR) { |anchor| "#{anchor}#{entry}" }
|
|
302
|
+
|
|
303
|
+
File.write(RECURRING, updated, mode: "w:UTF-8")
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def print_manual_schedule
|
|
307
|
+
say "\n #{pastel.yellow("No supported scheduler detected.")} Run the backup from cron instead:\n\n"
|
|
308
|
+
say "#{pastel.dim(" 0 3 * * * cd /path/to/app && #{backup_command}")}\n\n"
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def install_binstub
|
|
312
|
+
return unless File.directory?("bin")
|
|
313
|
+
|
|
314
|
+
if binstub?
|
|
315
|
+
say_status :identical, BINSTUB, :blue
|
|
316
|
+
return
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
return unless prompt.yes?("Add a #{BINSTUB} binstub? (drops the `bundle exec` prefix)")
|
|
320
|
+
|
|
321
|
+
run "bundle binstubs boring-backup"
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def binstub?
|
|
325
|
+
File.exist?(BINSTUB)
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def backup_command
|
|
329
|
+
binstub? ? "#{BINSTUB} backup" : "bundle exec bb backup"
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def say_next_steps
|
|
333
|
+
say "\n #{pastel.bold("Next:")} #{pastel.cyan(doctor_command)} #{pastel.dim("# check the setup before it runs at 3am")}\n\n"
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def doctor_command
|
|
337
|
+
binstub? ? "#{BINSTUB} doctor" : "bundle exec bb doctor"
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def prompt
|
|
341
|
+
@prompt ||= TTY::Prompt.new
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def pastel
|
|
345
|
+
@pastel ||= Pastel.new
|
|
346
|
+
end
|
|
16
347
|
end
|
|
17
348
|
end
|
|
@@ -26,16 +26,17 @@ module BoringBackup::Commands
|
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
class Backup
|
|
29
|
-
def self.execute(report: BoringBackup.config.report
|
|
30
|
-
result = new.execute
|
|
29
|
+
def self.execute(report: BoringBackup.config.report?, progress: nil)
|
|
30
|
+
result = new(progress: progress).execute
|
|
31
31
|
|
|
32
32
|
result.report if report
|
|
33
33
|
|
|
34
34
|
result
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
-
def initialize
|
|
37
|
+
def initialize(progress: nil)
|
|
38
38
|
@key = generate_key
|
|
39
|
+
@progress = progress
|
|
39
40
|
@store_results = []
|
|
40
41
|
@sinks = []
|
|
41
42
|
end
|
|
@@ -45,8 +46,8 @@ module BoringBackup::Commands
|
|
|
45
46
|
|
|
46
47
|
commands = [config.dump_command]
|
|
47
48
|
|
|
48
|
-
# Pipe pg_dump through pv
|
|
49
|
-
commands << ["pv", "-btra"] if config.report? && system("which", "pv", out: File::NULL, err: File::NULL)
|
|
49
|
+
# Pipe pg_dump through pv only when the caller isn't counting bytes itself
|
|
50
|
+
commands << ["pv", "-btra"] if @progress.nil? && config.report? && system("which", "pv", out: File::NULL, err: File::NULL)
|
|
50
51
|
|
|
51
52
|
Open3.pipeline_r(*commands) do |last_stdout, wait_threads|
|
|
52
53
|
@sinks = config.stores.map do |store|
|
|
@@ -64,7 +65,7 @@ module BoringBackup::Commands
|
|
|
64
65
|
BoringBackup::Tee::Sink.new(store:, writer:, thread:)
|
|
65
66
|
end
|
|
66
67
|
|
|
67
|
-
sinks_fanout = BoringBackup::Tee.new(@sinks)
|
|
68
|
+
sinks_fanout = BoringBackup::Tee.new(@sinks, progress: @progress)
|
|
68
69
|
|
|
69
70
|
begin
|
|
70
71
|
IO.copy_stream(last_stdout, sinks_fanout)
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
module BoringBackup::Commands
|
|
2
|
+
Check = Struct.new(:name, :status, :detail, keyword_init: true) do
|
|
3
|
+
def failed? = status == :fail
|
|
4
|
+
end
|
|
5
|
+
|
|
6
|
+
DoctorResult = Struct.new(:checks, keyword_init: true) do
|
|
7
|
+
def success? = checks.none?(&:failed?)
|
|
8
|
+
|
|
9
|
+
def failures = checks.select(&:failed?)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
class Doctor
|
|
13
|
+
RECURRING = "config/recurring.yml"
|
|
14
|
+
|
|
15
|
+
def self.execute = new.execute
|
|
16
|
+
|
|
17
|
+
def execute
|
|
18
|
+
checks = [
|
|
19
|
+
pg_dump_present,
|
|
20
|
+
pg_dump_version,
|
|
21
|
+
database_resolved,
|
|
22
|
+
stores_registered,
|
|
23
|
+
*config.stores.flat_map { |store| store_checks(store) },
|
|
24
|
+
schedule_registered
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
DoctorResult.new(checks: checks.compact)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def check(name, status, detail = nil)
|
|
33
|
+
Check.new(name: name, status: status, detail: detail)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def pg_dump_present
|
|
37
|
+
if pg_dump_path
|
|
38
|
+
check("pg_dump", :ok, pg_dump_path)
|
|
39
|
+
else
|
|
40
|
+
check("pg_dump", :fail, "not found on PATH")
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def pg_dump_path
|
|
45
|
+
return @pg_dump_path if defined?(@pg_dump_path)
|
|
46
|
+
|
|
47
|
+
@pg_dump_path = ENV.fetch("PATH", "").split(File::PATH_SEPARATOR)
|
|
48
|
+
.map { |dir| File.join(dir, "pg_dump") }
|
|
49
|
+
.find { |path| File.executable?(path) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def pg_dump_version
|
|
53
|
+
return unless pg_dump_path
|
|
54
|
+
|
|
55
|
+
client = major_version(`#{pg_dump_path} --version`)
|
|
56
|
+
server = major_version(server_version)
|
|
57
|
+
|
|
58
|
+
return check("versions", :skip, "could not read server version") unless server
|
|
59
|
+
return check("versions", :skip, "could not read pg_dump version") unless client
|
|
60
|
+
|
|
61
|
+
if client >= server
|
|
62
|
+
check("versions", :ok, "pg_dump #{client}, server #{server}")
|
|
63
|
+
else
|
|
64
|
+
check("versions", :fail, "pg_dump #{client} is older than server #{server} — the dump may not restore")
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def major_version(string)
|
|
69
|
+
string.to_s[/(\d+)/, 1]&.to_i
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def server_version
|
|
73
|
+
return unless defined?(::ActiveRecord)
|
|
74
|
+
|
|
75
|
+
::ActiveRecord::Base.connection.select_value("SHOW server_version")
|
|
76
|
+
rescue
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def database_resolved
|
|
81
|
+
database = config.pg_env["PGDATABASE"]
|
|
82
|
+
|
|
83
|
+
if database.to_s.empty?
|
|
84
|
+
check("database", :fail, "could not resolve PGDATABASE")
|
|
85
|
+
else
|
|
86
|
+
check("database", :ok, database)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def stores_registered
|
|
91
|
+
if config.stores.empty?
|
|
92
|
+
check("stores", :fail, "no stores registered")
|
|
93
|
+
else
|
|
94
|
+
check("stores", :ok, "#{config.stores.size} registered")
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def store_checks(store)
|
|
99
|
+
store.validate!
|
|
100
|
+
|
|
101
|
+
[check(store.name, :ok, store.description)]
|
|
102
|
+
rescue => e
|
|
103
|
+
[check(store.name, :fail, e.message)]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def schedule_registered
|
|
107
|
+
return check("schedule", :skip, "no #{RECURRING} — using cron or another scheduler?") unless File.exist?(RECURRING)
|
|
108
|
+
|
|
109
|
+
unless File.read(RECURRING, mode: "r:UTF-8").include?("BoringBackup::BackupJob")
|
|
110
|
+
return check("schedule", :fail, "#{RECURRING} does not run BoringBackup::BackupJob")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
unless backup_job_loadable?
|
|
114
|
+
return check("schedule", :fail, "BoringBackup::BackupJob won't load — is activejob in the Gemfile?")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
check("schedule", :ok, "#{RECURRING} runs BoringBackup::BackupJob")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def backup_job_loadable?
|
|
121
|
+
return false unless defined?(::ActiveJob)
|
|
122
|
+
|
|
123
|
+
Object.const_get("ActiveJob::Base")
|
|
124
|
+
require "boring_backup/jobs/backup_job"
|
|
125
|
+
|
|
126
|
+
true
|
|
127
|
+
rescue LoadError, NameError
|
|
128
|
+
false
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def config
|
|
132
|
+
@config ||= BoringBackup.config
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -47,6 +47,10 @@ module BoringBackup
|
|
|
47
47
|
[*@notifiers, sentinel].compact
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
+
def silence_stdout!
|
|
51
|
+
@notifiers.reject! { |notifier| notifier.is_a?(BoringBackup::Notifiers::Stdout) }
|
|
52
|
+
end
|
|
53
|
+
|
|
50
54
|
def ignore_tables
|
|
51
55
|
Array(@ignore_tables).map { |table| table.to_s.strip }.reject(&:empty?).uniq
|
|
52
56
|
end
|
|
@@ -62,17 +66,17 @@ module BoringBackup
|
|
|
62
66
|
end
|
|
63
67
|
|
|
64
68
|
def register(store_type)
|
|
65
|
-
raise BoringBackup::ConfigurationError, "`config.register` requires a block" unless block_given?
|
|
66
|
-
|
|
67
69
|
store =
|
|
68
70
|
case store_type.to_sym
|
|
69
|
-
when :s3 then
|
|
71
|
+
when :s3 then build_s3_store
|
|
70
72
|
else raise BoringBackup::ConfigurationError, "unknown store type: #{store_type}"
|
|
71
73
|
end
|
|
72
74
|
|
|
73
75
|
@stores << store
|
|
74
76
|
|
|
75
|
-
yield store
|
|
77
|
+
yield store if block_given?
|
|
78
|
+
|
|
79
|
+
store
|
|
76
80
|
end
|
|
77
81
|
|
|
78
82
|
def notifier(type)
|
|
@@ -87,6 +91,15 @@ module BoringBackup
|
|
|
87
91
|
end
|
|
88
92
|
end
|
|
89
93
|
|
|
94
|
+
def build_s3_store
|
|
95
|
+
require_relative "stores/s3"
|
|
96
|
+
|
|
97
|
+
BoringBackup::Stores::S3.new
|
|
98
|
+
rescue LoadError
|
|
99
|
+
raise BoringBackup::ConfigurationError,
|
|
100
|
+
"the :s3 store needs the aws-sdk-s3 gem. Add `gem \"aws-sdk-s3\"` to your Gemfile."
|
|
101
|
+
end
|
|
102
|
+
|
|
90
103
|
def pg_env
|
|
91
104
|
{
|
|
92
105
|
"PGHOST" => (pg_host || db_config[:host])&.to_s,
|
|
@@ -37,6 +37,12 @@ module BoringBackup::Stores
|
|
|
37
37
|
value.empty? ? nil : value.to_sym
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
+
def name = "s3"
|
|
41
|
+
|
|
42
|
+
def description
|
|
43
|
+
[bucket.to_s.empty? ? "no bucket" : "s3://#{bucket}", region].compact.join(" ")
|
|
44
|
+
end
|
|
45
|
+
|
|
40
46
|
# Prefer Aws::S3::TransferManager for streaming uploads if available.
|
|
41
47
|
# Aws::S3::Resource.upload_stream is deprecated in newer versions
|
|
42
48
|
def backup!(key, stream)
|
data/lib/boring_backup/stores.rb
CHANGED
data/lib/boring_backup/tee.rb
CHANGED
|
@@ -22,11 +22,20 @@ module BoringBackup
|
|
|
22
22
|
end
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
attr_reader :bytes
|
|
26
|
+
|
|
27
|
+
def initialize(sinks, progress: nil)
|
|
28
|
+
@sinks = sinks
|
|
29
|
+
@progress = progress
|
|
30
|
+
@bytes = 0
|
|
31
|
+
end
|
|
26
32
|
|
|
27
33
|
def write(chunk)
|
|
28
34
|
@sinks.each { |sink| sink.write(chunk) }
|
|
29
35
|
|
|
36
|
+
@bytes += chunk.bytesize
|
|
37
|
+
@progress&.call(@bytes)
|
|
38
|
+
|
|
30
39
|
chunk.bytesize
|
|
31
40
|
end
|
|
32
41
|
end
|
data/lib/boring_backup.rb
CHANGED
|
@@ -4,6 +4,7 @@ require_relative "boring_backup/tee"
|
|
|
4
4
|
require_relative "boring_backup/utils"
|
|
5
5
|
require_relative "boring_backup/stores"
|
|
6
6
|
require_relative "boring_backup/commands/backup"
|
|
7
|
+
require_relative "boring_backup/commands/doctor"
|
|
7
8
|
require_relative "boring_backup/notifiers/slack"
|
|
8
9
|
require_relative "boring_backup/notifiers/stdout"
|
|
9
10
|
require_relative "boring_backup/notifiers/sentinel"
|
|
@@ -53,6 +54,16 @@ module BoringBackup
|
|
|
53
54
|
require env_file if File.exist?(env_file)
|
|
54
55
|
end
|
|
55
56
|
|
|
57
|
+
def environment
|
|
58
|
+
return ::Rails.env.to_s if defined?(::Rails) && ::Rails.respond_to?(:env)
|
|
59
|
+
|
|
60
|
+
ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "development"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def production?
|
|
64
|
+
environment == "production"
|
|
65
|
+
end
|
|
66
|
+
|
|
56
67
|
def notify(result)
|
|
57
68
|
config.notifiers.each do |notifier|
|
|
58
69
|
notifier.notify(result)
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: boring-backup
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Georgi Mitrev
|
|
@@ -24,19 +24,47 @@ dependencies:
|
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
25
|
version: '0'
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
27
|
-
name:
|
|
27
|
+
name: pastel
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|
|
29
29
|
requirements:
|
|
30
|
-
- - "
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: tty-prompt
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '0'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
31
52
|
- !ruby/object:Gem::Version
|
|
32
|
-
version: '
|
|
53
|
+
version: '0'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: tty-spinner
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - ">="
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '0'
|
|
33
61
|
type: :runtime
|
|
34
62
|
prerelease: false
|
|
35
63
|
version_requirements: !ruby/object:Gem::Requirement
|
|
36
64
|
requirements:
|
|
37
|
-
- - "
|
|
65
|
+
- - ">="
|
|
38
66
|
- !ruby/object:Gem::Version
|
|
39
|
-
version: '
|
|
67
|
+
version: '0'
|
|
40
68
|
description: The simplest way to add recurring database backups to your project with
|
|
41
69
|
minimal setup required.
|
|
42
70
|
email:
|
|
@@ -55,6 +83,7 @@ files:
|
|
|
55
83
|
- lib/boring_backup.rb
|
|
56
84
|
- lib/boring_backup/cli.rb
|
|
57
85
|
- lib/boring_backup/commands/backup.rb
|
|
86
|
+
- lib/boring_backup/commands/doctor.rb
|
|
58
87
|
- lib/boring_backup/configuration.rb
|
|
59
88
|
- lib/boring_backup/jobs/backup_job.rb
|
|
60
89
|
- lib/boring_backup/notifiers/sentinel.rb
|