kamal-backup 0.3.0 → 0.4.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/README.md +2 -0
- data/lib/kamal_backup/app.rb +150 -202
- data/lib/kamal_backup/cli/helpers.rb +298 -0
- data/lib/kamal_backup/cli.rb +13 -294
- data/lib/kamal_backup/command.rb +11 -187
- data/lib/kamal_backup/command_output.rb +189 -0
- data/lib/kamal_backup/config.rb +77 -481
- data/lib/kamal_backup/config_file.rb +376 -0
- data/lib/kamal_backup/databases/base.rb +11 -0
- data/lib/kamal_backup/databases/postgres.rb +3 -3
- data/lib/kamal_backup/evidence.rb +0 -3
- data/lib/kamal_backup/kamal_bridge.rb +39 -27
- data/lib/kamal_backup/rails_app.rb +6 -17
- data/lib/kamal_backup/restic.rb +46 -45
- data/lib/kamal_backup/version.rb +1 -1
- data/lib/kamal_backup/yaml_access.rb +13 -0
- data/lib/kamal_backup.rb +3 -0
- metadata +48 -2
data/lib/kamal_backup/command.rb
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
require 'open3'
|
|
4
4
|
require 'pty'
|
|
5
|
-
require 'securerandom'
|
|
6
5
|
require 'shellwords'
|
|
7
6
|
require_relative 'errors'
|
|
8
7
|
|
|
@@ -30,190 +29,6 @@ module KamalBackup
|
|
|
30
29
|
|
|
31
30
|
CommandResult = Struct.new(:stdout, :stderr, :status, :streamed, keyword_init: true)
|
|
32
31
|
|
|
33
|
-
class CommandOutput
|
|
34
|
-
LEVELS = {
|
|
35
|
-
'DEBUG' => 0,
|
|
36
|
-
'INFO' => 1,
|
|
37
|
-
'WARN' => 2,
|
|
38
|
-
'ERROR' => 3,
|
|
39
|
-
'FATAL' => 4
|
|
40
|
-
}.freeze
|
|
41
|
-
LEVEL_COLORS = {
|
|
42
|
-
'DEBUG' => :black,
|
|
43
|
-
'INFO' => :blue,
|
|
44
|
-
'WARN' => :yellow,
|
|
45
|
-
'ERROR' => :red,
|
|
46
|
-
'FATAL' => :red
|
|
47
|
-
}.freeze
|
|
48
|
-
COLOR_CODES = {
|
|
49
|
-
black: 30,
|
|
50
|
-
red: 31,
|
|
51
|
-
green: 32,
|
|
52
|
-
yellow: 33,
|
|
53
|
-
blue: 34,
|
|
54
|
-
magenta: 35,
|
|
55
|
-
cyan: 36,
|
|
56
|
-
white: 37,
|
|
57
|
-
light_black: 90,
|
|
58
|
-
light_red: 91,
|
|
59
|
-
light_green: 92,
|
|
60
|
-
light_yellow: 93,
|
|
61
|
-
light_blue: 94,
|
|
62
|
-
light_magenta: 95,
|
|
63
|
-
light_cyan: 96,
|
|
64
|
-
light_white: 97
|
|
65
|
-
}.freeze
|
|
66
|
-
|
|
67
|
-
def initialize(io: $stdout, env: ENV, verbosity: :info)
|
|
68
|
-
@io = io
|
|
69
|
-
@env = env
|
|
70
|
-
@verbosity = LEVELS.fetch(verbosity.to_s.upcase)
|
|
71
|
-
@mutex = Mutex.new
|
|
72
|
-
@buffers = {}
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
def info(message, redactor:)
|
|
76
|
-
write_message('INFO', redactor.redact_string(message))
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
def error(message, redactor:)
|
|
80
|
-
write_message('ERROR', colorize(redactor.redact_string(message), :red, :bold))
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
def decorate(value, color, mode = nil)
|
|
84
|
-
colorize(value, color, mode)
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
def command_start(spec, redactor:)
|
|
88
|
-
id = SecureRandom.hex(4)
|
|
89
|
-
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
90
|
-
display = spec.display(redactor)
|
|
91
|
-
|
|
92
|
-
write_message('INFO', "Running #{colorize(display, :yellow, :bold)} #{target_for(spec)}", id)
|
|
93
|
-
write_message('DEBUG', "Command: #{colorize(display, :blue)}", id)
|
|
94
|
-
|
|
95
|
-
{ id: id, started_at: started_at, redactor: redactor }
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
def command_output(context, stream, data, redactor:)
|
|
99
|
-
raw = data.to_s
|
|
100
|
-
return if raw.empty?
|
|
101
|
-
return unless log_level?('DEBUG')
|
|
102
|
-
|
|
103
|
-
synchronize do
|
|
104
|
-
key = [context.fetch(:id), stream]
|
|
105
|
-
@buffers[key] = "#{@buffers[key]}#{raw}"
|
|
106
|
-
flush_complete_output_lines(context, key, redactor: redactor)
|
|
107
|
-
end
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
def command_exit(context, status)
|
|
111
|
-
runtime = Process.clock_gettime(Process::CLOCK_MONOTONIC) - context.fetch(:started_at)
|
|
112
|
-
result = status.to_i.zero? ? 'successful' : 'failed'
|
|
113
|
-
result_color = status.to_i.zero? ? :green : :red
|
|
114
|
-
|
|
115
|
-
synchronize do
|
|
116
|
-
flush_output_buffers(context)
|
|
117
|
-
message = "Finished in #{format('%.3f seconds', runtime)} with exit status #{status} " \
|
|
118
|
-
"(#{colorize(result, result_color, :bold)})."
|
|
119
|
-
write_message_unlocked('INFO', message, context.fetch(:id))
|
|
120
|
-
end
|
|
121
|
-
end
|
|
122
|
-
|
|
123
|
-
private
|
|
124
|
-
|
|
125
|
-
def write_message(level, message, id = nil)
|
|
126
|
-
return unless log_level?(level)
|
|
127
|
-
|
|
128
|
-
synchronize { write_message_unlocked(level, message, id) }
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
def write_message_unlocked(level, message, id = nil)
|
|
132
|
-
@io.puts(format_message(level, message, id)) if log_level?(level)
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
def synchronize(&block)
|
|
136
|
-
@mutex.synchronize(&block)
|
|
137
|
-
end
|
|
138
|
-
|
|
139
|
-
def flush_complete_output_lines(context, key, redactor:)
|
|
140
|
-
buffer = @buffers.fetch(key)
|
|
141
|
-
output = +''
|
|
142
|
-
|
|
143
|
-
while (index = buffer.index("\n"))
|
|
144
|
-
output << buffer.slice!(0..index)
|
|
145
|
-
end
|
|
146
|
-
|
|
147
|
-
@buffers[key] = buffer
|
|
148
|
-
write_output(context, output, redactor: redactor, stream: key.last) unless output.empty?
|
|
149
|
-
end
|
|
150
|
-
|
|
151
|
-
def flush_output_buffers(context)
|
|
152
|
-
id = context.fetch(:id)
|
|
153
|
-
keys = @buffers.keys.select { |key_id, _stream| key_id == id }
|
|
154
|
-
|
|
155
|
-
keys.each do |key|
|
|
156
|
-
output = @buffers.delete(key)
|
|
157
|
-
next if output.to_s.empty?
|
|
158
|
-
|
|
159
|
-
write_output(context, output, redactor: context.fetch(:redactor), stream: key.last)
|
|
160
|
-
end
|
|
161
|
-
end
|
|
162
|
-
|
|
163
|
-
def write_output(context, output, redactor:, stream: nil)
|
|
164
|
-
color = stream == :stderr ? :red : :green
|
|
165
|
-
|
|
166
|
-
redactor.redact_string(output).each_line do |line|
|
|
167
|
-
write_message_unlocked('DEBUG', colorize("\t#{line}".chomp, color), context.fetch(:id))
|
|
168
|
-
end
|
|
169
|
-
@io.flush if @io.respond_to?(:flush)
|
|
170
|
-
end
|
|
171
|
-
|
|
172
|
-
def format_message(level, message, id = nil)
|
|
173
|
-
message = "[#{colorize(id, :green)}] #{message}" if id
|
|
174
|
-
"#{colorize(level.rjust(6), LEVEL_COLORS.fetch(level))} #{message}"
|
|
175
|
-
end
|
|
176
|
-
|
|
177
|
-
def local_target
|
|
178
|
-
if (remote_host = @env['KAMAL_HOST'].to_s) && !remote_host.empty?
|
|
179
|
-
return "on #{colorize(remote_host, :blue)}"
|
|
180
|
-
end
|
|
181
|
-
|
|
182
|
-
user = @env['USER'].to_s.empty? ? @env['USERNAME'].to_s : @env['USER'].to_s
|
|
183
|
-
|
|
184
|
-
if user.empty?
|
|
185
|
-
"on #{colorize('localhost', :blue)}"
|
|
186
|
-
else
|
|
187
|
-
"as #{colorize(user, :blue)}@#{colorize('localhost', :blue)}"
|
|
188
|
-
end
|
|
189
|
-
end
|
|
190
|
-
|
|
191
|
-
def target_for(spec)
|
|
192
|
-
if spec.host
|
|
193
|
-
"on #{colorize(spec.host, :blue)}"
|
|
194
|
-
else
|
|
195
|
-
local_target
|
|
196
|
-
end
|
|
197
|
-
end
|
|
198
|
-
|
|
199
|
-
def log_level?(level)
|
|
200
|
-
LEVELS.fetch(level) >= @verbosity
|
|
201
|
-
end
|
|
202
|
-
|
|
203
|
-
def colorize(value, color, mode = nil)
|
|
204
|
-
string = value.to_s
|
|
205
|
-
return string unless colorize?
|
|
206
|
-
return string unless COLOR_CODES.key?(color)
|
|
207
|
-
|
|
208
|
-
prefix = mode == :bold ? "\e[1;" : "\e[0;"
|
|
209
|
-
"#{prefix}#{COLOR_CODES.fetch(color)};49m#{string}\e[0m"
|
|
210
|
-
end
|
|
211
|
-
|
|
212
|
-
def colorize?
|
|
213
|
-
@env['SSHKIT_COLOR'] || (@io.respond_to?(:tty?) && @io.tty?)
|
|
214
|
-
end
|
|
215
|
-
end
|
|
216
|
-
|
|
217
32
|
class Command
|
|
218
33
|
class << self
|
|
219
34
|
attr_accessor :output
|
|
@@ -359,12 +174,13 @@ module KamalBackup
|
|
|
359
174
|
end
|
|
360
175
|
|
|
361
176
|
def command_failure(spec, status, stdout, stderr, redactor)
|
|
177
|
+
redacted_stderr = redactor.redact_string(stderr)
|
|
362
178
|
CommandError.new(
|
|
363
|
-
"command failed (#{status}): #{spec.display(redactor)}\n#{
|
|
179
|
+
"command failed (#{status}): #{spec.display(redactor)}\n#{redacted_stderr}#{restic_lock_hint(spec, stderr)}",
|
|
364
180
|
command: spec,
|
|
365
181
|
status: status,
|
|
366
182
|
stdout: redactor.redact_string(stdout),
|
|
367
|
-
stderr:
|
|
183
|
+
stderr: redacted_stderr
|
|
368
184
|
)
|
|
369
185
|
end
|
|
370
186
|
|
|
@@ -376,6 +192,14 @@ module KamalBackup
|
|
|
376
192
|
stderr: error.message
|
|
377
193
|
)
|
|
378
194
|
end
|
|
195
|
+
|
|
196
|
+
def restic_lock_hint(spec, stderr)
|
|
197
|
+
return '' unless spec.argv.first == 'restic'
|
|
198
|
+
return '' if spec.argv[1] == 'unlock'
|
|
199
|
+
return '' unless stderr.to_s.match?(/repository is already locked|unlock.+stale locks/i)
|
|
200
|
+
|
|
201
|
+
"\nHint: run `kamal-backup unlock` to clear stale restic locks, then retry the command."
|
|
202
|
+
end
|
|
379
203
|
end
|
|
380
204
|
end
|
|
381
205
|
end
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module KamalBackup
|
|
6
|
+
class CommandOutput
|
|
7
|
+
LEVELS = {
|
|
8
|
+
'DEBUG' => 0,
|
|
9
|
+
'INFO' => 1,
|
|
10
|
+
'WARN' => 2,
|
|
11
|
+
'ERROR' => 3,
|
|
12
|
+
'FATAL' => 4
|
|
13
|
+
}.freeze
|
|
14
|
+
LEVEL_COLORS = {
|
|
15
|
+
'DEBUG' => :black,
|
|
16
|
+
'INFO' => :blue,
|
|
17
|
+
'WARN' => :yellow,
|
|
18
|
+
'ERROR' => :red,
|
|
19
|
+
'FATAL' => :red
|
|
20
|
+
}.freeze
|
|
21
|
+
COLOR_CODES = {
|
|
22
|
+
black: 30,
|
|
23
|
+
red: 31,
|
|
24
|
+
green: 32,
|
|
25
|
+
yellow: 33,
|
|
26
|
+
blue: 34,
|
|
27
|
+
magenta: 35,
|
|
28
|
+
cyan: 36,
|
|
29
|
+
white: 37,
|
|
30
|
+
light_black: 90,
|
|
31
|
+
light_red: 91,
|
|
32
|
+
light_green: 92,
|
|
33
|
+
light_yellow: 93,
|
|
34
|
+
light_blue: 94,
|
|
35
|
+
light_magenta: 95,
|
|
36
|
+
light_cyan: 96,
|
|
37
|
+
light_white: 97
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
def initialize(io: $stdout, env: ENV, verbosity: :info)
|
|
41
|
+
@io = io
|
|
42
|
+
@env = env
|
|
43
|
+
@verbosity = LEVELS.fetch(verbosity.to_s.upcase)
|
|
44
|
+
@mutex = Mutex.new
|
|
45
|
+
@buffers = {}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def info(message, redactor:)
|
|
49
|
+
write_message('INFO', redactor.redact_string(message))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def error(message, redactor:)
|
|
53
|
+
write_message('ERROR', colorize(redactor.redact_string(message), :red, :bold))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def decorate(value, color, mode = nil)
|
|
57
|
+
colorize(value, color, mode)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def command_start(spec, redactor:)
|
|
61
|
+
id = SecureRandom.hex(4)
|
|
62
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
63
|
+
display = spec.display(redactor)
|
|
64
|
+
|
|
65
|
+
write_message('INFO', "Running #{colorize(display, :yellow, :bold)} #{target_for(spec)}", id)
|
|
66
|
+
write_message('DEBUG', "Command: #{colorize(display, :blue)}", id)
|
|
67
|
+
|
|
68
|
+
{ id: id, started_at: started_at, redactor: redactor }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def command_output(context, stream, data, redactor:)
|
|
72
|
+
raw = data.to_s
|
|
73
|
+
return if raw.empty?
|
|
74
|
+
return unless log_level?('DEBUG')
|
|
75
|
+
|
|
76
|
+
synchronize do
|
|
77
|
+
key = [context.fetch(:id), stream]
|
|
78
|
+
@buffers[key] = "#{@buffers[key]}#{raw}"
|
|
79
|
+
flush_complete_output_lines(context, key, redactor: redactor)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def command_exit(context, status)
|
|
84
|
+
runtime = Process.clock_gettime(Process::CLOCK_MONOTONIC) - context.fetch(:started_at)
|
|
85
|
+
result = status.to_i.zero? ? 'successful' : 'failed'
|
|
86
|
+
result_color = status.to_i.zero? ? :green : :red
|
|
87
|
+
|
|
88
|
+
synchronize do
|
|
89
|
+
flush_output_buffers(context)
|
|
90
|
+
message = "Finished in #{format('%.3f seconds', runtime)} with exit status #{status} " \
|
|
91
|
+
"(#{colorize(result, result_color, :bold)})."
|
|
92
|
+
write_message_unlocked('INFO', message, context.fetch(:id))
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def write_message(level, message, id = nil)
|
|
99
|
+
return unless log_level?(level)
|
|
100
|
+
|
|
101
|
+
synchronize { write_message_unlocked(level, message, id) }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def write_message_unlocked(level, message, id = nil)
|
|
105
|
+
@io.puts(format_message(level, message, id)) if log_level?(level)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def synchronize(&block)
|
|
109
|
+
@mutex.synchronize(&block)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def flush_complete_output_lines(context, key, redactor:)
|
|
113
|
+
buffer = @buffers.fetch(key)
|
|
114
|
+
output = +''
|
|
115
|
+
|
|
116
|
+
while (index = buffer.index("\n"))
|
|
117
|
+
output << buffer.slice!(0..index)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
@buffers[key] = buffer
|
|
121
|
+
write_output(context, output, redactor: redactor, stream: key.last) unless output.empty?
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def flush_output_buffers(context)
|
|
125
|
+
id = context.fetch(:id)
|
|
126
|
+
keys = @buffers.keys.select { |key_id, _stream| key_id == id }
|
|
127
|
+
|
|
128
|
+
keys.each do |key|
|
|
129
|
+
output = @buffers.delete(key)
|
|
130
|
+
next if output.to_s.empty?
|
|
131
|
+
|
|
132
|
+
write_output(context, output, redactor: context.fetch(:redactor), stream: key.last)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def write_output(context, output, redactor:, stream: nil)
|
|
137
|
+
color = stream == :stderr ? :red : :green
|
|
138
|
+
|
|
139
|
+
redactor.redact_string(output).each_line do |line|
|
|
140
|
+
write_message_unlocked('DEBUG', colorize("\t#{line}".chomp, color), context.fetch(:id))
|
|
141
|
+
end
|
|
142
|
+
@io.flush if @io.respond_to?(:flush)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def format_message(level, message, id = nil)
|
|
146
|
+
message = "[#{colorize(id, :green)}] #{message}" if id
|
|
147
|
+
"#{colorize(level.rjust(6), LEVEL_COLORS.fetch(level))} #{message}"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def local_target
|
|
151
|
+
if (remote_host = @env['KAMAL_HOST'].to_s) && !remote_host.empty?
|
|
152
|
+
return "on #{colorize(remote_host, :blue)}"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
user = @env['USER'].to_s.empty? ? @env['USERNAME'].to_s : @env['USER'].to_s
|
|
156
|
+
|
|
157
|
+
if user.empty?
|
|
158
|
+
"on #{colorize('localhost', :blue)}"
|
|
159
|
+
else
|
|
160
|
+
"as #{colorize(user, :blue)}@#{colorize('localhost', :blue)}"
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def target_for(spec)
|
|
165
|
+
if spec.host
|
|
166
|
+
"on #{colorize(spec.host, :blue)}"
|
|
167
|
+
else
|
|
168
|
+
local_target
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def log_level?(level)
|
|
173
|
+
LEVELS.fetch(level) >= @verbosity
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def colorize(value, color, mode = nil)
|
|
177
|
+
string = value.to_s
|
|
178
|
+
return string unless colorize?
|
|
179
|
+
return string unless COLOR_CODES.key?(color)
|
|
180
|
+
|
|
181
|
+
prefix = mode == :bold ? "\e[1;" : "\e[0;"
|
|
182
|
+
"#{prefix}#{COLOR_CODES.fetch(color)};49m#{string}\e[0m"
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def colorize?
|
|
186
|
+
@env['SSHKIT_COLOR'] || (@io.respond_to?(:tty?) && @io.tty?)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|