kamal-backup 0.3.0.beta21 → 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