bard-backup 0.11.4 → 0.12.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: a78996cfc76728d6202bd4836681db28779460fce707fdf73ddffda233589289
4
- data.tar.gz: ed49a1090e5ea87065e09c4567bbe81d19a500b92c1151e76bb9f8150095a61f
3
+ metadata.gz: f051915564aff1edf80617a2f83d6eb0c5fbf392ef0f68b5cd4d082c9d0a6450
4
+ data.tar.gz: 274a30cc6ac3579045ffb1c7be96a62016f63497e177fc09483122535c72329a
5
5
  SHA512:
6
- metadata.gz: e738f2af35ce1b1459c07e747a0e7d89f0298daa22df4bfabfdff88273545adc1b0c8909f0207d2197027d6ca7a1ada0d6243a4ac91b51f076852487796c3d2f
7
- data.tar.gz: 9bd09e3d8355bc921d489f606b422c748ec5e2b042550d96c58d22259518a5c2f455774e33f85b92c38fcebacf9edc3a3dd0cb35356229c9eef36df35c860f3e
6
+ metadata.gz: 998e90d23806ee850d5440b7e63c89ef357749697dae25f5cb89b20c77b32ccb644da5c232ecebdbdc57d7e4a2b53562acdb9c081d02b34624ce533d03efb29f
7
+ data.tar.gz: bd5eb49fa3a1676ea5f1c6805450f0146c0d18b9ee26139fcee74314d0bfcb9984d27076b7af510ff959f5fa4c7e1eb31d157e7be52795c20e138be75551a4fb
data/CLAUDE.md CHANGED
@@ -45,8 +45,9 @@ Tests require AWS credentials at `spec/support/credentials.json`. In CI, this is
45
45
  **Entry points**:
46
46
  - `Bard::Backup.create!` accepts destination configs (or reads from `Bard::Config`) and delegates to destination strategies. Returns a `Bard::Backup` instance with timestamp/size/destinations.
47
47
  - `Bard::Backup::FileTree.create!` syncs configured data directories to S3.
48
+ - `Bard::Backup.restore!(at:)` downloads the backup nearest `at` (or the latest when omitted), decrypts it if necessary, and loads it into the local database via `backhoe` (drop-and-create). `Bard::Backup.available` lists the available backup timestamps.
48
49
 
49
- **Destination strategy pattern**: `Destination.build(config)` is a factory that picks the right class based on `:type`:
50
+ **Destination strategy pattern**: `Destination.build(config)` is a factory that picks the right class based on `:type`. `Destination.resolve(hashes)` is the single entry point both `Database.create!` and `Finder` use — it falls back to the configured destinations, normalizes a lone Hash/Array, and builds them. Each destination resolves its own `encryption_key` (explicit config, falling back to `Bard::Config.current.backup.encryption_key`), so callers never inject it.
50
51
  - `S3Destination` — dumps DB locally via backhoe, uploads to S3, runs `Deleter` for retention, verifies previous hour's backup
51
52
  - `UploadDestination` — dumps DB and uploads to presigned URLs (multi-threaded)
52
53
 
@@ -64,6 +65,8 @@ end
64
65
  - `Encryptor` — AES-256-GCM with HKDF-derived keys and a deterministic IV (HMAC of plaintext), enabling content-addressable encryption
65
66
  - `Deleter` — implements the retention policy via `Filter` structs that check time-based granularities
66
67
  - `LocalBackhoe` / `CachedLocalBackhoe` — database dump strategies (cached variant avoids conflicts when running parallel destinations)
67
- - `LatestFinder` — finds the most recent backup across all configured destinations
68
+ - `Finder` — lists backups across destinations and selects one: by timestamp (nearest match), the latest, or the most-recent as a `Bard::Backup` (with size + destination info). Backs `Bard::Backup.available`/`.latest` and `Restore`
69
+ - `Restore` — downloads the backup selected by `Finder`, decrypts it via `S3Tree#get`, and loads it into the local database via backhoe (drop-and-create). Backs `Bard::Backup.restore!`
68
70
  - `BackupConfig` — the `backup do ... end` DSL surface (`bard`, `disabled`, `s3 name, **kwargs`); `create!` reads `bard_config.backup.destinations` from it
