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.
@@ -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
@@ -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) if local_restore_app.drill_failed?(result)
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) if direct_app.drill_failed?(result)
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