Class: KamalBackup::Config

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

Defined Under Namespace

Classes: ConfigData, 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
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.



136
137
138
139
140
141
142
143
144
# File 'lib/kamal_backup/config.rb', line 136

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.



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

def env
  @env
end

Instance Method Details

#accessory_nameObject



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

def accessory_name
  value("KAMAL_BACKUP_ACCESSORY")
end

#allow_in_place_file_restore?Boolean

Returns:

  • (Boolean)


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

def allow_in_place_file_restore?
  truthy?("KAMAL_BACKUP_ALLOW_IN_PLACE_FILE_RESTORE")
end

#allow_suspicious_backup_paths?Boolean

Returns:

  • (Boolean)


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

def allow_suspicious_backup_paths?
  truthy?("KAMAL_BACKUP_ALLOW_SUSPICIOUS_PATHS")
end

#app_nameObject



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

def app_name
  value("APP_NAME")
end

#backup_path_label(path) ⇒ Object



249
250
251
252
# File 'lib/kamal_backup/config.rb', line 249

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

#backup_pathsObject



222
223
224
225
226
227
228
# File 'lib/kamal_backup/config.rb', line 222

def backup_paths
  if path_definitions?
    @path_definitions
  else
    legacy_backup_paths
  end
end

#backup_schedule_secondsObject



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

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

#backup_start_delay_secondsObject



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

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

#check_after_backup?Boolean

Returns:

  • (Boolean)


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

def check_after_backup?
  truthy?("RESTIC_CHECK_AFTER_BACKUP")
end

#check_read_data_subsetObject



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

def check_read_data_subset
  value("RESTIC_CHECK_READ_DATA_SUBSET")
end

#database_adapterObject



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

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

#database_nameObject



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

def database_name
  "app"
end

#databasesObject



266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
# File 'lib/kamal_backup/config.rb', line 266

def databases
  @databases ||= begin
    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
end

#falsey?(key) ⇒ Boolean

Returns:

  • (Boolean)


423
424
425
# File 'lib/kamal_backup/config.rb', line 423

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

#forget_after_backup?Boolean

Returns:

  • (Boolean)


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

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

#last_check_pathObject



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

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

#last_restore_drill_pathObject



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

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

#local_restore_path_pairsObject



238
239
240
241
242
243
244
245
246
247
# File 'lib/kamal_backup/config.rb', line 238

def local_restore_path_pairs
  source_paths = local_restore_source_paths
  target_paths = backup_paths

  if source_paths.size == target_paths.size
    source_paths.zip(target_paths)
  else
    raise ConfigurationError, "local restore source paths must contain the same number of paths as file paths"
  end
end

#local_restore_source_pathsObject



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

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)


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

def production_like_target?(target)
  target = target.to_s

  if source_database_targets.include?(target)
    true
  else
    production_named_target?(target.downcase)
  end
end

#required_app_nameObject



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

def required_app_name
  required_value("APP_NAME")
end

#required_value(key) ⇒ Object



415
416
417
# File 'lib/kamal_backup/config.rb', line 415

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

#restic_init_if_missing?Boolean

Returns:

  • (Boolean)


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

def restic_init_if_missing?
  truthy?("RESTIC_INIT_IF_MISSING")
end

#restic_passwordObject



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

def restic_password
  value("RESTIC_PASSWORD")
end

#restic_password_commandObject



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

def restic_password_command
  value("RESTIC_PASSWORD_COMMAND")
end

#restic_password_fileObject



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

def restic_password_file
  value("RESTIC_PASSWORD_FILE")
end

#restic_repositoryObject



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

def restic_repository
  value("RESTIC_REPOSITORY")
end

#restic_repository_fileObject



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

def restic_repository_file
  value("RESTIC_REPOSITORY_FILE")
end

#retentionObject



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

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

#retention_argsObject



301
302
303
304
305
306
307
308
309
310
311
312
313
# File 'lib/kamal_backup/config.rb', line 301

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



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

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

#truthy?(key) ⇒ Boolean

Returns:

  • (Boolean)


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

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

#validate_backup(check_files: true) ⇒ Object



321
322
323
324
325
# File 'lib/kamal_backup/config.rb', line 321

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



358
359
360
361
362
363
364
365
366
# File 'lib/kamal_backup/config.rb', line 358

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:



332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
# File 'lib/kamal_backup/config.rb', line 332

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")
      raise ConfigurationError, "SQLite database #{database.database_name} does not exist: #{path}" if check_files && !File.file?(path)
    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:



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

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

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

#validate_file_restore_target(target) ⇒ Object

Raises:



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

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:



368
369
370
371
372
373
374
# File 'lib/kamal_backup/config.rb', line 368

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

  if production_named_target?(target)
    raise ConfigurationError, "refusing production-looking local restore target #{target}; use restore production for production restores"
  end
end

#validate_local_machine_restoreObject



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

def validate_local_machine_restore
  validate_local_machine_environment
  validate_local_machine_paths
end

#validate_restic(check_files: true) ⇒ Object



315
316
317
318
319
# File 'lib/kamal_backup/config.rb', line 315

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



407
408
409
410
411
412
413
# File 'lib/kamal_backup/config.rb', line 407

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

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