Module: KamalBackup::CLI::Helpers

Included in:
KamalBackup::CLI, CommandBase
Defined in:
lib/kamal_backup/cli.rb

Instance Method Summary collapse

Instance Method Details

#accessory_nameObject



92
93
94
# File 'lib/kamal_backup/cli.rb', line 92

def accessory_name
  @accessory_name ||= bridge.accessory_name(preferred: local_preferences.accessory_name)
end

#accessory_reboot_commandObject



123
124
125
126
127
128
# File 'lib/kamal_backup/cli.rb', line 123

def accessory_reboot_command
  argv = ["bin/kamal", "accessory", "reboot", accessory_name]
  argv.concat(["-c", options[:config_file]]) if options[:config_file]
  argv.concat(["-d", options[:destination]]) if options[:destination]
  Shellwords.join(argv)
end

#bridgeObject



69
70
71
72
73
74
75
76
77
78
# File 'lib/kamal_backup/cli.rb', line 69

def bridge
  @bridge ||= KamalBridge.new(
    redactor: redactor,
    config_file: options[:config_file],
    destination: options[:destination],
    env: command_env,
    stdout: $stdout,
    stderr: $stderr
  )
end

#command_envObject



14
15
16
# File 'lib/kamal_backup/cli.rb', line 14

def command_env
  CLI.command_env || ENV
end

#confirm!(message) ⇒ Object



163
164
165
166
167
168
169
170
171
172
173
# File 'lib/kamal_backup/cli.rb', line 163

def confirm!(message)
  return if options[:yes]

  unless $stdin.tty?
    raise ConfigurationError, "confirmation required; rerun with --yes"
  end

  unless yes?("#{message} [y/N]")
    raise ConfigurationError, "aborted"
  end
end

#confirm_production_restore!(snapshot) ⇒ Object



175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
# File 'lib/kamal_backup/cli.rb', line 175

def confirm_production_restore!(snapshot)
  return if options[:"confirm-production-restore"]

  if options[:yes]
    raise ConfigurationError, "--yes does not bypass restore production; use --confirm-production-restore only for deliberate automation"
  end

  unless $stdin.tty?
    raise ConfigurationError, "production restore confirmation required; rerun interactively or pass --confirm-production-restore only for deliberate automation"
  end

  app_name = production_restore_confirmation_config.required_app_name
  say "This will overwrite the production database and file paths for #{app_name} from backup #{snapshot}.", :red
  require_typed_confirmation("Type the app name to continue", app_name)
  require_typed_confirmation("Type RESTORE PRODUCTION to continue", "RESTORE PRODUCTION")
  confirm!("Restore #{snapshot} into production now? This will overwrite production data.")
end

#default_deploy_config?Boolean

Returns:

  • (Boolean)


84
85
86
# File 'lib/kamal_backup/cli.rb', line 84

def default_deploy_config?
  File.file?(File.expand_path(KamalBridge::DEFAULT_CONFIG_FILE))
end

#deploy_snippetObject



266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
# File 'lib/kamal_backup/cli.rb', line 266

def deploy_snippet
  <<~YAML
    accessories:
      backup:
        image: ghcr.io/crmne/kamal-backup:#{VERSION}
        host: your-server.example.com
        files:
          - config/kamal-backup.yml:/app/config/kamal-backup.yml:ro
        env:
          secret:
            - DATABASE_PASSWORD
            - RESTIC_PASSWORD
            - AWS_ACCESS_KEY_ID
            - AWS_SECRET_ACCESS_KEY
        volumes:
          - "your_app_storage:/data/storage:ro"
          - "your_app_backup_state:/var/lib/kamal-backup"
  YAML
end

#deployment_mode?Boolean

Returns:

  • (Boolean)


80
81
82
# File 'lib/kamal_backup/cli.rb', line 80

def deployment_mode?
  !options[:destination].to_s.strip.empty? || !options[:config_file].to_s.strip.empty?
end

#direct_appObject



