bard-backup 0.15.2 → 0.16.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: 4dd726619aabc1410bb02866fffa6e94df6110a121aa797e634feb0626a0c060
4
- data.tar.gz: 075d4087afa0d9ef15dd71f2e5e8c1da26f7eaa35b1df8827ae46edb88e8b796
3
+ metadata.gz: b04f7c45bf80b1e66501cde66ca37f02def6a887368710c18ebbb6b12e8dd906
4
+ data.tar.gz: 2ae3f5a68b9fd1d3166475917b3c0abfdecad4d4e9fdcdba6ebf0f8397f38659
5
5
  SHA512:
6
- metadata.gz: f79d15e553a38bc6f01b5c932f8be14ed1cdf871f5b31f82c5c189d8ccc1ce57e51e4f1e405e144a66c99c57be9221bc3fafa5618b63b3b58af5e4ec2d1d9619
7
- data.tar.gz: 1b17ee9f5d0d85bf0c5915ef3c6aaecdede4cfef045881c4048bf81e396754714ccd09e45db6240a1edc5b2638a9f8299f2126012188cd47ac9f7da288ab1a86
6
+ metadata.gz: 3647ba74dbeaad6cc519d8cb6a6e8a00166c53a8f885891435d3fd4e800d0a5388395176bdc5ca9b76af3d607de6138fadc845df6a946f32fd7591042c6fa1f1
7
+ data.tar.gz: d58dc757b0738f1f0dc1547fe15cf236e95d51f0843a204c1d5e2df8d671c560f4bf237ec27217991d26f241b6f03c036c6a8b8ef889466a55ceb127efb59874
data/CLAUDE.md CHANGED
@@ -68,6 +68,7 @@ credentials entry — there is no DSL toggle.
68
68
  - `LocalBackhoe` / `CachedLocalBackhoe` — database dump strategies (cached variant avoids conflicts when running parallel destinations)
69
69
  - `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). `#available` returns one `Bard::Backup` per timestamp (with size) for listing. Backs `Bard::Backup.available`/`.latest` and `Restore`
70
70
  - `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!`
71
+ - `Rekey` — rewrites every object under an S3 path so it is encrypted under a key (from plaintext, or from an old key via `from_key:`), skipping objects already under it and reading each rewrite back. Backs `Bard::Backup.encrypt_existing!` and `bard:backup:encrypt`
71
72
  - `BackupConfig` — the `backup do ... end` DSL surface (`bard`, `disabled`, `s3 name, **kwargs`); `create!` reads `bard_config.backup.destinations` from it
72
- - `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
73
+ - `Railtie` — loads `tasks.rake` which provides `bard:backup` (DB + data), `bard:backup:data` (data only), `bard:backup:encrypt` (encrypt pre-key backups and data files), and `bard:backup:restore[at]` (restore/list) rake tasks in Rails apps
73
74
  - `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
@@ -39,11 +39,17 @@ Adding `encryption_key` is what turns on encryption at rest; omit it and payload
39
39
  in the clear. Changing its value makes existing backups undecryptable, so treat it as
40
40
  permanent unless you re-key deliberately.
41
41
 
42
+ Adding a key only encrypts what is uploaded afterwards. Database dumps stored before it still
43
+ restore (they are recognized as plain gzip), and `rake bard:backup:encrypt` rewrites them and
44
+ the data file tree in place under the key, verifying each object as it goes. It skips anything
45
+ already encrypted, so it is safe to rerun.
46
+
42
47
  Then run via the rake tasks provided by the bundled Railtie:
43
48
 
44
49
  ```bash
45
- rake bard:backup # database backup + data file-tree sync
46
- rake bard:backup:data # data file-tree sync only
50
+ rake bard:backup # database backup + data file-tree sync
51
+ rake bard:backup:data # data file-tree sync only
52
+ rake bard:backup:encrypt # encrypt everything stored before encryption_key was added
47
53
  ```
48
54
 
49
55
  ## Restoring
@@ -14,7 +14,12 @@ module Bard
14
14
  end
15
15
 
16
16
  def s3_tree
17
- @s3_tree ||= S3Tree.new(**resolved_config.slice(:endpoint, :path, :access_key_id, :secret_access_key, :session_token, :region, :encryption_key).compact)
17
+ @s3_tree ||= S3Tree.new(**s3_tree_config, **resolved_config.slice(:encryption_key).compact)
18
+ end
19
+
20
+ # The connection alone, without the key, so a re-key can read objects as stored.
21
+ def s3_tree_config
22
+ resolved_config.slice(:endpoint, :path, :access_key_id, :secret_access_key, :session_token, :region).compact
18
23
  end