69
- - `Railtie` — loads `tasks.rake` which provides `bard:backup` (DB + data) and `bard:backup:data` (data only) rake tasks in Rails apps
71
+ - `Railtie` — loads `tasks.rake` which provides `bard:backup` (DB + data), `bard:backup:data` (data only), and `bard:backup:restore[at]` (restore/list) rake tasks in Rails apps
72
+ - `bard/plugins/backup_restore.rb` — registers the `bard backup restore [TIMESTAMP]` CLI subcommand (loaded by the `bard` CLI via `Gem.find_files`); shells out to the `bard:backup:restore` rake task
data/README.md CHANGED
@@ -42,6 +42,39 @@ rake bard:backup # database backup + data file-tree sync
42
42
  rake bard:backup:data # data file-tree sync only
43
43
  ```
44
44
 
45
+ ## Restoring
46
+
47
+ Restore a database backup from S3 into the local database. The backup nearest the
48
+ requested timestamp is selected (backups run hourly, so an exact match isn't
49
+ guaranteed), downloaded, decrypted if necessary, and loaded via `backhoe`,
50
+ dropping and recreating the local database.
51
+
52
+ From the `bard` CLI:
53
+
54
+ ```bash
55
+ bard backup restore # list the available backup timestamps
56
+ bard backup restore 2026-06-05T16:00:00Z # restore the backup nearest this UTC time
57
+ ```
58
+
59
+ Or via the rake task directly:
60
+
61
+ ```bash
62
+ rake "bard:backup:restore" # list available backups
63
+ rake "bard:backup:restore[2026-06-05T16:00:00Z]" # restore nearest this time
64
+ ```
65
+
66
+ Or programmatically:
67
+
68
+ ```ruby
69
+ Bard::Backup.available # => [Time, Time, ...]
70
+ Bard::Backup.restore! # restore the latest backup
71
+ Bard::Backup.restore!(at: "2026-06-05T16:00:00Z") # restore nearest this time
72
+ ```
73
+
74
+ Timestamps are UTC (the `Z`-suffixed ISO-8601 form the backups are named with).
75
+ Restore requires self-managed destinations configured via the `backup do ... end`
76
+ DSL; bard-managed backups are not yet supported.
77
+
45
78
  Or call programmatically:
46
79
 
47
80
  ```ruby
@@ -8,25 +8,15 @@ module Bard
8
8
  destination_hashes = [config]
9
9
  end
10
10
 
11
- bard_config = defined?(Bard::Config) ? Bard::Config.current : nil
12
- destination_hashes ||= bard_config&.backup&.destinations || []
11
+ now = Time.now.utc
12
+ backups = Destination.resolve(destination_hashes, now: now).map(&:call)
13
+ return nil if backups.empty?
13
14
 
14
- destinations = if destination_hashes.is_a?(Hash)
15
- [destination_hashes]
16
- else
17
- Array(destination_hashes)
18
- end
19
-
20
- encryption_key = bard_config&.backup&.encryption_key
21
- if encryption_key
22
- destinations = destinations.map { |h| { encryption_key: encryption_key, **h } }
23
- end
24
-
25
- result = nil
26
- destinations.each do |hash|
27
- result = Backup::Destination.build(hash).call
28
- end
29
- result
15
+ Bard::Backup.new(
16
+ timestamp: backups.first.timestamp,
17
+ size: backups.first.size,
18
+ destinations: backups.flat_map(&:destinations),
19
+ )
30
20
  end
31
21
  end
32
22
  end
@@ -14,28 +14,30 @@ module Bard
14
14
  end
15
15
 
16
16
  def s3_tree
17
- @s3_tree ||= S3Tree.new(**config.slice(:endpoint, :path, :access_key_id, :secret_access_key, :session_token, :region, :encryption_key))
17
+ @s3_tree ||= S3Tree.new(**resolved_config.slice(:endpoint, :path, :access_key_id, :secret_access_key, :session_token, :region, :encryption_key).compact)
18
18
  end
19
19
 
20
20
  def info
21
- config.slice(:name, :type, :path, :region)
21
+ resolved_config.slice(:name, :type, :path, :region)
22
22
  end
23
23
 
24
24
  private
25
25
 
26
- def config
27
- @config ||= begin
28
- explicit = super
29
- credentials = RailsCredentials.find(name: explicit[:name])
30
- config = { type: :s3, region: "us-west-2" }.merge(credentials).merge(explicit)
31
- config[:endpoint] ||= "https://s3.#{config[:region]}.amazonaws.com"
32
- config
26
+ # The raw config enriched with Rails credentials (by name), defaults, the
27
+ # computed endpoint, and the resolved encryption key.
28
+ def resolved_config
29
+ @resolved_config ||= begin
30
+ credentials = RailsCredentials.find(name: config[:name])
31
+ resolved = { type: :s3, region: "us-west-2" }.merge(credentials).merge(config)
32
+ resolved[:endpoint] ||= "https://s3.#{resolved[:region]}.amazonaws.com"
33
+ resolved[:encryption_key] ||= Bard::Config.current.backup.encryption_key
34
+ resolved
33
35
  end