22
23
24
25
26
27
# File 'lib/kamal_backup/cli.rb', line 22

def direct_app
  @direct_app ||= App.new(
    config: Config.new(env: command_env),
    redactor: redactor
  )
end

#ensure_remote_version_match!Object

Raises:



113
114
115
116
117
118
119
120
121
# File 'lib/kamal_backup/cli.rb', line 113

def ensure_remote_version_match!
  return if remote_version == VERSION

  raise ConfigurationError, <<~MESSAGE.strip
    local gem version #{VERSION} does not match remote accessory version #{remote_version}.
    Reboot the backup accessory to pick up the latest image:
    #{accessory_reboot_command}
  MESSAGE
end

#exec_remote(argv, require_version_match: true) ⇒ Object



100
101
102
103
104
105
106
107
108
109
110
111
# File 'lib/kamal_backup/cli.rb', line 100

def exec_remote(argv, require_version_match: true)
  ensure_remote_version_match! if require_version_match

  result = bridge.execute_on_accessory(
    accessory_name: accessory_name,
    command: Shellwords.join(argv),
    stream: true
  )
  print(result.stdout) unless result.streamed
  $stderr.print(result.stderr) if !result.streamed && !result.stderr.empty?
  result
end

#init_config_rootObject



225
226
227
228
# File 'lib/kamal_backup/cli.rb', line 225

def init_config_root
  config_file = options[:config_file] || KamalBridge::DEFAULT_CONFIG_FILE
  File.dirname(File.expand_path(config_file))
end

#local_command_configObject



40
41
42
43
44
45
46
47
48
49
50
51
52
# File 'lib/kamal_backup/cli.rb', line 40

def local_command_config
  @local_command_config ||= begin
    if deployment_mode?
      Config.new(
        env: command_env,
        defaults: production_source_defaults,
        config_paths: [Config::LOCAL_CONFIG_PATH]
      )
    else
      Config.new(env: command_env)
    end
  end
end

#local_preferencesObject



36
37
38
# File 'lib/kamal_backup/cli.rb', line 36

def local_preferences
  @local_preferences ||= Config.new(env: command_env)
end

#local_restore_appObject



29
30
31
32
33
34
# File 'lib/kamal_backup/cli.rb', line 29

def local_restore_app
  @local_restore_app ||= App.new(
    config: local_command_config,
    redactor: redactor
  )
end


141
142
143
144
145
146
147
148
149
150
151
152
# File 'lib/kamal_backup/cli.rb', line 141

def print_backup_result(result)
  return unless result.is_a?(Hash)

  puts("Backup completed at #{result.fetch(:finished_at)}")
  result.fetch(:databases).each do |database|
    puts("database #{database.fetch(:database)}: #{database.fetch(:snapshot)} at #{database.fetch(:time)}")
  end

  if files = result[:files]
    puts("files: #{files.fetch(:snapshot)} at #{files.fetch(:time)}")
  end
end


130
131
132
133
134
135
136
137
138
139
# File 'lib/kamal_backup/cli.rb', line 130

def print_remote_version_status
  status = remote_version == VERSION ? "in sync" : "out of sync"
  status_color = status == "in sync" ? :green : :red
  status_output = CommandOutput.new(io: $stdout, env: command_env)

  puts("local: #{VERSION}")
  puts("remote: #{remote_version}")
  puts("status: #{status_output.decorate(status, status_color, :bold)}")
  puts("fix: #{status_output.decorate(accessory_reboot_command, :yellow, :bold)}") if status == "out of sync"
end

#production_restore_confirmation_configObject



200
201
202
203
204
205
206
207
208
209
210
# File 'lib/kamal_backup/cli.rb', line 200

def production_restore_confirmation_config
  if deployment_mode?
    Config.new(
      env: bridge.accessory_environment(accessory_name: accessory_name),
      config_paths: [Config::SHARED_CONFIG_PATH],
      load_project_defaults: false
    )
  else
    direct_app.config
  end