19
24
 
20
25
  def info
@@ -21,6 +21,7 @@ module Bard
21
21
 
22
22
  def decrypt(data)
23
23
  data = data.b if data.encoding != Encoding::BINARY
24
+ raise OpenSSL::Cipher::CipherError, "too short to be ciphertext" if data.bytesize < 28
24
25
  cipher = OpenSSL::Cipher.new("aes-256-gcm")
25
26
  cipher.decrypt
26
27
  cipher.key = @encrypt_key
@@ -8,16 +8,21 @@ module Bard
8
8
  MANIFEST_PATH = ".bard-file-tree-sync.json"
9
9
  DEFAULT_BUCKET = "bard-data"
10
10
 
11
- def self.create!(data_paths: nil, project_name: nil, bucket: DEFAULT_BUCKET, **s3_config)
12
- bard_config = defined?(Bard::Config) ? Bard::Config.current : nil
13
- data_paths ||= bard_config&.data || []
14
- project_name ||= bard_config&.project_name
15
- return if data_paths.empty?
11
+ def self.create!(data_paths: nil, **options)
12
+ data_paths ||= configured_data_paths
13
+ tree_config = s3_tree_config(data_paths:, **options)
14
+ new(S3Tree.new(**tree_config), data_paths).call if tree_config
15
+ end
16
16
 
17
- encryption_key = s3_config.delete(:encryption_key)
17
+ def self.s3_tree_config(data_paths: nil, project_name: nil, bucket: DEFAULT_BUCKET, **s3_config)
18
+ data_paths ||= configured_data_paths
19
+ return if data_paths.empty?
20
+ project_name ||= Bard::Config.current.project_name if defined?(Bard::Config)
21
+ { path: "#{bucket}/#{project_name}", **s3_config }
22
+ end
18
23
 
19
- s3_tree = S3Tree.new(path: "#{bucket}/#{project_name}", encryption_key: encryption_key, **s3_config)
20
- new(s3_tree, data_paths).call
24
+ def self.configured_data_paths
25
+ defined?(Bard::Config) ? Bard::Config.current.data : []
21
26
  end
22
27
 
23
28
  def initialize(s3_tree, data_paths)
@@ -0,0 +1,63 @@
1
+ require "bard/backup/s3_tree"
2
+ require "bard/backup/encryptor"
3
+ require "bard/backup/destination"
4
+
5
+ module Bard
6
+ class Backup
7
+ class Rekey < Struct.new(:tree_config, :to_key, :from_key, keyword_init: true)
8
+ def self.call(...)
9
+ new(...).call
10
+ end
11
+
12
+ def call
13
+ result = { path: tree_config[:path], encrypted: [], skipped: [] }
14
+ stored_tree.files.each do |file|
15
+ stored = stored_tree.get(file).b
16
+ if under_target_key?(stored)
17
+ result[:skipped] << file
18
+ else
19
+ rewrite(file, stored)
20
+ result[:encrypted] << file
21
+ end
22
+ end
23
+ result
24
+ end
25
+
26
+ private
27
+
28
+ def rewrite(file, stored)
29
+ body = from_key ? Encryptor.new(from_key).decrypt(stored) : stored
30
+ target_tree.put_body(file, body)
31
+ return if reads_back?(file, body)
32
+
33
+ stored_tree.put_body(file, stored)
34
+ raise Error, "#{file} did not read back intact after encrypting, so its original was put back"
35
+ end
36
+
37
+ def reads_back?(file, body)
38
+ target_tree.get(file).b == body
39
+ rescue OpenSSL::Cipher::CipherError
40
+ false
41
+ end
42
+
43
+ def under_target_key?(stored)
44
+ target_encryptor.decrypt(stored)
45
+ true
46
+ rescue OpenSSL::Cipher::CipherError
47
+ false
48
+ end
49
+
50
+ def stored_tree
51
+ @stored_tree ||= S3Tree.new(**tree_config)
52
+ end
53
+
54
+ def target_tree
55
+ @target_tree ||= S3Tree.new(**tree_config, encryption_key: to_key)
56
+ end
57
+
58
+ def target_encryptor
59
+ @target_encryptor ||= Encryptor.new(to_key)
60
+ end
61
+ end
62
+ end
63
+ end
@@ -72,7 +72,7 @@ module Bard
72
72
  key: [folder_prefix, remote_key].compact.join("/"),
