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_path_excludes(paths = backup_paths) ⇒ Object



203
204
205
206
207
# File 'lib/kamal_backup/config.rb', line 203

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



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

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

#backup_pathsObject



195
196
197
198
199
200
201
# File 'lib/kamal_backup/config.rb', line 195

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

#backup_schedule_secondsObject



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

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

#backup_start_delay_secondsObject



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

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



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

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

#databasesObject



235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
# File 'lib/kamal_backup/config.rb', line 235

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)


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

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



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

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

#last_check_pathObject



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

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

#last_restore_drill_pathObject



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

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

#local_restore_path_pairsObject

Raises:



213
214
215
216
217
218
219
220
# File 'lib/kamal_backup/config.rb', line 213

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



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

def local_restore_source_paths
  @restore_from_definitions || legacy_local_restore_source_paths || backup_paths
end

#production_like_target?(target) ⇒ Boolean

Returns:

  • (Boolean)


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

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



389
390
391
# File 'lib/kamal_backup/config.rb', line 389

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



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

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

#retention_argsObject



268
269
270
271
272
273
274
275
276
277
278
279
280
# File 'lib/kamal_backup/config.rb', line 268

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



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

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

#truthy?(key) ⇒ Boolean

Returns:

  • (Boolean)


393
394
395
# File 'lib/kamal_backup/config.rb', line 393

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

#validate_backup(check_files: true) ⇒ Object



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

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



332
333
334
335
336
337
338
339
340
341
# File 'lib/kamal_backup/config.rb', line 332

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:



299
300
301
302
303
304
305
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
# File 'lib/kamal_backup/config.rb', line 299

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:



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

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:



352
353
354
355
356
357
358
359
360
361
362
363
364
# File 'lib/kamal_backup/config.rb', line 352

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:



343
344
345
346
347
348
349
350
# File 'lib/kamal_backup/config.rb', line 343

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



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

def validate_local_machine_restore
  validate_local_machine_environment
  validate_local_machine_paths
end

#validate_restic(check_files: true) ⇒ Object



282
283
284
285
286
# File 'lib/kamal_backup/config.rb', line 282

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



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

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

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