noop-backup 0.1.3 → 0.2.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 +7 -0
- data/README.md +29 -22
- data/lib/noop_backup/cli.rb +3 -2
- data/lib/noop_backup/commands/backup.rb +99 -65
- data/lib/noop_backup/configuration.rb +37 -33
- data/lib/noop_backup/jobs/backup_job.rb +4 -2
- data/lib/noop_backup/stores/result.rb +11 -0
- data/lib/noop_backup/stores/s3.rb +82 -0
- data/lib/noop_backup/stores/store.rb +9 -0
- data/lib/noop_backup/stores.rb +3 -0
- data/lib/noop_backup/tee.rb +33 -0
- data/lib/noop_backup/utils.rb +14 -0
- data/lib/noop_backup/version.rb +1 -1
- data/lib/noop_backup.rb +37 -0
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bba6625ab079fd41615d6ff304e7517743a67d2cb394a6644e058f3d93a8dd0e
|
|
4
|
+
data.tar.gz: 194c11d8d1e0cdb069f05a20b06342a1d421ae4178d3d918090a9f6fc182722a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 201fdd9c78813f8f20d1e042d842dc76f2bfff07f94e71ce0ef926e6e606f549b7889075cae371602f33e6d095a1c854ce762daf21de7cbd7bee1a7bd32a730a
|
|
7
|
+
data.tar.gz: 313b0980120d3330cd6a37959813cd3fbd44f037369eab05fdff7c3a18dd8d06cd1207aaeeaac1c83eea9adb76ee1ed46303f5a1425392e7a7de3f34cfc65a82
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.2.0] - 2026-07-04
|
|
4
|
+
|
|
5
|
+
- Support multiple destination stores in a single backup run
|
|
6
|
+
- Delete partial uploads and fail the job safely when a dump fails
|
|
7
|
+
- Validate configuration before running, with clearer errors
|
|
8
|
+
- A lot of undocumented API and configuration changes
|
|
9
|
+
|
|
3
10
|
## [0.1.3] - 2026-07-02
|
|
4
11
|
|
|
5
12
|
- Pass `access_key_id` and `secret_access_key` in config block
|
data/README.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Ruby gem for backing up PostgreSQL databases with minimum effort.
|
|
4
4
|
|
|
5
|
+
This gem is very much __unstable__. Expect APIs to change a lot before v1.0.0 is released.
|
|
6
|
+
|
|
5
7
|
## Installation
|
|
6
8
|
|
|
7
9
|
If using bundler, add the gem to your `Gemfile`:
|
|
@@ -24,11 +26,24 @@ The gem requires `pg_dump` to be installed on the machine that is running it.
|
|
|
24
26
|
|
|
25
27
|
#### Rails 8 with Solid Queue
|
|
26
28
|
|
|
27
|
-
|
|
29
|
+
Add the NoopBackup job to the `config/recurring.yml` file:
|
|
30
|
+
|
|
31
|
+
```yaml
|
|
32
|
+
production:
|
|
33
|
+
# other jobs
|
|
34
|
+
noop_backup:
|
|
35
|
+
class: NoopBackup::BackupJob
|
|
36
|
+
schedule: at 3am every day
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The job will run on the `:default` queue on your desired schedule.
|
|
40
|
+
If you wish to customize the job, create a new one and make sure to invoke
|
|
41
|
+
`NoopBackup::Commands::Backup.execute` in the `perform` method.
|
|
42
|
+
|
|
28
43
|
|
|
29
44
|
#### Sidekiq, Good Job, whenever
|
|
30
45
|
|
|
31
|
-
|
|
46
|
+
Create a new recurring job and have it invoke `NoopBackup::Commands::Backup.execute`
|
|
32
47
|
|
|
33
48
|
### Manually
|
|
34
49
|
|
|
@@ -36,33 +51,23 @@ The gem requires `pg_dump` to be installed on the machine that is running it.
|
|
|
36
51
|
bundle exec nbu backup
|
|
37
52
|
```
|
|
38
53
|
|
|
39
|
-
This command will dump the database and stream it to
|
|
40
|
-
scheduler or even cron to run it periodically.
|
|
54
|
+
This command will dump the database and stream it to your configured destinations without writing
|
|
55
|
+
anything to disk. Use any scheduler or even cron to run it periodically.
|
|
41
56
|
|
|
42
57
|
## Configuration
|
|
43
58
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
```
|
|
47
|
-
AWS_REGION=region
|
|
48
|
-
AWS_ACCESS_KEY_ID=your-key
|
|
49
|
-
AWS_SECRET_ACCESS_KEY=your-secret-key
|
|
50
|
-
NBU_BUCKET=s3-bucket
|
|
51
|
-
NBU_MIN_SIZE=2048
|
|
52
|
-
```
|
|
53
|
-
|
|
54
|
-
If your app already has the AWS SDK set up, only the bucket needs to be configured.
|
|
55
|
-
|
|
56
|
-
Alternatively, the gem can be configured with an initializer:
|
|
59
|
+
All configuration options can be edited in the initializer:
|
|
57
60
|
|
|
58
61
|
```rb
|
|
59
62
|
# config/initializers/noop-backup.rb
|
|
60
63
|
|
|
61
64
|
NoopBackup.configure do |config|
|
|
62
|
-
config.
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
65
|
+
config.register(:s3) do |store|
|
|
66
|
+
store.bucket = Settings.aws.bucket
|
|
67
|
+
store.region = Settings.aws.region
|
|
68
|
+
store.access_key_id = Settings.aws.access_key_id
|
|
69
|
+
store.secret_access_key = Settings.aws.secret_access_key
|
|
70
|
+
end
|
|
66
71
|
|
|
67
72
|
config.notifier :slack do |slack|
|
|
68
73
|
slack.webhook_url = 'https://hooks.slack.com/services/whatever'
|
|
@@ -83,11 +88,13 @@ Bug reports and pull requests are welcome on GitHub at https://github.com/gmitre
|
|
|
83
88
|
## Wishlist
|
|
84
89
|
|
|
85
90
|
- [ ] S3-compatible backends
|
|
86
|
-
- [ ] file
|
|
91
|
+
- [ ] file backend
|
|
87
92
|
- [ ] email notifier
|
|
88
93
|
- [ ] SMS notifier
|
|
89
94
|
- [ ] restore command
|
|
90
95
|
- [ ] test command
|
|
96
|
+
- [ ] encryption
|
|
97
|
+
- [ ] delete stale
|
|
91
98
|
|
|
92
99
|
## License
|
|
93
100
|
|
data/lib/noop_backup/cli.rb
CHANGED
|
@@ -7,9 +7,10 @@ module NoopBackup
|
|
|
7
7
|
desc "backup", "Create and store a new backup"
|
|
8
8
|
def backup
|
|
9
9
|
NoopBackup.prepare!
|
|
10
|
-
NoopBackup::Commands::Backup.execute
|
|
10
|
+
result = NoopBackup::Commands::Backup.execute
|
|
11
|
+
exit 1 unless result.success?
|
|
11
12
|
rescue => e
|
|
12
|
-
warn e
|
|
13
|
+
warn e
|
|
13
14
|
exit 1
|
|
14
15
|
end
|
|
15
16
|
end
|
|
@@ -1,101 +1,135 @@
|
|
|
1
|
-
require "aws-sdk-s3"
|
|
2
1
|
require "open3"
|
|
3
2
|
|
|
4
3
|
module NoopBackup::Commands
|
|
4
|
+
CommandResult = Struct.new(:error, :store_results, keyword_init: true) do
|
|
5
|
+
def success?
|
|
6
|
+
status == :success
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def status
|
|
10
|
+
return :error if error
|
|
11
|
+
return :error if store_results.empty? || store_results.none?(&:success)
|
|
12
|
+
return :success if store_results.all?(&:success)
|
|
13
|
+
|
|
14
|
+
:partial_success
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def report
|
|
18
|
+
if error
|
|
19
|
+
NoopBackup.notify "❌ Fatal error: #{error.message}"
|
|
20
|
+
else
|
|
21
|
+
store_results.each do |result|
|
|
22
|
+
NoopBackup.notify result.message
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
5
28
|
class Backup
|
|
6
|
-
def self.execute
|
|
7
|
-
new.execute
|
|
29
|
+
def self.execute(report: NoopBackup.config.report?)
|
|
30
|
+
result = new.execute
|
|
31
|
+
|
|
32
|
+
result.report if report
|
|
33
|
+
|
|
34
|
+
result
|
|
8
35
|
end
|
|
9
36
|
|
|
10
37
|
def initialize
|
|
11
|
-
|
|
12
|
-
@
|
|
13
|
-
|
|
14
|
-
config.pg_env["PGDATABASE"],
|
|
15
|
-
now.strftime("%Y"),
|
|
16
|
-
now.strftime("%m"),
|
|
17
|
-
"#{now.strftime("%d-%H%M%S")}.dump"
|
|
18
|
-
].compact.join("/")
|
|
19
|
-
@bytes = 0
|
|
20
|
-
@started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
38
|
+
@key = generate_key
|
|
39
|
+
@store_results = []
|
|
40
|
+
@sinks = []
|
|
21
41
|
end
|
|
22
42
|
|
|
23
43
|
def execute
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
]
|
|
44
|
+
perform_sanity_check!
|
|
45
|
+
|
|
46
|
+
commands = [config.dump_command]
|
|
27
47
|
|
|
28
|
-
|
|
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)
|
|
29
50
|
|
|
30
51
|
Open3.pipeline_r(*commands) do |last_stdout, wait_threads|
|
|
31
|
-
|
|
52
|
+
@sinks = config.stores.map do |store|
|
|
53
|
+
reader, writer = IO.pipe(binmode: true)
|
|
54
|
+
|
|
55
|
+
thread = Thread.new do
|
|
56
|
+
store.backup!(@key, reader)
|
|
57
|
+
rescue => e
|
|
58
|
+
# *always* return a Result, even if unexpected. Add store name for debugging.
|
|
59
|
+
NoopBackup::Stores::Result.new(success: false, error: e, store: store.class.name, key: @key)
|
|
60
|
+
ensure
|
|
61
|
+
reader.close
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
NoopBackup::Tee::Sink.new(store:, writer:, thread:)
|
|
65
|
+
end
|
|
32
66
|
|
|
33
|
-
|
|
34
|
-
end
|
|
67
|
+
sinks_fanout = NoopBackup::Tee.new(@sinks)
|
|
35
68
|
|
|
36
|
-
|
|
69
|
+
begin
|
|
70
|
+
IO.copy_stream(last_stdout, sinks_fanout)
|
|
71
|
+
rescue => e
|
|
72
|
+
raise NoopBackup::DumpFailedError, "streaming failed: #{e.message}"
|
|
73
|
+
ensure
|
|
74
|
+
@sinks.each(&:close)
|
|
75
|
+
end
|
|
37
76
|
|
|
38
|
-
|
|
39
|
-
|
|
77
|
+
@store_results = @sinks.map(&:collect)
|
|
78
|
+
|
|
79
|
+
raise NoopBackup::DumpFailedError, "pipeline failed" unless wait_threads.all? { |t| t.value.success? }
|
|
40
80
|
end
|
|
41
81
|
|
|
42
|
-
|
|
43
|
-
rescue =>
|
|
44
|
-
|
|
82
|
+
CommandResult.new(store_results: @store_results)
|
|
83
|
+
rescue NoopBackup::DumpFailedError => error
|
|
84
|
+
# The dump stream itself failed, so uploads that finished are truncated copies of a bad stream — delete them.
|
|
85
|
+
# Collect the results first: if copy_stream raised, the collection line above never ran. Sinks are already
|
|
86
|
+
# closed on every DumpFailedError path, so collect can't block.
|
|
87
|
+
@store_results = @sinks.map(&:collect)
|
|
45
88
|
|
|
46
|
-
|
|
89
|
+
cleanup_uploaded_stores!
|
|
47
90
|
|
|
48
|
-
|
|
91
|
+
CommandResult.new(error:, store_results: @store_results)
|
|
92
|
+
rescue => error
|
|
93
|
+
CommandResult.new(error:, store_results: @store_results)
|
|
49
94
|
end
|
|
50
95
|
|
|
51
96
|
private
|
|
52
97
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
98
|
+
# 1. Check if any stores are registered.
|
|
99
|
+
# 2. Make sure **all** stores have a valid configuration
|
|
100
|
+
def perform_sanity_check!
|
|
101
|
+
raise NoopBackup::ConfigurationError, "No backup stores registered" if config.stores.empty?
|
|
102
|
+
raise NoopBackup::ConfigurationError, "Could not resolve PGDATABASE" if config.pg_env["PGDATABASE"].to_s.empty?
|
|
56
103
|
|
|
57
|
-
|
|
58
|
-
@_s3_client ||= Aws::S3::Client.new(**config.s3_config)
|
|
104
|
+
config.stores.each(&:validate!)
|
|
59
105
|
end
|
|
60
106
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
manager.upload_stream(bucket: config.bucket, key: @key, part_size: 8 * 1024 * 1024, thread_count: 2) do |s3_stream|
|
|
68
|
-
@bytes = IO.copy_stream(stdout, s3_stream)
|
|
69
|
-
end
|
|
70
|
-
else
|
|
71
|
-
object = Aws::S3::Resource.new(region: config.region).bucket(config.bucket).object(@key)
|
|
72
|
-
|
|
73
|
-
object.upload_stream(part_size: 8 * 1024 * 1024, thread_count: 2) do |s3_stream|
|
|
74
|
-
@bytes = IO.copy_stream(stdout, s3_stream)
|
|
75
|
-
end
|
|
107
|
+
def cleanup_uploaded_stores!
|
|
108
|
+
@sinks.each do |sink|
|
|
109
|
+
sink.store.cleanup!(@key) if sink.collect&.success
|
|
110
|
+
rescue => e
|
|
111
|
+
NoopBackup.notify "⚠️ Cleanup failed for #{sink.store.class}: #{e.message}"
|
|
76
112
|
end
|
|
77
113
|
end
|
|
78
114
|
|
|
79
|
-
def
|
|
80
|
-
|
|
81
|
-
size = bytes.to_f
|
|
82
|
-
i = 0
|
|
83
|
-
while size >= 1024 && i < units.size - 1
|
|
84
|
-
size /= 1024
|
|
85
|
-
i += 1
|
|
86
|
-
end
|
|
87
|
-
format("%.1f %s", size, units[i])
|
|
115
|
+
def config
|
|
116
|
+
@config ||= NoopBackup.config
|
|
88
117
|
end
|
|
89
118
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
119
|
+
# File name of the current backup. Example:
|
|
120
|
+
#
|
|
121
|
+
# prefix db_name y m d h m s ms
|
|
122
|
+
# /database/db_name_production/2026/07/03-14-47-23-724.dump
|
|
123
|
+
def generate_key
|
|
124
|
+
now = Time.now.utc
|
|
94
125
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
126
|
+
[
|
|
127
|
+
config.prefix,
|
|
128
|
+
config.pg_env["PGDATABASE"],
|
|
129
|
+
now.strftime("%Y"),
|
|
130
|
+
now.strftime("%m"),
|
|
131
|
+
"#{now.strftime("%d-%H-%M-%S-%L")}.dump"
|
|
132
|
+
].compact.join("/")
|
|
99
133
|
end
|
|
100
134
|
end
|
|
101
135
|
end
|
|
@@ -1,22 +1,44 @@
|
|
|
1
1
|
module NoopBackup
|
|
2
2
|
class Configuration
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
:region,
|
|
6
|
-
:access_key_id,
|
|
7
|
-
:secret_access_key,
|
|
8
|
-
:prefix,
|
|
3
|
+
attr_accessor :prefix,
|
|
4
|
+
:min_size,
|
|
9
5
|
:pg_host,
|
|
10
6
|
:pg_port,
|
|
11
7
|
:pg_user,
|
|
12
8
|
:pg_password,
|
|
13
|
-
:pg_database
|
|
14
|
-
|
|
9
|
+
:pg_database
|
|
10
|
+
|
|
11
|
+
attr_reader :stores, :notifiers
|
|
12
|
+
attr_writer :report, :dump_command
|
|
15
13
|
|
|
16
14
|
def initialize
|
|
17
|
-
@prefix = "database"
|
|
15
|
+
@prefix = ENV.fetch("NBU_PREFIX", "database")
|
|
16
|
+
@stores = []
|
|
17
|
+
@min_size = ENV.fetch("NBU_MIN_SIZE", 2048).to_i
|
|
18
18
|
@notifiers = [NoopBackup::Notifiers::Stdout.new]
|
|
19
|
-
@
|
|
19
|
+
@report = true
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def report?
|
|
23
|
+
@report
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def dump_command
|
|
27
|
+
@dump_command || [pg_env, "pg_dump", "--format=custom", "--no-owner"]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def register(store_type)
|
|
31
|
+
raise NoopBackup::ConfigurationError, "`config.register` requires a block" unless block_given?
|
|
32
|
+
|
|
33
|
+
store =
|
|
34
|
+
case store_type.to_sym
|
|
35
|
+
when :s3 then NoopBackup::Stores::S3.new
|
|
36
|
+
else raise NoopBackup::ConfigurationError, "unknown store type: #{store_type}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
@stores << store
|
|
40
|
+
|
|
41
|
+
yield store
|
|
20
42
|
end
|
|
21
43
|
|
|
22
44
|
def notifier(type)
|
|
@@ -31,27 +53,13 @@ module NoopBackup
|
|
|
31
53
|
end
|
|
32
54
|
end
|
|
33
55
|
|
|
34
|
-
def notify(message)
|
|
35
|
-
@notifiers.each do |notifier|
|
|
36
|
-
notifier.notify(message)
|
|
37
|
-
end
|
|
38
|
-
end
|
|
39
|
-
|
|
40
56
|
def pg_env
|
|
41
57
|
{
|
|
42
|
-
"PGHOST" => pg_host || db_config[:host]&.to_s
|
|
43
|
-
"PGPORT" => pg_port || db_config[:port]&.to_s,
|
|
44
|
-
"PGUSER" => pg_user || db_config[:username]&.to_s,
|
|
45
|
-
"PGPASSWORD" => pg_password || db_config[:password]&.to_s,
|
|
46
|
-
"PGDATABASE" => pg_database || db_config[:database]
|
|
47
|
-
}.compact
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
def s3_config
|
|
51
|
-
{
|
|
52
|
-
region: region,
|
|
53
|
-
access_key_id: access_key_id,
|
|
54
|
-
secret_access_key: secret_access_key
|
|
58
|
+
"PGHOST" => (pg_host || db_config[:host])&.to_s,
|
|
59
|
+
"PGPORT" => (pg_port || db_config[:port])&.to_s,
|
|
60
|
+
"PGUSER" => (pg_user || db_config[:username])&.to_s,
|
|
61
|
+
"PGPASSWORD" => (pg_password || db_config[:password])&.to_s,
|
|
62
|
+
"PGDATABASE" => (pg_database || db_config[:database])&.to_s
|
|
55
63
|
}.compact
|
|
56
64
|
end
|
|
57
65
|
|
|
@@ -59,9 +67,5 @@ module NoopBackup
|
|
|
59
67
|
@db_config ||= defined?(::ActiveRecord) ?
|
|
60
68
|
::ActiveRecord::Base.connection_db_config.configuration_hash : {}
|
|
61
69
|
end
|
|
62
|
-
|
|
63
|
-
# TODO: raise if config invalid (e.g. bucket missing)
|
|
64
|
-
def validate!
|
|
65
|
-
end
|
|
66
70
|
end
|
|
67
71
|
end
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
module NoopBackup
|
|
2
2
|
class BackupJob < ActiveJob::Base
|
|
3
|
-
queue_as :
|
|
3
|
+
queue_as :default
|
|
4
4
|
|
|
5
5
|
def perform
|
|
6
|
-
NoopBackup::Commands::Backup.execute
|
|
6
|
+
result = NoopBackup::Commands::Backup.execute
|
|
7
|
+
|
|
8
|
+
raise NoopBackup::BackupFailedError.new(result) unless result.success?
|
|
7
9
|
end
|
|
8
10
|
end
|
|
9
11
|
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
module NoopBackup::Stores
|
|
2
|
+
Result = Struct.new(:success, :error, :store, :key, :bytes, :duration, keyword_init: true) do
|
|
3
|
+
def message
|
|
4
|
+
if success
|
|
5
|
+
"✅ [#{store}] Backup successful — #{NoopBackup.utils.human_size(bytes)} in #{duration.round(1)}s → /#{key}"
|
|
6
|
+
else
|
|
7
|
+
"❌ [#{store}] Backup failed: #{error.message}"
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
require "aws-sdk-s3"
|
|
2
|
+
|
|
3
|
+
module NoopBackup::Stores
|
|
4
|
+
class S3 < Store
|
|
5
|
+
attr_accessor :bucket, :region, :access_key_id, :secret_access_key
|
|
6
|
+
|
|
7
|
+
def initialize
|
|
8
|
+
@bucket = ENV["AWS_S3_BUCKET"]
|
|
9
|
+
@region = ENV["AWS_REGION"]
|
|
10
|
+
@access_key_id = ENV["AWS_ACCESS_KEY_ID"]
|
|
11
|
+
@secret_access_key = ENV["AWS_SECRET_ACCESS_KEY"]
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Prefer Aws::S3::TransferManager for streaming uploads if available.
|
|
15
|
+
# Aws::S3::Resource.upload_stream is deprecated in newer versions
|
|
16
|
+
def backup!(key, stream)
|
|
17
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
18
|
+
bytes = 0
|
|
19
|
+
|
|
20
|
+
validate!
|
|
21
|
+
|
|
22
|
+
upload_attempted = true
|
|
23
|
+
|
|
24
|
+
if defined?(Aws::S3::TransferManager)
|
|
25
|
+
manager = Aws::S3::TransferManager.new(client: s3_client)
|
|
26
|
+
|
|
27
|
+
manager.upload_stream(bucket:, key: key, part_size: 8 * 1024 * 1024, thread_count: 2) do |s3_stream|
|
|
28
|
+
bytes = IO.copy_stream(stream, s3_stream)
|
|
29
|
+
end
|
|
30
|
+
else
|
|
31
|
+
object = Aws::S3::Resource.new(client: s3_client).bucket(bucket).object(key)
|
|
32
|
+
|
|
33
|
+
object.upload_stream(part_size: 8 * 1024 * 1024, thread_count: 2) do |s3_stream|
|
|
34
|
+
bytes = IO.copy_stream(stream, s3_stream)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
|
|
39
|
+
|
|
40
|
+
if config.min_size && bytes < config.min_size.to_i
|
|
41
|
+
raise NoopBackup::DumpTooSmallError, "backup too small: #{NoopBackup.utils.human_size(bytes)} < min_size (#{NoopBackup.utils.human_size(config.min_size)})"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
Result.new(success: true, store: :s3, bytes:, key:, duration:)
|
|
45
|
+
rescue => e
|
|
46
|
+
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
|
|
47
|
+
|
|
48
|
+
cleanup!(key) if upload_attempted
|
|
49
|
+
|
|
50
|
+
Result.new(success: false, error: e, store: :s3, bytes:, key:, duration:)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def validate!
|
|
54
|
+
raise NoopBackup::ConfigurationError, "bucket is not configured" if bucket.to_s.empty?
|
|
55
|
+
|
|
56
|
+
if access_key_id.to_s.empty? != secret_access_key.to_s.empty?
|
|
57
|
+
raise NoopBackup::ConfigurationError,
|
|
58
|
+
"access_key_id and secret_access_key must both be set, or both left blank to use the default AWS credential chain"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def cleanup!(key)
|
|
63
|
+
s3_client.delete_object(bucket:, key: key)
|
|
64
|
+
rescue => e
|
|
65
|
+
warn "Failed to clean up partial upload #{key}: #{e.message}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def s3_client
|
|
71
|
+
@_s3_client ||= Aws::S3::Client.new(**s3_config)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def s3_config
|
|
75
|
+
{
|
|
76
|
+
region: region,
|
|
77
|
+
access_key_id: access_key_id,
|
|
78
|
+
secret_access_key: secret_access_key
|
|
79
|
+
}.compact
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module NoopBackup
|
|
2
|
+
class Tee
|
|
3
|
+
Sink = Struct.new(:store, :writer, :thread, keyword_init: true) do
|
|
4
|
+
def write(chunk)
|
|
5
|
+
return if @error
|
|
6
|
+
|
|
7
|
+
writer.write(chunk)
|
|
8
|
+
rescue Errno::EPIPE, IOError => e
|
|
9
|
+
@error = e
|
|
10
|
+
|
|
11
|
+
close unless writer.closed?
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def close
|
|
15
|
+
writer.close
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def collect
|
|
19
|
+
thread.value
|
|
20
|
+
rescue
|
|
21
|
+
nil
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(sinks) = @sinks = sinks
|
|
26
|
+
|
|
27
|
+
def write(chunk)
|
|
28
|
+
@sinks.each { |sink| sink.write(chunk) }
|
|
29
|
+
|
|
30
|
+
chunk.bytesize
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
data/lib/noop_backup/version.rb
CHANGED
data/lib/noop_backup.rb
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
require_relative "noop_backup/version"
|
|
2
2
|
require_relative "noop_backup/configuration"
|
|
3
|
+
require_relative "noop_backup/tee"
|
|
4
|
+
require_relative "noop_backup/utils"
|
|
5
|
+
require_relative "noop_backup/stores"
|
|
3
6
|
require_relative "noop_backup/commands/backup"
|
|
4
7
|
require_relative "noop_backup/notifiers/slack"
|
|
5
8
|
require_relative "noop_backup/notifiers/stdout"
|
|
@@ -7,6 +10,26 @@ require_relative "noop_backup/notifiers/stdout"
|
|
|
7
10
|
module NoopBackup
|
|
8
11
|
class Error < StandardError; end
|
|
9
12
|
|
|
13
|
+
class DumpTooSmallError < Error; end
|
|
14
|
+
class DumpFailedError < Error; end
|
|
15
|
+
class ConfigurationError < Error; end
|
|
16
|
+
|
|
17
|
+
class BackupFailedError < Error
|
|
18
|
+
attr_reader :result
|
|
19
|
+
|
|
20
|
+
def initialize(result)
|
|
21
|
+
@result = result
|
|
22
|
+
|
|
23
|
+
super("Backup failed (#{result.status}): #{result.error&.message || failed_stores(result)}")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def failed_stores(result)
|
|
29
|
+
result.store_results.reject(&:success).map(&:store).join(", ")
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
10
33
|
class << self
|
|
11
34
|
def configuration
|
|
12
35
|
@configuration ||= Configuration.new
|
|
@@ -14,6 +37,10 @@ module NoopBackup
|
|
|
14
37
|
|
|
15
38
|
alias_method :config, :configuration
|
|
16
39
|
|
|
40
|
+
def reset!
|
|
41
|
+
@configuration = nil
|
|
42
|
+
end
|
|
43
|
+
|
|
17
44
|
def configure
|
|
18
45
|
yield(configuration)
|
|
19
46
|
end
|
|
@@ -24,6 +51,16 @@ module NoopBackup
|
|
|
24
51
|
|
|
25
52
|
require env_file if File.exist?(env_file)
|
|
26
53
|
end
|
|
54
|
+
|
|
55
|
+
def notify(message)
|
|
56
|
+
config.notifiers.each do |notifier|
|
|
57
|
+
notifier.notify(message)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def utils
|
|
62
|
+
NoopBackup::Utils
|
|
63
|
+
end
|
|
27
64
|
end
|
|
28
65
|
end
|
|
29
66
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: noop-backup
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Georgi Mitrev
|
|
@@ -60,6 +60,12 @@ files:
|
|
|
60
60
|
- lib/noop_backup/notifiers/slack.rb
|
|
61
61
|
- lib/noop_backup/notifiers/stdout.rb
|
|
62
62
|
- lib/noop_backup/plugins/rails.rb
|
|
63
|
+
- lib/noop_backup/stores.rb
|
|
64
|
+
- lib/noop_backup/stores/result.rb
|
|
65
|
+
- lib/noop_backup/stores/s3.rb
|
|
66
|
+
- lib/noop_backup/stores/store.rb
|
|
67
|
+
- lib/noop_backup/tee.rb
|
|
68
|
+
- lib/noop_backup/utils.rb
|
|
63
69
|
- lib/noop_backup/version.rb
|
|
64
70
|
homepage: https://github.com/gmitrev/noop-backup
|
|
65
71
|
licenses:
|