end

#production_source_defaultsObject



54
55
56
# File 'lib/kamal_backup/cli.rb', line 54

def production_source_defaults
  shared_config_source_defaults.merge(bridge.local_restore_defaults(accessory_name: accessory_name))
end

#prompt_required(label) ⇒ Object



212
213
214
215
216
217
218
219
220
221
222
223
# File 'lib/kamal_backup/cli.rb', line 212

def prompt_required(label)
  unless $stdin.tty?
    raise ConfigurationError, "#{label.downcase} is required; pass it on the command line"
  end

  value = ask("#{label}:").to_s.strip
  if value.empty?
    raise ConfigurationError, "#{label.downcase} is required"
  else
    value
  end
end

#redactorObject



18
19
20
# File 'lib/kamal_backup/cli.rb', line 18

def redactor
  @redactor ||= Redactor.new(env: command_env)
end

#remote_command_mode?Boolean

Returns:

  • (Boolean)


88
89
90
# File 'lib/kamal_backup/cli.rb', line 88

def remote_command_mode?
  deployment_mode? || default_deploy_config?
end

#remote_versionObject



96
97
98
# File 'lib/kamal_backup/cli.rb', line 96

def remote_version
  @remote_version ||= bridge.remote_version(accessory_name: accessory_name)
end

#require_typed_confirmation(prompt, expected) ⇒ Object

Raises:



193
194
195
196
197
198
# File 'lib/kamal_backup/cli.rb', line 193

def require_typed_confirmation(prompt, expected)
  answer = ask("#{prompt}:").to_s.strip
  return if answer == expected

  raise ConfigurationError, "aborted"
end

#shared_config_pathObject



230
231
232
# File 'lib/kamal_backup/cli.rb', line 230

def shared_config_path
  File.join(init_config_root, "kamal-backup.yml")
end

#shared_config_source_defaultsObject



58
59
60
61
62
63
64
65
66
67
# File 'lib/kamal_backup/cli.rb', line 58

def shared_config_source_defaults
  config = Config.new(env: {}, config_paths: [Config::SHARED_CONFIG_PATH], load_project_defaults: false)

  {}.tap do |defaults|
    defaults["APP_NAME"] = config.app_name if config.app_name
    defaults["DATABASE_ADAPTER"] = config.database_adapter if config.database_adapter
    defaults["RESTIC_REPOSITORY"] = config.restic_repository if config.restic_repository
    defaults["LOCAL_RESTORE_SOURCE_PATHS"] = config.backup_paths.join("\n") if config.backup_paths.any?
  end
end

#shared_config_templateObject



244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
# File 'lib/kamal_backup/cli.rb', line 244

def shared_config_template
  <<~YAML
    app: your-app
    accessory: backup
    databases:
      - name: app
        adapter: postgres
        url: postgres://your-app@your-db:5432/your_app_production
        password:
          secret: DATABASE_PASSWORD
    paths:
      - /data/storage
    restic:
      repository: s3:https://s3.example.com/your-app-backups
      password:
        secret: RESTIC_PASSWORD
      init_if_missing: true
    backup:
      schedule: 1d
  YAML
end

#validate_deploy_configObject



154
155
156
157
158
159
160
161
# File 'lib/kamal_backup/cli.rb', line 154

def validate_deploy_config
  config = Config.new(
    env: bridge.accessory_environment(accessory_name: accessory_name),
    config_paths: [Config::SHARED_CONFIG_PATH],
    load_project_defaults: false
  )
  config.validate_backup(check_files: false)
end

#write_init_file(path, contents) ⇒ Object



234
235
236
237
238
239
240
241
242
# File 'lib/kamal_backup/cli.rb', line 234

def write_init_file(path, contents)
  if File.exist?(path)
    say "Exists: #{path}", :yellow
  else
    FileUtils.mkdir_p(File.dirname(path))
    File.write(path, contents)
    say "Created: #{path}", :green
  end
end