Class: KamalBackup::Config

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

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

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(env: ENV) ⇒ Config

Returns a new instance of Config.



21
22
23
# File 'lib/kamal_backup/config.rb', line 21

def initialize(env: ENV)
  @env = env.to_h
end

Instance Attribute Details

#envObject (readonly)

Returns the value of attribute env.



19
20
21
# File 'lib/kamal_backup/config.rb', line 19

def env
  @env
end

Instance Method Details

#allow_in_place_file_restore?Boolean

Returns:

  • (Boolean)


65
66
67
# File 'lib/kamal_backup/config.rb', line 65

def allow_in_place_file_restore?
  truthy?("KAMAL_BACKUP_ALLOW_IN_PLACE_FILE_RESTORE")
end

#allow_production_restore?Boolean

Returns:

  • (Boolean)


61
62
63
# File 'lib/kamal_backup/config.rb', line 61

def allow_production_restore?
  truthy?("KAMAL_BACKUP_ALLOW_PRODUCTION_RESTORE")
end

#allow_restore?Boolean

Returns:

  • (Boolean)


57
58
59
# File 'lib/kamal_backup/config.rb', line 57

def allow_restore?
  truthy?("KAMAL_BACKUP_ALLOW_RESTORE")
end

#allow_suspicious_backup_paths?Boolean

Returns:

  • (Boolean)


69
70
71
# File 'lib/kamal_backup/config.rb', line 69

def allow_suspicious_backup_paths?
  truthy?("KAMAL_BACKUP_ALLOW_SUSPICIOUS_PATHS")
end

#app_nameObject



25
26
27
# File 'lib/kamal_backup/config.rb', line 25

def app_name
  value("APP_NAME")
end

#app_name!Object



29
30
31
# File 'lib/kamal_backup/config.rb', line 29

def app_name!
  required!("APP_NAME")
end

#backup_path_label(path) ⇒ Object



94
95
96
97
# File 'lib/kamal_backup/config.rb', line 94

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



89
90
91
92
# File 'lib/kamal_backup/config.rb', line 89

def backup_paths
  raw = value("BACKUP_PATHS").to_s
  raw.split(/[\n:]+/).map(&:strip).reject(&:empty?)
end

#backup_schedule_secondsObject



73
74
75
# File 'lib/kamal_backup/config.rb', line 73

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

#backup_start_delay_secondsObject



77
78
79
# File 'lib/kamal_backup/config.rb', line 77

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

#check_after_backup?Boolean

Returns:

  • (Boolean)


45
46
47
# File 'lib/kamal_backup/config.rb', line 45

def check_after_backup?
  truthy?("RESTIC_CHECK_AFTER_BACKUP")
end

#check_read_data_subsetObject



53
54
55
# File 'lib/kamal_backup/config.rb', line 53

def check_read_data_subset
  value("RESTIC_CHECK_READ_DATA_SUBSET")
end

#database_adapterObject



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

def database_adapter
  explicit = value("DATABASE_ADAPTER")
  return normalize_adapter(explicit) if explicit

  url = value("DATABASE_URL")
  if url
    scheme = URI.parse(url).scheme rescue nil
    detected = normalize_adapter(scheme)
    return detected if detected
  end

  return "sqlite" if value("SQLITE_DATABASE_PATH")

  nil
end

#falsey?(key) ⇒ Boolean

Returns:

  • (Boolean)


244
245
246
# File 'lib/kamal_backup/config.rb', line 244

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

#forget_after_backup?Boolean

Returns:

  • (Boolean)


49
50
51
# File 'lib/kamal_backup/config.rb', line 49

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

#last_check_pathObject



85
86
87
# File 'lib/kamal_backup/config.rb', line 85

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

#production_like_target?(target) ⇒ Boolean

Returns:

  • (Boolean)


210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
# File 'lib/kamal_backup/config.rb', line 210

