Class: KamalBackup::Config

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

Defined Under Namespace

Classes: ConfigData, DatabaseSource, PathDefinition

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
TOP_LEVEL_YAML_KEYS =
%w[app accessory databases paths restore_from restic backup state].freeze
LEGACY_YAML_KEYS =
%w[
  app_name
  database_adapter
  database_url
  sqlite_database_path
  backup_paths
  local_restore_source_paths
  restic_repository
  restic_repository_file
  restic_password
  restic_password_file
  restic_password_command
  restic_init_if_missing
  restic_check_after_backup
  restic_check_read_data_subset
  restic_forget_after_backup
  restic_keep_last
  restic_keep_daily
  restic_keep_weekly
  restic_keep_monthly
  restic_keep_yearly
  backup_schedule_seconds
  backup_start_delay_seconds
  state_dir
  allow_suspicious_paths
  pgpassword
  mysql_pwd
].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.



140
141
142
143
144
145
146
147
148
# File 'lib/kamal_backup/config.rb', line 140

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.



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

def env
  @env
end

Instance Method Details

#accessory_nameObject



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

def accessory_name
  value('KAMAL_BACKUP_ACCESSORY')
end

#allow_in_place_file_restore?Boolean

Returns:

  • (Boolean)


198
199
200
# File 'lib/kamal_backup/config.rb', line 198

def allow_in_place_file_restore?
  truthy?('KAMAL_BACKUP_ALLOW_IN_PLACE_FILE_RESTORE')
end

#allow_suspicious_backup_paths?Boolean

Returns:

  • (Boolean)


202
203
204
# File 'lib/kamal_backup/config.rb', line 202

def allow_suspicious_backup_paths?
  truthy?('KAMAL_BACKUP_ALLOW_SUSPICIOUS_PATHS')
end

#app_nameObject



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

def app_name
  value('APP_NAME')
end

#backup_path_excludes(paths = backup_paths) ⇒ Object



238
239
240
241
242
# File 'lib/kamal_backup/config.rb', line 238

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



261
262
263
264
# File 'lib/kamal_backup/config.rb', line 261

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

#backup_pathsObject



230
231
232
233
234
235
236
# File 'lib/kamal_backup/config.rb', line 230

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

#backup_schedule_secondsObject



206
207
208
# File 'lib/kamal_backup/config.rb', line 206

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

#backup_start_delay_secondsObject



210
211
212
# File 'lib/kamal_backup/config.rb', line 210

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

#check_after_backup?Boolean

Returns:

  • (Boolean)


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

def check_after_backup?
  truthy?('RESTIC_CHECK_AFTER_BACKUP')
end

#check_read_data_subsetObject



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

def check_read_data_subset
  value('RESTIC_CHECK_READ_DATA_SUBSET')
end

#database_adapterObject



266
267
268
269
270
271
272
# File 'lib/kamal_backup/config.rb', line 266

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

#database_nameObject



274
275
276
# File 'lib/kamal_backup/config.rb', line 274

def database_name
  'app'
end

#databasesObject



278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
# File 'lib/kamal_backup/config.rb', line 278

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: database_name,
                       adapter: legacy_database_adapter,
                       env: {},
                       structured: false
                     )
                   ]
                 else
                   []
                 end
end

#falsey?(key) ⇒ Boolean

Returns:

  • (Boolean)


440
441
442
# File 'lib/kamal_backup/config.rb', line 440

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

#forget_after_backup?Boolean

Returns:

  • (Boolean)


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

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

#last_backup_pathObject



222
223
224
# File 'lib/kamal_backup/config.rb', line 222

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

#last_check_pathObject



218
219
220
# File 'lib/kamal_backup/config.rb', line 218

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

#last_restore_drill_pathObject



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

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

#local_restore_path_pairsObject

Raises:



252
253
254
255
256
257
258
259
# File 'lib/kamal_backup/config.rb', line 252

def local_restore_path_pairs
  source_paths = local_restore_source_paths
  target_paths = backup_paths

  raise ConfigurationError, 'local restore source paths must contain the same number of paths as file paths' unless source_paths.size == target_paths.size

  source_paths.zip(target_paths)
end

#local_restore_source_pathsObject



244
245
246
247
248
249
250
# File 'lib/kamal_backup/config.rb', line 244

def local_restore_source_paths
  if path_definitions?
    @restore_from_definitions || legacy_local_restore_source_paths || backup_paths
  else
    legacy_local_restore_source_paths || backup_paths
  end
end

#production_like_target?(target) ⇒ Boolean

Returns:

  • (Boolean)


418
419
420
421
422
# File 'lib/kamal_backup/config.rb', line 418

def production_like_target?(target)
  target = target.to_s

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

#required_app_nameObject



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

def required_app_name
  required_value('APP_NAME')
end

#required_value(key) ⇒ Object



432
433
434
# File 'lib/kamal_backup/config.rb', line 432

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

#restic_init_if_missing?Boolean

Returns:

  • (Boolean)


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

def restic_init_if_missing?
  truthy?('RESTIC_INIT_IF_MISSING')
end

#restic_passwordObject



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

def restic_password
  value('RESTIC_PASSWORD')
end

#restic_password_commandObject



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

def restic_password_command
  value('RESTIC_PASSWORD_COMMAND')
end

#restic_password_fileObject



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

def restic_password_file
  value('RESTIC_PASSWORD_FILE')
end

#restic_repositoryObject



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

def restic_repository
  value('RESTIC_REPOSITORY')
end

#restic_repository_fileObject



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

def restic_repository_file
  value('RESTIC_REPOSITORY_FILE')
end

#retentionObject



305
306
307
308
309
# File 'lib/kamal_backup/config.rb', line 305

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

#retention_argsObject



311
312
313
314
315
316
317
318
319
320
321
322
323
# File 'lib/kamal_backup/config.rb', line 311

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



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

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

#truthy?(key) ⇒ Boolean

Returns:

  • (Boolean)


436
437
438
# File 'lib/kamal_backup/config.rb', line 436

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

#validate_backup(check_files: true) ⇒ Object



331
332
333
334
335
# File 'lib/kamal_backup/config.rb', line 331

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



375
376
377
378
379
380
381
382
383
384
# File 'lib/kamal_backup/config.rb', line 375

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:



342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
# File 'lib/kamal_backup/config.rb', line 342

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:



409
410
411
412
413
414
415
416
# File 'lib/kamal_backup/config.rb', line 409

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:



395
396
397
398
399
400
401
402
403
404
405
406
407
# File 'lib/kamal_backup/config.rb', line 395

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:



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

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



337
338
339
340
# File 'lib/kamal_backup/config.rb', line 337

def validate_local_machine_restore
  validate_local_machine_environment
  validate_local_machine_paths
end

#validate_restic(check_files: true) ⇒ Object



325
326
327
328
329
# File 'lib/kamal_backup/config.rb', line 325

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



424
425
426
427
428
429
430
# File 'lib/kamal_backup/config.rb', line 424

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

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