34
36
  end
35
37
 
36
38
  def strategy
37
39
  return @strategy if @strategy
38
- @strategy = config.fetch(:strategy, LocalBackhoe)
40
+ @strategy = resolved_config.fetch(:strategy, LocalBackhoe)
39
41
  @strategy = Bard::Backup.const_get(@strategy) if @strategy.is_a?(String)
40
42
  @strategy
41
43
  end
@@ -55,8 +55,8 @@ module Bard
55
55
 
56
56
  File.open(file_path, "rb") do |file|
57
57
  body = file.read
58
- if config[:encryption_key]
59
- body = Encryptor.new(config[:encryption_key]).encrypt(body)
58
+ if (key = encryption_key)
59
+ body = Encryptor.new(key).encrypt(body)
60
60
  end
61
61
  request = Net::HTTP::Put.new(uri)
62
62
  request.body = body
@@ -1,3 +1,5 @@
1
+ require "bard/config"
2
+
1
3
  module Bard
2
4
  class Backup
3
5
  class Destination < Struct.new(:config)
@@ -6,12 +8,28 @@ module Bard
6
8
  klass.new(config)
7
9
  end
8
10
 
11
+ # Normalizes destination config into built destinations: falls back to the
12
+ # configured destinations when none are given, and accepts a lone Hash or
13
+ # an Array. A +now+ stamps every destination with one run timestamp (an
14
+ # explicit per-destination +now+ still wins). Each destination resolves its
15
+ # own encryption key (see #encryption_key), so callers don't have to inject it.
16
+ def self.resolve(destination_hashes = nil, now: nil)
17
+ hashes = destination_hashes || Bard::Config.current.backup.destinations
18
+ hashes = hashes.is_a?(Hash) ? [hashes] : Array(hashes)
19
+ hashes = hashes.map { |hash| { now:, **hash } } if now
20
+ hashes.map { |hash| build(hash) }
21
+ end
22
+
9
23
  def call
10
24
  raise NotImplementedError
11
25
  end
12
26
 
13
27
  private
14
28
 
29
+ def encryption_key
30
+ config[:encryption_key] || Bard::Config.current.backup.encryption_key
31
+ end
32
+
15
33
  def now
16
34
  @now ||= config.fetch(:now, Time.now.utc)
17
35
  end
@@ -0,0 +1,68 @@
1
+ require "time"
2
+ require "bard/config"
3
+ require "bard/backup/destination"
4
+
5
+ module Bard
6
+ class Backup
7
+ class NotFound < StandardError; end
8
+
9
+ # Locates database backups across the configured destinations and selects one by timestamp.
10
+ class Finder
11
+ def initialize(destinations = nil)
12
+ @destinations = Destination.resolve(destinations)
13
+ end
14
+
15
+ def all
16
+ @destinations.flat_map do |destination|
17
+ destination.s3_tree.list_objects.keys.filter_map do |filename|
18
+ timestamp = parse_timestamp(filename)
19
+ next unless timestamp
20
+ { timestamp:, destination:, filename: }
21
+ end
22
+ end
23
+ end
24
+
25
+ def timestamps
26
+ all.map { |backup| backup[:timestamp] }.uniq.sort
27
+ end
28
+
29
+ def find(at: nil)
30
+ backups = all
31
+ raise NotFound, "No backups found" if backups.empty?
32
+
33
+ if at.nil?
34
+ backups.max_by { |backup| backup[:timestamp] }
35
+ else
36
+ # returns nearest backup (exact timestamp match is not guaranteed)
37
+ target = at.is_a?(Time) ? at : Time.parse(at.to_s)
38
+ backups.min_by { |backup| (backup[:timestamp] - target).abs }
39
+ end
40
+ end
41
+
42
+ def latest
43
+ backups = all
44
+ raise NotFound, "No backups found" if backups.empty?
45
+
46
+ newest = backups.max_by { |backup| backup[:timestamp] }
47
+ Bard::Backup.new(
48
+ timestamp: newest[:timestamp],
49
+ size: file_size(newest[:destination].s3_tree, newest[:filename]),
50
+ destinations: backups
51
+ .select { |backup| backup[:timestamp] == newest[:timestamp] }
52
+ .map { |backup| backup[:destination].info },
53
+ )
54
+ end
55
+
56
+ private
57
+
58
+ def file_size(s3_tree, filename)
59
+ key = [s3_tree.folder_prefix, filename].compact.join("/")
60
+ s3_tree.send(:client).head_object(bucket: s3_tree.bucket_name, key: key).content_length
61
+ end
62
+
63
+ def parse_timestamp(filename)
64
+ filename =~ /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)/ ? Time.parse($1) : nil
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,36 @@
1
+ require "backhoe"
2
+ require "fileutils"
3
+ require "bard/backup/finder"
4
+
5
+ module Bard
6
+ class Backup
7
+ # Downloads a database backup from S3 and loads it into the local database.
8
+ # The backup nearest +at+ is selected, decrypted if necessary, and loaded
9
+ # via backhoe.
10
+ class Restore < Struct.new(:at, :drop_and_create, :destinations, keyword_init: true)
11
+ def self.call(...)
12
+ new(...).call
13
+ end
14
+
15
+ def initialize(at:, drop_and_create: true, destinations: nil)
16
+ super
17
+ end
18
+
19
+ def call
20
+ backup = Finder.new(destinations).find(at: at)
21
+ body = backup[:destination].s3_tree.get(backup[:filename])
22
+ path = "/tmp/bard-backup-restore-#{File.basename(backup[:filename])}"
23
+ File.binwrite(path, body)
24
+ Backhoe.load(path, drop_and_create: drop_and_create)
25
+
26
+ Bard::Backup.new(
27
+ timestamp: backup[:timestamp],
28
+ size: body.bytesize,
29
+ destinations: [backup[:destination].info],
30
+ )
31
+ ensure
32
+ FileUtils.rm_f(path) if path
33
+ end
34
+ end
35
+ end
36
+ end
@@ -1,9 +1,8 @@
1
- require "bard/backup/rails_credentials"
2
-
3
1
  namespace :bard do
