kamal-backup 0.3.0 → 0.3.1
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 +1 -0
- data/lib/kamal_backup/app.rb +145 -202
- data/lib/kamal_backup/cli/helpers.rb +298 -0
- data/lib/kamal_backup/cli.rb +4 -294
- data/lib/kamal_backup/command.rb +0 -185
- 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 +41 -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
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'shellwords'
|
|
6
|
+
require 'thor'
|
|
7
|
+
require_relative '../app'
|
|
8
|
+
require_relative '../command_output'
|
|
9
|
+
require_relative '../config'
|
|
10
|
+
require_relative '../kamal_bridge'
|
|
11
|
+
require_relative '../redactor'
|
|
12
|
+
require_relative '../version'
|
|
13
|
+
|
|
14
|
+
module KamalBackup
|
|
15
|
+
class CLI < Thor
|
|
16
|
+
module Helpers
|
|
17
|
+
def command_env
|
|
18
|
+
CLI.command_env || ENV
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def redactor
|
|
22
|
+
@redactor ||= Redactor.new(env: command_env)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def direct_app
|
|
26
|
+
@direct_app ||= App.new(
|
|
27
|
+
config: Config.new(env: command_env),
|
|
28
|
+
redactor: redactor
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def local_restore_app
|
|
33
|
+
@local_restore_app ||= App.new(
|
|
34
|
+
config: local_command_config,
|
|
35
|
+
redactor: redactor
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def local_preferences
|
|
40
|
+
@local_preferences ||= Config.new(env: command_env)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def local_command_config
|
|
44
|
+
@local_command_config ||= if deployment_mode?
|
|
45
|
+
Config.new(
|
|
46
|
+
env: command_env,
|
|
47
|
+
defaults: production_source_defaults,
|
|
48
|
+
config_paths: [Config::LOCAL_CONFIG_PATH]
|
|
49
|
+
)
|
|
50
|
+
else
|
|
51
|
+
Config.new(env: command_env)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def production_source_defaults
|
|
56
|
+
shared_config_source_defaults.merge(bridge.local_restore_defaults(accessory_name: accessory_name))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def shared_config_source_defaults
|
|
60
|
+
config = Config.new(env: {}, config_paths: [Config::SHARED_CONFIG_PATH], load_project_defaults: false)
|
|
61
|
+
|
|
62
|
+
{}.tap do |defaults|
|
|
63
|
+
defaults['APP_NAME'] = config.app_name if config.app_name
|
|
64
|
+
defaults['DATABASE_ADAPTER'] = config.database_adapter if config.database_adapter
|
|
65
|
+
defaults['RESTIC_REPOSITORY'] = config.restic_repository if config.restic_repository
|
|
66
|
+
defaults['LOCAL_RESTORE_SOURCE_PATHS'] = config.backup_paths.join("\n") if config.backup_paths.any?
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def bridge
|
|
71
|
+
@bridge ||= KamalBridge.new(
|
|
72
|
+
redactor: redactor,
|
|
73
|
+
config_file: options[:config_file],
|
|
74
|
+
destination: options[:destination],
|
|
75
|
+
env: command_env,
|
|
76
|
+
stdout: $stdout,
|
|
77
|
+
stderr: $stderr
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def deployment_mode?
|
|
82
|
+
!options[:destination].to_s.strip.empty? || !options[:config_file].to_s.strip.empty?
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def default_deploy_config?
|
|
86
|
+
File.file?(File.expand_path(KamalBridge::DEFAULT_CONFIG_FILE))
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def remote_command_mode?
|
|
90
|
+
deployment_mode? || default_deploy_config?
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def accessory_name
|
|
94
|
+
@accessory_name ||= bridge.accessory_name(preferred: local_preferences.accessory_name)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def remote_version
|
|
98
|
+
@remote_version ||= bridge.remote_version(accessory_name: accessory_name)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def exec_remote(argv, require_version_match: true)
|
|
102
|
+
ensure_remote_version_match! if require_version_match
|
|
103
|
+
|
|
104
|
+
result = bridge.execute_on_accessory(
|
|
105
|
+
accessory_name: accessory_name,
|
|
106
|
+
command: argv,
|
|
107
|
+
stream: true
|
|
108
|
+
)
|
|
109
|
+
print(result.stdout) unless result.streamed
|
|
110
|
+
$stderr.print(result.stderr) if !result.streamed && !result.stderr.empty?
|
|
111
|
+
result
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def ensure_remote_version_match!
|
|
115
|
+
return if remote_version == VERSION
|
|
116
|
+
|
|
117
|
+
raise ConfigurationError, <<~MESSAGE.strip
|
|
118
|
+
local gem version #{VERSION} does not match remote accessory version #{remote_version}.
|
|
119
|
+
Reboot the backup accessory to pick up the latest image:
|
|
120
|
+
#{accessory_reboot_command}
|
|
121
|
+
MESSAGE
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def accessory_reboot_command
|
|
125
|
+
argv = ['bin/kamal', 'accessory', 'reboot', accessory_name]
|
|
126
|
+
argv.concat(['-c', options[:config_file]]) if options[:config_file]
|
|
127
|
+
argv.concat(['-d', options[:destination]]) if options[:destination]
|
|
128
|
+
Shellwords.join(argv)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def print_remote_version_status
|
|
132
|
+
status = remote_version == VERSION ? 'in sync' : 'out of sync'
|
|
133
|
+
status_color = status == 'in sync' ? :green : :red
|
|
134
|
+
status_output = CommandOutput.new(io: $stdout, env: command_env)
|
|
135
|
+
|
|
136
|
+
puts("local: #{VERSION}")
|
|
137
|
+
puts("remote: #{remote_version}")
|
|
138
|
+
puts("status: #{status_output.decorate(status, status_color, :bold)}")
|
|
139
|
+
puts("fix: #{status_output.decorate(accessory_reboot_command, :yellow, :bold)}") if status == 'out of sync'
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def print_backup_result(result)
|
|
143
|
+
if result[:status] == 'skipped'
|
|
144
|
+
puts("No backup due. Last backup finished at #{result.fetch(:last_backup_at)}.")
|
|
145
|
+
puts("Next backup is due at #{result.fetch(:next_backup_at)}.")
|
|
146
|
+
puts("Run `#{result.fetch(:force_command)}` to force a backup now.")
|
|
147
|
+
return
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
puts("Backup completed at #{result.fetch(:finished_at)}")
|
|
151
|
+
result.fetch(:databases).each do |database|
|
|
152
|
+
puts("database #{database.fetch(:database)}: #{database.fetch(:snapshot)} at #{database.fetch(:time)}")
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
if (files = result[:files])
|
|
156
|
+
puts("files: #{files.fetch(:snapshot)} at #{files.fetch(:time)}")
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def print_prune_result(results)
|
|
161
|
+
output = Array(results).map(&:stdout).join
|
|
162
|
+
|
|
163
|
+
if output.empty?
|
|
164
|
+
puts('Prune completed')
|
|
165
|
+
else
|
|
166
|
+
print(output)
|
|
167
|
+
puts unless output.end_with?("\n")
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def validate_deploy_config
|
|
172
|
+
config = Config.new(
|
|
173
|
+
env: bridge.accessory_environment(accessory_name: accessory_name),
|
|
174
|
+
config_paths: [Config::SHARED_CONFIG_PATH],
|
|
175
|
+
load_project_defaults: false
|
|
176
|
+
)
|
|
177
|
+
config.validate_backup(check_files: false)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def confirm!(message)
|
|
181
|
+
return if options[:yes]
|
|
182
|
+
|
|
183
|
+
raise ConfigurationError, 'confirmation required; rerun with --yes' unless $stdin.tty?
|
|
184
|
+
|
|
185
|
+
raise ConfigurationError, 'aborted' unless yes?("#{message} [y/N]")
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def confirm_production_restore!(snapshot)
|
|
189
|
+
return if options[:"confirm-production-restore"]
|
|
190
|
+
|
|
191
|
+
if options[:yes]
|
|
192
|
+
raise ConfigurationError,
|
|
193
|
+
'--yes does not bypass restore production; use --confirm-production-restore only for deliberate automation'
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
unless $stdin.tty?
|
|
197
|
+
raise ConfigurationError,
|
|
198
|
+
'production restore confirmation required; rerun interactively or pass --confirm-production-restore only for deliberate automation'
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
app_name = production_restore_confirmation_config.required_app_name
|
|
202
|
+
say "This will overwrite the production database and file paths for #{app_name} from backup #{snapshot}.", :red
|
|
203
|
+
require_typed_confirmation('Type the app name to continue', app_name)
|
|
204
|
+
require_typed_confirmation('Type RESTORE PRODUCTION to continue', 'RESTORE PRODUCTION')
|
|
205
|
+
confirm!("Restore #{snapshot} into production now? This will overwrite production data.")
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def require_typed_confirmation(prompt, expected)
|
|
209
|
+
answer = ask("#{prompt}:").to_s.strip
|
|
210
|
+
return if answer == expected
|
|
211
|
+
|
|
212
|
+
raise ConfigurationError, 'aborted'
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def production_restore_confirmation_config
|
|
216
|
+
if deployment_mode?
|
|
217
|
+
Config.new(
|
|
218
|
+
env: bridge.accessory_environment(accessory_name: accessory_name),
|
|
219
|
+
config_paths: [Config::SHARED_CONFIG_PATH],
|
|
220
|
+
load_project_defaults: false
|
|
221
|
+
)
|
|
222
|
+
else
|
|
223
|
+
direct_app.config
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def prompt_required(label)
|
|
228
|
+
raise ConfigurationError, "#{label.downcase} is required; pass it on the command line" unless $stdin.tty?
|
|
229
|
+
|
|
230
|
+
value = ask("#{label}:").to_s.strip
|
|
231
|
+
raise ConfigurationError, "#{label.downcase} is required" if value.empty?
|
|
232
|
+
|
|
233
|
+
value
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def init_config_root
|
|
237
|
+
config_file = options[:config_file] || KamalBridge::DEFAULT_CONFIG_FILE
|
|
238
|
+
File.dirname(File.expand_path(config_file))
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def shared_config_path
|
|
242
|
+
File.join(init_config_root, 'kamal-backup.yml')
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def write_init_file(path, contents)
|
|
246
|
+
if File.exist?(path)
|
|
247
|
+
say "Exists: #{path}", :yellow
|
|
248
|
+
else
|
|
249
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
250
|
+
File.write(path, contents)
|
|
251
|
+
say "Created: #{path}", :green
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def shared_config_template
|
|
256
|
+
<<~YAML
|
|
257
|
+
app: your-app
|
|
258
|
+
accessory: backup
|
|
259
|
+
databases:
|
|
260
|
+
- name: app
|
|
261
|
+
adapter: postgres
|
|
262
|
+
url: postgres://your-app@your-db:5432/your_app_production
|
|
263
|
+
password:
|
|
264
|
+
secret: DATABASE_PASSWORD
|
|
265
|
+
paths:
|
|
266
|
+
- /data/storage
|
|
267
|
+
restic:
|
|
268
|
+
repository: s3:https://s3.example.com/your-app-backups
|
|
269
|
+
password:
|
|
270
|
+
secret: RESTIC_PASSWORD
|
|
271
|
+
init_if_missing: true
|
|
272
|
+
backup:
|
|
273
|
+
schedule: 1d
|
|
274
|
+
YAML
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def deploy_snippet
|
|
278
|
+
<<~YAML
|
|
279
|
+
accessories:
|
|
280
|
+
backup:
|
|
281
|
+
image: ghcr.io/crmne/kamal-backup:#{VERSION}
|
|
282
|
+
host: your-server.example.com
|
|
283
|
+
files:
|
|
284
|
+
- config/kamal-backup.yml:/app/config/kamal-backup.yml:ro
|
|
285
|
+
env:
|
|
286
|
+
secret:
|
|
287
|
+
- DATABASE_PASSWORD
|
|
288
|
+
- RESTIC_PASSWORD
|
|
289
|
+
- AWS_ACCESS_KEY_ID
|
|
290
|
+
- AWS_SECRET_ACCESS_KEY
|
|
291
|
+
volumes:
|
|
292
|
+
- "your_app_storage:/data/storage:ro"
|
|
293
|
+
- "your_app_backup_state:/var/lib/kamal-backup"
|
|
294
|
+
YAML
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
data/lib/kamal_backup/cli.rb
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'fileutils'
|
|
4
3
|
require 'json'
|
|
5
|
-
require 'shellwords'
|
|
6
4
|
require 'thor'
|
|
7
5
|
require_relative 'app'
|
|
6
|
+
require_relative 'cli/helpers'
|
|
7
|
+
require_relative 'command_output'
|
|
8
8
|
require_relative 'config'
|
|
9
9
|
require_relative 'kamal_bridge'
|
|
10
10
|
require_relative 'redactor'
|
|
@@ -12,290 +12,6 @@ require_relative 'version'
|
|
|
12
12
|
|
|
13
13
|
module KamalBackup
|
|
14
14
|
class CLI < Thor
|
|
15
|
-
module Helpers
|
|
16
|
-
def command_env
|
|
17
|
-
CLI.command_env || ENV
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
def redactor
|
|
21
|
-
@redactor ||= Redactor.new(env: command_env)
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
def direct_app
|
|
25
|
-
@direct_app ||= App.new(
|
|
26
|
-
config: Config.new(env: command_env),
|
|
27
|
-
redactor: redactor
|
|
28
|
-
)
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
def local_restore_app
|
|
32
|
-
@local_restore_app ||= App.new(
|
|
33
|
-
config: local_command_config,
|
|
34
|
-
redactor: redactor
|
|
35
|
-
)
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
def local_preferences
|
|
39
|
-
@local_preferences ||= Config.new(env: command_env)
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
def local_command_config
|
|
43
|
-
@local_command_config ||= if deployment_mode?
|
|
44
|
-
Config.new(
|
|
45
|
-
env: command_env,
|
|
46
|
-
defaults: production_source_defaults,
|
|
47
|
-
config_paths: [Config::LOCAL_CONFIG_PATH]
|
|
48
|
-
)
|
|
49
|
-
else
|
|
50
|
-
Config.new(env: command_env)
|
|
51
|
-
end
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
def production_source_defaults
|
|
55
|
-
shared_config_source_defaults.merge(bridge.local_restore_defaults(accessory_name: accessory_name))
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
def shared_config_source_defaults
|
|
59
|
-
config = Config.new(env: {}, config_paths: [Config::SHARED_CONFIG_PATH], load_project_defaults: false)
|
|
60
|
-
|
|
61
|
-
{}.tap do |defaults|
|
|
62
|
-
defaults['APP_NAME'] = config.app_name if config.app_name
|
|
63
|
-
defaults['DATABASE_ADAPTER'] = config.database_adapter if config.database_adapter
|
|
64
|
-
defaults['RESTIC_REPOSITORY'] = config.restic_repository if config.restic_repository
|
|
65
|
-
defaults['LOCAL_RESTORE_SOURCE_PATHS'] = config.backup_paths.join("\n") if config.backup_paths.any?
|
|
66
|
-
end
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
def bridge
|
|
70
|
-
@bridge ||= KamalBridge.new(
|
|
71
|
-
redactor: redactor,
|
|
72
|
-
config_file: options[:config_file],
|
|
73
|
-
destination: options[:destination],
|
|
74
|
-
env: command_env,
|
|
75
|
-
stdout: $stdout,
|
|
76
|
-
stderr: $stderr
|
|
77
|
-
)
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
def deployment_mode?
|
|
81
|
-
!options[:destination].to_s.strip.empty? || !options[:config_file].to_s.strip.empty?
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
def default_deploy_config?
|
|
85
|
-
File.file?(File.expand_path(KamalBridge::DEFAULT_CONFIG_FILE))
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
def remote_command_mode?
|
|
89
|
-
deployment_mode? || default_deploy_config?
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
def accessory_name
|
|
93
|
-
@accessory_name ||= bridge.accessory_name(preferred: local_preferences.accessory_name)
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
def remote_version
|
|
97
|
-
@remote_version ||= bridge.remote_version(accessory_name: accessory_name)
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
def exec_remote(argv, require_version_match: true)
|
|
101
|
-
ensure_remote_version_match! if require_version_match
|
|
102
|
-
|
|
103
|
-
result = bridge.execute_on_accessory(
|
|
104
|
-
accessory_name: accessory_name,
|
|
105
|
-
command: Shellwords.join(argv),
|
|
106
|
-
stream: true
|
|
107
|
-
)
|
|
108
|
-
print(result.stdout) unless result.streamed
|
|
109
|
-
$stderr.print(result.stderr) if !result.streamed && !result.stderr.empty?
|
|
110
|
-
result
|
|
111
|
-
end
|
|
112
|
-
|
|
113
|
-
def ensure_remote_version_match!
|
|
114
|
-
return if remote_version == VERSION
|
|
115
|
-
|
|
116
|
-
raise ConfigurationError, <<~MESSAGE.strip
|
|
117
|
-
local gem version #{VERSION} does not match remote accessory version #{remote_version}.
|
|
118
|
-
Reboot the backup accessory to pick up the latest image:
|
|
119
|
-
#{accessory_reboot_command}
|
|
120
|
-
MESSAGE
|
|
121
|
-
end
|
|
122
|
-
|
|
123
|
-
def accessory_reboot_command
|
|
124
|
-
argv = ['bin/kamal', 'accessory', 'reboot', accessory_name]
|
|
125
|
-
argv.concat(['-c', options[:config_file]]) if options[:config_file]
|
|
126
|
-
argv.concat(['-d', options[:destination]]) if options[:destination]
|
|
127
|
-
Shellwords.join(argv)
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
def print_remote_version_status
|
|
131
|
-
status = remote_version == VERSION ? 'in sync' : 'out of sync'
|
|
132
|
-
status_color = status == 'in sync' ? :green : :red
|
|
133
|
-
status_output = CommandOutput.new(io: $stdout, env: command_env)
|
|
134
|
-
|
|
135
|
-
puts("local: #{VERSION}")
|
|
136
|
-
puts("remote: #{remote_version}")
|
|
137
|
-
puts("status: #{status_output.decorate(status, status_color, :bold)}")
|
|
138
|
-
puts("fix: #{status_output.decorate(accessory_reboot_command, :yellow, :bold)}") if status == 'out of sync'
|
|
139
|
-
end
|
|
140
|
-
|
|
141
|
-
def print_backup_result(result)
|
|
142
|
-
return unless result.is_a?(Hash)
|
|
143
|
-
|
|
144
|
-
if result[:status] == 'skipped'
|
|
145
|
-
puts("No backup due. Last backup finished at #{result.fetch(:last_backup_at)}.")
|
|
146
|
-
puts("Next backup is due at #{result.fetch(:next_backup_at)}.")
|
|
147
|
-
puts("Run `#{result.fetch(:force_command)}` to force a backup now.")
|
|
148
|
-
return
|
|
149
|
-
end
|
|
150
|
-
|
|
151
|
-
puts("Backup completed at #{result.fetch(:finished_at)}")
|
|
152
|
-
result.fetch(:databases).each do |database|
|
|
153
|
-
puts("database #{database.fetch(:database)}: #{database.fetch(:snapshot)} at #{database.fetch(:time)}")
|
|
154
|
-
end
|
|
155
|
-
|
|
156
|
-
if (files = result[:files])
|
|
157
|
-
puts("files: #{files.fetch(:snapshot)} at #{files.fetch(:time)}")
|
|
158
|
-
end
|
|
159
|
-
end
|
|
160
|
-
|
|
161
|
-
def print_prune_result(results)
|
|
162
|
-
output = Array(results).map(&:stdout).join
|
|
163
|
-
|
|
164
|
-
if output.empty?
|
|
165
|
-
puts('Prune completed')
|
|
166
|
-
else
|
|
167
|
-
print(output)
|
|
168
|
-
puts unless output.end_with?("\n")
|
|
169
|
-
end
|
|
170
|
-
end
|
|
171
|
-
|
|
172
|
-
def validate_deploy_config
|
|
173
|
-
config = Config.new(
|
|
174
|
-
env: bridge.accessory_environment(accessory_name: accessory_name),
|
|
175
|
-
config_paths: [Config::SHARED_CONFIG_PATH],
|
|
176
|
-
load_project_defaults: false
|
|
177
|
-
)
|
|
178
|
-
config.validate_backup(check_files: false)
|
|
179
|
-
end
|
|
180
|
-
|
|
181
|
-
def confirm!(message)
|
|
182
|
-
return if options[:yes]
|
|
183
|
-
|
|
184
|
-
raise ConfigurationError, 'confirmation required; rerun with --yes' unless $stdin.tty?
|
|
185
|
-
|
|
186
|
-
raise ConfigurationError, 'aborted' unless yes?("#{message} [y/N]")
|
|
187
|
-
end
|
|
188
|
-
|
|
189
|
-
def confirm_production_restore!(snapshot)
|
|
190
|
-
return if options[:"confirm-production-restore"]
|
|
191
|
-
|
|
192
|
-
if options[:yes]
|
|
193
|
-
raise ConfigurationError,
|
|
194
|
-
'--yes does not bypass restore production; use --confirm-production-restore only for deliberate automation'
|
|
195
|
-
end
|
|
196
|
-
|
|
197
|
-
unless $stdin.tty?
|
|
198
|
-
raise ConfigurationError,
|
|
199
|
-
'production restore confirmation required; rerun interactively or pass --confirm-production-restore only for deliberate automation'
|
|
200
|
-
end
|
|
201
|
-
|
|
202
|
-
app_name = production_restore_confirmation_config.required_app_name
|
|
203
|
-
say "This will overwrite the production database and file paths for #{app_name} from backup #{snapshot}.", :red
|
|
204
|
-
require_typed_confirmation('Type the app name to continue', app_name)
|
|
205
|
-
require_typed_confirmation('Type RESTORE PRODUCTION to continue', 'RESTORE PRODUCTION')
|
|
206
|
-
confirm!("Restore #{snapshot} into production now? This will overwrite production data.")
|
|
207
|
-
end
|
|
208
|
-
|
|
209
|
-
def require_typed_confirmation(prompt, expected)
|
|
210
|
-
answer = ask("#{prompt}:").to_s.strip
|
|
211
|
-
return if answer == expected
|
|
212
|
-
|
|
213
|
-
raise ConfigurationError, 'aborted'
|
|
214
|
-
end
|
|
215
|
-
|
|
216
|
-
def production_restore_confirmation_config
|
|
217
|
-
if deployment_mode?
|
|
218
|
-
Config.new(
|
|
219
|
-
env: bridge.accessory_environment(accessory_name: accessory_name),
|
|
220
|
-
config_paths: [Config::SHARED_CONFIG_PATH],
|
|
221
|
-
load_project_defaults: false
|
|
222
|
-
)
|
|
223
|
-
else
|
|
224
|
-
direct_app.config
|
|
225
|
-
end
|
|
226
|
-
end
|
|
227
|
-
|
|
228
|
-
def prompt_required(label)
|
|
229
|
-
raise ConfigurationError, "#{label.downcase} is required; pass it on the command line" unless $stdin.tty?
|
|
230
|
-
|
|
231
|
-
value = ask("#{label}:").to_s.strip
|
|
232
|
-
raise ConfigurationError, "#{label.downcase} is required" if value.empty?
|
|
233
|
-
|
|
234
|
-
value
|
|
235
|
-
end
|
|
236
|
-
|
|
237
|
-
def init_config_root
|
|
238
|
-
config_file = options[:config_file] || KamalBridge::DEFAULT_CONFIG_FILE
|
|
239
|
-
File.dirname(File.expand_path(config_file))
|
|
240
|
-
end
|
|
241
|
-
|
|
242
|
-
def shared_config_path
|
|
243
|
-
File.join(init_config_root, 'kamal-backup.yml')
|
|
244
|
-
end
|
|
245
|
-
|
|
246
|
-
def write_init_file(path, contents)
|
|
247
|
-
if File.exist?(path)
|
|
248
|
-
say "Exists: #{path}", :yellow
|
|
249
|
-
else
|
|
250
|
-
FileUtils.mkdir_p(File.dirname(path))
|
|
251
|
-
File.write(path, contents)
|
|
252
|
-
say "Created: #{path}", :green
|
|
253
|
-
end
|
|
254
|
-
end
|
|
255
|
-
|
|
256
|
-
def shared_config_template
|
|
257
|
-
<<~YAML
|
|
258
|
-
app: your-app
|
|
259
|
-
accessory: backup
|
|
260
|
-
databases:
|
|
261
|
-
- name: app
|
|
262
|
-
adapter: postgres
|
|
263
|
-
url: postgres://your-app@your-db:5432/your_app_production
|
|
264
|
-
password:
|
|
265
|
-
secret: DATABASE_PASSWORD
|
|
266
|
-
paths:
|
|
267
|
-
- /data/storage
|
|
268
|
-
restic:
|
|
269
|
-
repository: s3:https://s3.example.com/your-app-backups
|
|
270
|
-
password:
|
|
271
|
-
secret: RESTIC_PASSWORD
|
|
272
|
-
init_if_missing: true
|
|
273
|
-
backup:
|
|
274
|
-
schedule: 1d
|
|
275
|
-
YAML
|
|
276
|
-
end
|
|
277
|
-
|
|
278
|
-
def deploy_snippet
|
|
279
|
-
<<~YAML
|
|
280
|
-
accessories:
|
|
281
|
-
backup:
|
|
282
|
-
image: ghcr.io/crmne/kamal-backup:#{VERSION}
|
|
283
|
-
host: your-server.example.com
|
|
284
|
-
files:
|
|
285
|
-
- config/kamal-backup.yml:/app/config/kamal-backup.yml:ro
|
|
286
|
-
env:
|
|
287
|
-
secret:
|
|
288
|
-
- DATABASE_PASSWORD
|
|
289
|
-
- RESTIC_PASSWORD
|
|
290
|
-
- AWS_ACCESS_KEY_ID
|
|
291
|
-
- AWS_SECRET_ACCESS_KEY
|
|
292
|
-
volumes:
|
|
293
|
-
- "your_app_storage:/data/storage:ro"
|
|
294
|
-
- "your_app_backup_state:/var/lib/kamal-backup"
|
|
295
|
-
YAML
|
|
296
|
-
end
|
|
297
|
-
end
|
|
298
|
-
|
|
299
15
|
class CommandBase < Thor
|
|
300
16
|
include Helpers
|
|
301
17
|
|
|
@@ -341,7 +57,7 @@ module KamalBackup
|
|
|
341
57
|
confirm!("Run a local restore drill for #{snapshot}? This will overwrite local data.")
|
|
342
58
|
result = local_restore_app.drill_on_local_machine(snapshot, check_command: options[:check])
|
|
343
59
|
puts(JSON.pretty_generate(result))
|
|
344
|
-
exit(1)
|
|
60
|
+
exit(1) unless result[:status] == 'ok'
|
|
345
61
|
end
|
|
346
62
|
|
|
347
63
|
method_option :database, type: :string, desc: 'Scratch database name for PostgreSQL or MySQL'
|
|
@@ -368,7 +84,7 @@ module KamalBackup
|
|
|
368
84
|
check_command: options[:check]
|
|
369
85
|
)
|
|
370
86
|
puts(JSON.pretty_generate(result))
|
|
371
|
-
exit(1)
|
|
87
|
+
exit(1) unless result[:status] == 'ok'
|
|
372
88
|
end
|
|
373
89
|
end
|
|
374
90
|
|
|
@@ -432,16 +148,10 @@ module KamalBackup
|
|
|
432
148
|
Command.with_output(output) do
|
|
433
149
|
super(normalize_global_options(argv))
|
|
434
150
|
end
|
|
435
|
-
rescue Error => e
|
|
436
|
-
output ||= CommandOutput.new(io: $stderr, env: env)
|
|
437
|
-
output.error("(#{e.class}): #{e.message}", redactor: Redactor.new(env: env))
|
|
438
|
-
exit(1)
|
|
439
151
|
rescue StandardError => e
|
|
440
|
-
output ||= CommandOutput.new(io: $stderr, env: env)
|
|
441
152
|
output.error("(#{e.class}): #{e.message}", redactor: Redactor.new(env: env))
|
|
442
153
|
exit(1)
|
|
443
154
|
rescue Interrupt
|
|
444
|
-
output ||= CommandOutput.new(io: $stderr, env: env)
|
|
445
155
|
output.error('(Interrupt): interrupted', redactor: Redactor.new(env: env))
|
|
446
156
|
exit(130)
|
|
447
157
|
ensure
|