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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 520f4d8e05984b490027c72e0922660b93f73ed22f6d48671ba87b6c4922883e
4
- data.tar.gz: 698eeeb6840e35554551cf2d87a2c2a567f8d5b58678a6d0eaed517781dff7db
3
+ metadata.gz: 90fcf205730a58bf45284b04cd2a7be2604158b5bcd983c2bd3bdaada0fb06c0
4
+ data.tar.gz: b17bae14e94295a24d68da3592b3baa238bc1a10fb76bcad464ae515bd7cc46c
5
5
  SHA512:
6
- metadata.gz: c6f7efdd9f7ab1a12a53f84cb3bd539f1d2003f61098dee77a48b48a23bbb9e40814293ff6622477e1397a4857242a19775f2ca6d375df3f026208ef301f4600
7
- data.tar.gz: d0351b5626ffbc7b17d88b60682a8d4e3b30bfa12d390d47cff858ca0cb741879f8182d2bace733f5e31f8e3a43e653e9e1ddf43be6f73206c87171c57aaf293
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
@@ -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
- result = BoringBackup::Commands::Backup.execute
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
- warn e
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 if installed for a basic progress report
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 BoringBackup::Stores::S3.new
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)
@@ -1,5 +1,13 @@
1
1
  module BoringBackup::Stores
2
2
  class Store
3
+ def name
4
+ self.class.name.split("::").last.downcase
5
+ end
6
+
7
+ def description
8
+ name
9
+ end
10
+
3
11
  private
4
12
 
5
13
  def config
@@ -1,3 +1,2 @@
1
1
  require_relative "stores/store"
2
2
  require_relative "stores/result"
3
- require_relative "stores/s3" # TODO: load conditionally
@@ -22,11 +22,20 @@ module BoringBackup
22
22
  end
23
23
  end
24
24
 
25
- def initialize(sinks) = @sinks = sinks
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
@@ -1,3 +1,3 @@
1
1
  module BoringBackup
2
- VERSION = "0.4.0"
2
+ VERSION = "0.5.0"
3
3
  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.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: aws-sdk-s3
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: '1'
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: '1'
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