def production_like_target?(target)
  target = target.to_s
  source_targets = [
    value("DATABASE_URL"),
    value("SQLITE_DATABASE_PATH"),
    value("PGDATABASE"),
    value("MYSQL_DATABASE"),
    value("MARIADB_DATABASE")
  ].compact

  return true if source_targets.include?(target)

  lowered = target.downcase
  lowered.include?("production") ||
    lowered.match?(%r{(^|[/_.:-])prod([/_.:-]|$)}) ||
    lowered.match?(%r{(^|[/_.:-])live([/_.:-]|$)})
end

#required!(key) ⇒ Object



236
237
238
# File 'lib/kamal_backup/config.rb', line 236

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

#restic_init_if_missing?Boolean

Returns:

  • (Boolean)


41
42
43
# File 'lib/kamal_backup/config.rb', line 41

def restic_init_if_missing?
  truthy?("RESTIC_INIT_IF_MISSING")
end

#restic_passwordObject



37
38
39
# File 'lib/kamal_backup/config.rb', line 37

def restic_password
  value("RESTIC_PASSWORD")
end

#restic_repositoryObject



33
34
35
# File 'lib/kamal_backup/config.rb', line 33

def restic_repository
  value("RESTIC_REPOSITORY")
end

#retentionObject



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

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

#retention_argsObject



121
122
123
124
125
126
127
128
129
130
131
132
133
# File 'lib/kamal_backup/config.rb', line 121

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



81
82
83
# File 'lib/kamal_backup/config.rb', line 81

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

#truthy?(key) ⇒ Boolean

Returns:

  • (Boolean)


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

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

#validate_backup_paths!Object

Raises:



165
166
167
168
169
170
171
172
173
174
175
176
# File 'lib/kamal_backup/config.rb', line 165

def validate_backup_paths!
  paths = backup_paths
  raise ConfigurationError, "BACKUP_PATHS must contain at least one path" if paths.empty?

  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}" unless File.exist?(path)
  end
end

#validate_database_backup!Object



147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
# File 'lib/kamal_backup/config.rb', line 147

def validate_database_backup!
  case database_adapter
  when "postgres"
    unless value("DATABASE_URL") || value("PGDATABASE")
      raise ConfigurationError, "PostgreSQL backup requires DATABASE_URL or PGDATABASE/libpq environment"
    end
  when "mysql"
    unless value("DATABASE_URL") || value("MYSQL_DATABASE") || value("MARIADB_DATABASE")
      raise ConfigurationError, "MySQL backup requires DATABASE_URL or MYSQL_DATABASE/MARIADB_DATABASE"
    end
  when "sqlite"
    path = required!("SQLITE_DATABASE_PATH")
    raise ConfigurationError, "SQLITE_DATABASE_PATH does not exist: #{path}" unless File.file?(path)
  else
    raise ConfigurationError, "DATABASE_ADAPTER is required or must be detectable from DATABASE_URL/SQLITE_DATABASE_PATH"
  end
end

#validate_database_restore_target!(target) ⇒ Object

Raises:



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

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

  if production_like_target?(target) && !allow_production_restore?
    raise ConfigurationError, "refusing production-looking restore target #{target}; set KAMAL_BACKUP_ALLOW_PRODUCTION_RESTORE=true to override"
  end
end

#validate_file_restore_target!(target) ⇒ Object

Raises:



184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
# File 'lib/kamal_backup/config.rb', line 184

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 == "/"

  in_place = backup_paths.any? do |path|
    expanded_path = File.expand_path(path)
    expanded_target == expanded_path || expanded_path.start_with?(expanded_target + "/") || expanded_target.start_with?(expanded_path + "/")
  end

  if in_place && !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_for_backup!Object



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

def validate_for_backup!
  validate_for_restic!
  validate_database_backup!
  validate_backup_paths!
end

#validate_for_restic!Object



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

def validate_for_restic!
  app_name!
  required!("RESTIC_REPOSITORY")
  required!("RESTIC_PASSWORD")
end

#validate_restore_allowed!Object

Raises:



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

def validate_restore_allowed!
  return if allow_restore?

  raise ConfigurationError, "restore commands require KAMAL_BACKUP_ALLOW_RESTORE=true"
end

#value(key) ⇒ Object



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

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

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