73
73
  })
74
74
  body = response.body.read
75
- body = encryptor.decrypt(body) if encryptor
75
+ body = decrypt(body) if encryptor
76
76
  body
77
77
  end
78
78
 
@@ -113,6 +113,14 @@ module Bard
113
113
  Encryptor.new(encryption_key) if encryption_key
114
114
  end
115
115
 
116
+ def decrypt(body)
117
+ encryptor.decrypt(body)
118
+ rescue OpenSSL::Cipher::CipherError
119
+ body = body.b
120
+ raise unless body.start_with?("\x1f\x8b".b) # a database dump stored before encryption was enabled
121
+ body
122
+ end
123
+
116
124
  def client
117
125
  Aws::S3::Client.new({
118
126
  endpoint: endpoint,
@@ -19,6 +19,20 @@ namespace :bard do
19
19
  Bard::Backup::FileTree.create!(**Bard::Backup::RailsCredentials.find)
20
20
  end
21
21
 
22
+ desc "Encrypt the backups and data files stored before encryption was enabled"
23
+ task :encrypt => :environment do
24
+ require "bard/backup"
25
+ require "bard/backup/rails_credentials"
26
+
27
+ destinations = Bard::Config.current.backup.destinations.map do |dest|
28
+ Bard::Backup::RailsCredentials.find(name: dest[:name]).merge(dest)
29
+ end
30
+
31
+ Bard::Backup.encrypt_existing!(destinations, **Bard::Backup::RailsCredentials.find).each do |result|
32
+ puts "#{result[:path]}: encrypted #{result[:encrypted].size}, already encrypted #{result[:skipped].size}"
33
+ end
34
+ end
35
+
22
36
  desc "Restore the database backup nearest a timestamp (no timestamp lists available backups)"
23
37
  task :restore, [:at] => :environment do |_task, args|
24
38
  require "bard/backup"
@@ -1,6 +1,6 @@
1
1
  module Bard
2
2
  class Backup
3
- VERSION = "0.15.2"
3
+ VERSION = "0.16.0"
4
4
  end
5
5
  end
6
6
 
data/lib/bard/backup.rb CHANGED
@@ -4,6 +4,7 @@ require "bard/backup/database"
4
4
  require "bard/backup/file_tree"
5
5
  require "bard/backup/finder"
6
6
  require "bard/backup/restore"
7
+ require "bard/backup/rekey"
7
8
  require "bard/backup/railtie" if defined?(Rails)
8
9
 
9
10
  module Bard
@@ -16,6 +17,24 @@ module Bard
16
17
  backup
17
18
  end
18
19
 
20
+ def self.encrypt_existing!(destination_hashes = nil, **config)
21
+ destination_hashes = [config] if destination_hashes.nil? && !config.empty?
22
+ trees = Destination.resolve(destination_hashes)
23
+ .select { |destination| destination.respond_to?(:s3_tree_config) }
24
+ .map { |destination| [destination.s3_tree_config, destination.encryption_key, destination.info[:name]] }
25
+
26
+ file_tree_config = FileTree.s3_tree_config(**config.slice(*FILE_TREE_KEYS, :data_paths, :project_name, :bucket))
27
+ if file_tree_config
28
+ key = file_tree_config.delete(:encryption_key)
29
+ trees << [file_tree_config, key, file_tree_config[:path]]
30
+ end
31
+
32
+ missing = trees.reject { |_, key, _| key }.map(&:last)
33
+ raise Error, "No encryption_key for #{missing.join(", ")}" if missing.any?
34
+
35
+ trees.map { |tree_config, key, _| Rekey.call(tree_config:, to_key: key) }
36
+ end
37
+
19
38
  def self.latest
20
39
  Finder.new.latest
21
40
  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.15.2
4
+ version: 0.16.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Micah Geisel
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-11-03 00:00:00.000000000 Z
10
+ date: 2026-11-13 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: backhoe
@@ -107,6 +107,7 @@ files:
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/rekey.rb
110
111
  - lib/bard/backup/restore.rb
111
112
  - lib/bard/backup/s3_tree.rb
112
113
  - lib/bard/backup/tasks.rake