4
2
  desc "Backup the database and file trees to configured destinations"
5
3
  task :backup => :environment do
6
4
  require "bard/backup"
5
+ require "bard/backup/rails_credentials"
7
6
 
8
7
  destinations = Bard::Config.current.backup.destinations.map do |dest|
9
8
  Bard::Backup::RailsCredentials.find(name: dest[:name]).merge(dest)
@@ -16,7 +15,24 @@ namespace :bard do
16
15
  desc "Backup file trees to S3"
17
16
  task :data => :environment do
18
17
  require "bard/backup"
18
+ require "bard/backup/rails_credentials"
19
19
  Bard::Backup::FileTree.create!(**Bard::Backup::RailsCredentials.find)
20
20
  end
21
+
22
+ desc "Restore the database backup nearest a timestamp (no timestamp lists available backups)"
23
+ task :restore, [:at] => :environment do |_task, args|
24
+ require "bard/backup"
25
+ require "bard/backup/rails_credentials"
26
+
27
+ destinations = Bard::Config.current.backup.destinations
28
+ if args[:at].to_s.strip.empty?
29
+ timestamps = Bard::Backup.available(destinations: destinations)
30
+ puts "Available backups:"
31
+ timestamps.each { |timestamp| puts " #{timestamp.iso8601}" }
32
+ else
33
+ backup = Bard::Backup.restore!(at: args[:at], destinations: destinations)
34
+ puts "Restored database from backup #{backup.timestamp.iso8601}."
35
+ end
36
+ end
21
37
  end
22
38
  end
@@ -1,6 +1,6 @@
1
1
  module Bard
2
2
  class Backup
3
- VERSION = "0.11.4"
3
+ VERSION = "0.12.0"
4
4
  end
5
5
  end
6
6
 
data/lib/bard/backup.rb CHANGED
@@ -3,7 +3,8 @@ require "bard/plugins/backup"
3
3
  require "bard/plugins/encrypt"
4
4
  require "bard/backup/database"
5
5
  require "bard/backup/file_tree"
6
- require "bard/backup/latest_finder"
6
+ require "bard/backup/finder"
7
+ require "bard/backup/restore"
7
8
  require "bard/backup/railtie" if defined?(Rails)
8
9
 
9
10
  module Bard
@@ -17,7 +18,15 @@ module Bard
17
18
  end
18
19
 
19
20
  def self.latest
20
- LatestFinder.new.call
21
+ Finder.new.latest
22
+ end
23
+
24
+ def self.available(destinations: nil)
25
+ Finder.new(destinations).timestamps
26
+ end
27
+
28
+ def self.restore!(at:, drop_and_create: true, destinations: nil)
29
+ Restore.call(at: at, drop_and_create: drop_and_create, destinations: destinations)
21
30
  end
22
31
 
