Class: KamalBackup::Config

Inherits:
Object
  • Object
show all
Defined in:
lib/kamal_backup/config.rb

Defined Under Namespace

Classes: DatabaseSource

Constant Summary collapse

DEFAULT_RETENTION =
{
  'RESTIC_KEEP_LAST' => '7',
  'RESTIC_KEEP_DAILY' => '7',
  'RESTIC_KEEP_WEEKLY' => '4',
  'RESTIC_KEEP_MONTHLY' => '6',
  'RESTIC_KEEP_YEARLY' => '2'
}.freeze
SUSPICIOUS_BACKUP_PATHS =
%w[/ /var /etc /root /usr /bin /sbin /boot /dev /proc /sys /run].freeze
SHARED_CONFIG_PATH =
'config/kamal-backup.yml'
LOCAL_CONFIG_PATH =
'config/kamal-backup.local.yml'
DEFAULT_CONFIG_PATHS =
[SHARED_CONFIG_PATH, LOCAL_CONFIG_PATH].freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(env: ENV, cwd: Dir.pwd, defaults: {}, config_paths: nil, load_project_defaults: true) ⇒ Config

Returns a new instance of Config.



105
106
107
108
109
110
111
112
113
# File 'lib/kamal_backup/config.rb', line 105

def initialize(env: ENV, cwd: Dir.pwd, defaults: {}, config_paths: nil, load_project_defaults: true)
  raw_env = env.to_h
  base = load_project_defaults ? project_defaults(cwd: cwd) : {}
  config_data = load_config_files(raw_env, cwd: cwd, paths: config_paths)
  @database_definitions = config_data.database_definitions
  @path_definitions = config_data.path_definitions
  @restore_from_definitions = config_data.restore_from_definitions
  @env = base.merge(defaults.to_h).merge(config_data.env).merge(raw_env)
end

Instance Attribute Details

#envObject (readonly)

Returns the value of attribute env.



103
104
105
# File 'lib/kamal_backup/config.rb', line 103

def env
  @env
end

Instance Method Details

#accessory_nameObject



123
124
125
# File 'lib/kamal_backup/config.rb', line 123

def accessory_name
  value('KAMAL_BACKUP_ACCESSORY')
end

#allow_in_place_file_restore?Boolean

Returns:

  • (Boolean)


163
164
165
# File 'lib/kamal_backup/config.rb', line 163

def allow_in_place_file_restore?
  truthy?('KAMAL_BACKUP_ALLOW_IN_PLACE_FILE_RESTORE')
end

#allow_suspicious_backup_paths?Boolean

Returns:

  • (Boolean)


167
168
169
# File 'lib/kamal_backup/config.rb', line 167

def allow_suspicious_backup_paths?
  truthy?('KAMAL_BACKUP_ALLOW_SUSPICIOUS_PATHS')
end

#app_nameObject



115
116
117
# File 'lib/kamal_backup/config.rb', line 115

def app_name
  value('APP_NAME')
end

#backup_enabled?Boolean

Returns:

  • (Boolean)


171
172
173
# File 'lib/kamal_backup/config.rb', line 171

def backup_enabled?
  !falsey?('BACKUP_ENABLED')
end

#backup_path_excludes(paths = backup_paths) ⇒ Object



207
208
209
210
211
# File 'lib/kamal_backup/config.rb', line 207

def backup_path_excludes(paths = backup_paths)
  paths = Array(paths).compact.map(&:to_s).reject(&:empty?)

  configured_backup_path_excludes(paths) + sqlite_backup_path_excludes(paths)
end

#backup_path_label(path) ⇒ Object



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

def backup_path_label(path)
  label = path.to_s.sub(%r{\A/+}, '').gsub(/[^A-Za-z0-9_.-]+/, '-')
  label.empty? ? 'root' : label
end

#backup_pathsObject



199
200
201
202
203
204
205
# File 'lib/kamal_backup/config.rb', line 199

def backup_paths
  if path_definitions?
    @path_definitions.map(&:path)
  else
    legacy_backup_paths
  end
end

#backup_schedule_secondsObject



175
176
177
# File 'lib/kamal_backup/config.rb', line 175

def backup_schedule_seconds
  integer('BACKUP_SCHEDULE_SECONDS', 86_400, minimum: 1)
end

#backup_start_delay_secondsObject



179
180
181
# File 'lib/kamal_backup/config.rb', line 179

def backup_start_delay_seconds
  integer('BACKUP_START_DELAY_SECONDS', 0, minimum: 0)
end

#check_after_backup?Boolean

Returns:

  • (Boolean)


151
152
153
# File 'lib/kamal_backup/config.rb', line 151

def check_after_backup?
  truthy?('RESTIC_CHECK_AFTER_BACKUP')
end

#check_read_data_subsetObject



159
160
161
# File 'lib/kamal_backup/config.rb', line 159

def check_read_data_subset
  value('RESTIC_CHECK_READ_DATA_SUBSET')
end

#database_adapterObject



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

def database_adapter
  if database_definitions?
    databases.first&.database_adapter
  else
    legacy_database_adapter
  end