23
32
  attr_reader :timestamp, :size, :destinations
@@ -0,0 +1,35 @@
1
+ # Registers the `bard backup restore` CLI subcommand. The actual work runs in
2
+ # the `bard:backup:restore` rake task, which boots Rails so it has the AWS
3
+ # credentials and database config it needs (mirroring how `bard data` shells out
4
+ # to `bin/rake db:load`).
5
+ require "bard/command"
6
+
7
+ class Bard::CLI
8
+ class BackupCommands < Thor
9
+ desc "restore [TIMESTAMP]", "restore the database backup nearest TIMESTAMP into the local database"
10
+ long_desc <<~DESC
11
+ Restores the database backup nearest TIMESTAMP (ISO-8601 UTC, e.g.
12
+ 2026-06-05T16:00:00Z) into the LOCAL database, dropping and recreating it.
13
+
14
+ With no TIMESTAMP, lists the available backups instead of restoring.
15
+ DESC
16
+ def restore(timestamp = nil)
17
+ if timestamp.nil?
18
+ Bard::Command.run! "bin/rake bard:backup:restore", verbose: true
19
+ else
20
+ say "This will DROP and reload your LOCAL database from the backup nearest #{timestamp}.", :yellow
21
+ unless yes?("Continue? (y/N)")
22
+ say "Aborted."
23
+ exit 1
24
+ end
25
+ Bard::Command.run! %(bin/rake "bard:backup:restore[#{timestamp}]"), verbose: true
26
+ end
27
+ rescue Bard::Command::Error => e
28
+ say "!!! Running command failed: #{e.message}", :red
29
+ exit 1
30
+ end
31
+ end
32
+
33
+ desc "backup SUBCOMMAND", "database backup operations"
34
+ subcommand "backup", BackupCommands
35
+ end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bard-backup
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.11.4
4
+ version: 0.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Micah Geisel
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-05-17 00:00:00.000000000 Z
10
+ date: 2026-06-10 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: backhoe
@@ -103,14 +103,16 @@ files:
103
103
  - lib/bard/backup/destination/upload_destination.rb
104
104
  - lib/bard/backup/encryptor.rb
105
105
  - lib/bard/backup/file_tree.rb
106
- - lib/bard/backup/latest_finder.rb
106
+ - lib/bard/backup/finder.rb
107
107
  - lib/bard/backup/local_backhoe.rb
108
108
  - lib/bard/backup/rails_credentials.rb
109
109
  - lib/bard/backup/railtie.rb
110
+ - lib/bard/backup/restore.rb
110
111
  - lib/bard/backup/s3_tree.rb
111
112
  - lib/bard/backup/tasks.rake
112
113
  - lib/bard/backup/version.rb
113
114
  - lib/bard/plugins/backup.rb
115
+ - lib/bard/plugins/backup_restore.rb
114
116
  - lib/bard/plugins/encrypt.rb
115
117
  - sig/bard/backup.rbs
116
118
  homepage: https://github.com/botandrose/bard-backup
@@ -1,47 +0,0 @@
1
- require "bard/config"
2
-
3
- module Bard
4
- class Backup
5
- class NotFound < StandardError; end
6
-
7
- class LatestFinder
8
- def call
9
- destinations = Bard::Config.current.backup.destinations.map do |hash|
10
- Destination.build(hash)
11
- end
12
-
13
- all_backups = destinations.flat_map do |dest|
14
- dest.s3_tree.list_objects.keys.filter_map do |filename|
15
- timestamp = parse_timestamp(filename)
16
- next unless timestamp
17
-
18
- { timestamp: timestamp, destination: dest, filename: filename }
19
- end
20
- end
21
-
22
- raise NotFound, "No backups found" if all_backups.empty?
23
-
24
- latest = all_backups.max_by { |b| b[:timestamp] }
25
-
26
- Bard::Backup.new(
27
- timestamp: latest[:timestamp],
28
- size: get_file_size(latest[:destination].s3_tree, latest[:filename]),
29
- destinations: all_backups
30
- .select { |b| b[:timestamp] == latest[:timestamp] }
31
- .map { |b| b[:destination].info }
32
- )
33
- end
34
-
35
- private
36
-
37
- def parse_timestamp(filename)
38
- filename =~ /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)/ ? Time.parse($1) : nil
39
- end
40
-
41
- def get_file_size(s3_tree, filename)
42
- key = [s3_tree.folder_prefix, filename].compact.join("/")
43
- s3_tree.send(:client).head_object(bucket: s3_tree.bucket_name, key: key).content_length
44
- end
45
- end
46
- end
47
- end