end

#databasesObject



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

def databases
  @databases ||= if database_definitions?
                   @database_definitions.map do |definition|
                     DatabaseSource.new(
                       parent: self,
                       name: definition.fetch(:name),
                       adapter: definition.fetch(:adapter),
                       env: definition.fetch(:env),
                       structured: true,
                       missing_secrets: definition.fetch(:missing_secrets, [])
                     )
                   end
                 elsif legacy_database_adapter
                   [
                     DatabaseSource.new(
                       parent: self,
                       name: 'app',
                       adapter: legacy_database_adapter,
                       env: {},
                       structured: false
                     )
                   ]
                 else
                   []
                 end
end

#falsey?(key) ⇒ Boolean

Returns:

  • (Boolean)


404
405
406
# File 'lib/kamal_backup/config.rb', line 404

def falsey?(key)
  %w[0 false no n off].include?(value(key).to_s.downcase)
end

#forget_after_backup?Boolean

Returns:

  • (Boolean)


155
156
157
# File 'lib/kamal_backup/config.rb', line 155

def forget_after_backup?
  !falsey?('RESTIC_FORGET_AFTER_BACKUP')
end

#last_backup_pathObject



191
192
193
# File 'lib/kamal_backup/config.rb', line 191

def last_backup_path
  File.join(state_dir, 'last_backup.json')
end

#last_check_pathObject



187
188
189
# File 'lib/kamal_backup/config.rb', line 187

def last_check_path
  File.join(state_dir, 'last_check.json')
end

#last_restore_drill_pathObject



195
196
197
# File 'lib/kamal_backup/config.rb', line 195

def last_restore_drill_path
  File.join(state_dir, 'last_restore_drill.json')
end

#local_restore_path_pairsObject



217
218
219
220
221
222
223
224
225
226
227
# File 'lib/kamal_backup/config.rb', line 217

def local_restore_path_pairs
  source_paths = local_restore_source_paths
  target_paths = backup_paths

  unless source_paths.size == target_paths.size
    raise ConfigurationError,
          'local file paths must be explicitly configured and match the number of production restore source paths'
  end

  source_paths.zip(target_paths)
end

#local_restore_source_pathsObject



213
214
215
# File 'lib/kamal_backup/config.rb', line 213

def local_restore_source_paths
  @restore_from_definitions || legacy_local_restore_source_paths || backup_paths
end

#production_like_target?(target) ⇒ Boolean

Returns:

  • (Boolean)


382
383
384
385
386
# File 'lib/kamal_backup/config.rb', line 382

def production_like_target?(target)
  target = target.to_s

  source_database_targets.include?(target) || production_named_target?(target.downcase)
end

#required_app_nameObject



119
120
121
# File 'lib/kamal_backup/config.rb', line 119

def required_app_name
  required_value('APP_NAME')
end

#required_value(key) ⇒ Object



396
397
398
# File 'lib/kamal_backup/config.rb', line 396

def required_value(key)
  value(key) || raise(ConfigurationError, "#{key} is required")
end

#restic_init_if_missing?Boolean

Returns:

  • (Boolean)


147
148
149
# File 'lib/kamal_backup/config.rb', line 147

def restic_init_if_missing?
  truthy?('RESTIC_INIT_IF_MISSING')
end

#restic_passwordObject



135
136
137
# File 'lib/kamal_backup/config.rb', line 135

def restic_password
  value('RESTIC_PASSWORD')
end

#restic_password_commandObject



143
144
145
# File 'lib/kamal_backup/config.rb', line 143

def restic_password_command
  value('RESTIC_PASSWORD_COMMAND')
end

#restic_password_fileObject



139
140
141
# File 'lib/kamal_backup/config.rb', line 139

def restic_password_file
  value('RESTIC_PASSWORD_FILE')
end

#restic_repositoryObject



127
128
129
# File 'lib/kamal_backup/config.rb', line 127

def restic_repository
  value('RESTIC_REPOSITORY')
end

#restic_repository_fileObject



131
132
133
# File 'lib/kamal_backup/config.rb', line 131

def restic_repository_file
  value('RESTIC_REPOSITORY_FILE')
end

#retentionObject



269
270
271
272
273
# File 'lib/kamal_backup/config.rb', line 269

def retention
  DEFAULT_RETENTION.each_with_object({}) do |(key, default), result|
    result[key] = value(key) || default
  end
end

#retention_argsObject



275
276
277
278
279
280
281
282
283
284
285
286
287
# File 'lib/kamal_backup/config.rb', line 275

def retention_args
  retention.each_with_object([]) do |(key, raw), args|
    next if raw.to_s.empty?

    number = Integer(raw)
    next if number <= 0

    flag = "--#{key.sub('RESTIC_KEEP_', 'keep-').downcase.tr('_', '-')}"
    args.concat([flag, number.to_s])
  rescue ArgumentError
    raise ConfigurationError, "#{key} must be an integer"
  end
end

#state_dirObject



183
184
185
# File 'lib/kamal_backup/config.rb', line 183

def state_dir
  value('KAMAL_BACKUP_STATE_DIR') || '/var/lib/kamal-backup'
end

#truthy?(key) ⇒ Boolean

Returns:

  • (Boolean)


400
401
402
# File 'lib/kamal_backup/config.rb', line 400

def truthy?(key)
  %w[1 true yes y on].include?(value(key).to_s.downcase)
end

#validate_backup(check_files: true) ⇒ Object



295
296
297
298
299
# File 'lib/kamal_backup/config.rb', line 295

def validate_backup(check_files: true)
  validate_restic(check_files: check_files)
  validate_database_backup(check_files: check_files)
  validate_backup_paths(check_files: check_files)
end

#validate_backup_paths(check_files: true) ⇒ Object



339
340
341
342
343
344
345
346
347
348
# File 'lib/kamal_backup/config.rb', line 339

def validate_backup_paths(check_files: true)
  backup_paths.each do |path|
    expanded = File.expand_path(path)
    if SUSPICIOUS_BACKUP_PATHS.include?(expanded) && !allow_suspicious_backup_paths?
      raise ConfigurationError,
            "refusing suspicious backup path #{expanded}; set KAMAL_BACKUP_ALLOW_SUSPICIOUS_PATHS=true to override"
    end
    raise ConfigurationError, "backup path does not exist: #{path}" if check_files && !File.exist?(path)
  end
end

#validate_database_backup(check_files: true) ⇒ Object

Raises:



306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
# File 'lib/kamal_backup/config.rb', line 306

def validate_database_backup(check_files: true)
  raise ConfigurationError, 'databases must contain at least one database' if databases.empty?

  databases.each do |database|
    unless database.missing_secrets.empty?
      raise ConfigurationError,
            "database #{database.database_name} requires missing secret #{database.missing_secrets.join(', ')}"
    end

    case database.database_adapter
    when 'postgres'
      unless database.value('DATABASE_URL') || database.value('PGDATABASE')
        raise ConfigurationError,
              "PostgreSQL database #{database.database_name} requires url or PGDATABASE/libpq environment"
      end
    when 'mysql'
      unless database.value('DATABASE_URL') || database.value('MYSQL_DATABASE') || database.value('MARIADB_DATABASE')
        raise ConfigurationError,
              "MySQL database #{database.database_name} requires url or MYSQL_DATABASE/MARIADB_DATABASE"
      end
    when 'sqlite'
      path = database.required_value('SQLITE_DATABASE_PATH')
      if check_files && !File.file?(path)
        raise ConfigurationError,
              "SQLite database #{database.database_name} does not exist: #{path}"
      end
    else
      raise ConfigurationError,
            "database #{database.database_name} adapter is required and must be postgres, mysql, or sqlite"
    end
  end
end

#validate_database_restore_target(target) ⇒ Object

Raises:



373
374
375
376
377
378
379
380
# File 'lib/kamal_backup/config.rb', line 373

def validate_database_restore_target(target)
  raise ConfigurationError, 'restore database target is required' if target.to_s.strip.empty?

  return unless production_like_target?(target)

  raise ConfigurationError,
        "refusing production-looking restore target #{target}; choose a scratch target that does not look like production"
end

#validate_file_restore_target(target) ⇒ Object

Raises:



359
360
361
362
363
364
365
366
367
368
369
370
371
# File 'lib/kamal_backup/config.rb', line 359

def validate_file_restore_target(target)
  raise ConfigurationError, 'restore target cannot be empty' if target.to_s.strip.empty?

  expanded_target = File.expand_path(target)
  raise ConfigurationError, 'refusing to restore files to /' if expanded_target == '/'

  if in_place_file_restore?(expanded_target) && !allow_in_place_file_restore?
    raise ConfigurationError,
          "refusing in-place file restore to #{expanded_target}; set KAMAL_BACKUP_ALLOW_IN_PLACE_FILE_RESTORE=true to override"
  end

  expanded_target
end

#validate_local_database_restore_target(target) ⇒ Object

Raises:



350
351
352
353
354
355
356
357
# File 'lib/kamal_backup/config.rb', line 350

def validate_local_database_restore_target(target)
  raise ConfigurationError, 'local restore database target is required' if target.to_s.strip.empty?

  return unless production_named_target?(target)

  raise ConfigurationError,
        "refusing production-looking local restore target #{target}; use restore production for production restores"
end

#validate_local_machine_restoreObject



301
302
303
304
# File 'lib/kamal_backup/config.rb', line 301

def validate_local_machine_restore
  validate_local_machine_environment
  validate_local_machine_paths
end

#validate_restic(check_files: true) ⇒ Object



289
290
291
292
293
# File 'lib/kamal_backup/config.rb', line 289

def validate_restic(check_files: true)
  required_app_name
  validate_restic_repository(check_files: check_files)
  validate_restic_password(check_files: check_files)
end

#value(key) ⇒ Object



388
389
390
391
392
393
394
# File 'lib/kamal_backup/config.rb', line 388

def value(key)
  raw = env[key]
  return nil if raw.nil?

  stripped = raw.to_s.strip
  stripped.empty? ? nil : stripped
end