Class: KamalBackup::Restic

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

Constant Summary collapse

RESTIC_ENV_PATTERN =
/\A(?:RESTIC_|AWS_|B2_|AZURE_|GOOGLE_|RCLONE_|OS_|ST_|HP_|HTTP_|HTTPS_|NO_PROXY)/i

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(config, redactor:) ⇒ Restic

Returns a new instance of Restic.



13
14
15
16
# File 'lib/kamal_backup/restic.rb', line 13

def initialize(config, redactor:)
  @config = config
  @redactor = redactor
end

Instance Attribute Details

#configObject (readonly)

Returns the value of attribute config.



11
12
13
# File 'lib/kamal_backup/restic.rb', line 11

def config
  @config
end

#redactorObject (readonly)

Returns the value of attribute redactor.



11
12
13
# File 'lib/kamal_backup/restic.rb', line 11

def redactor
  @redactor
end

Instance Method Details

#backup_file(path, filename:, tags:) ⇒ Object



38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
# File 'lib/kamal_backup/restic.rb', line 38

def backup_file(path, filename:, tags:)
  command = CommandSpec.new(
    argv: ["restic", "backup"] + host_args + ["--stdin", "--stdin-filename", filename] + tag_args(common_tags + tags),
    env: restic_env
  )
  log("backing up file content as #{filename}")

  File.open(path, "rb") do |file|
    Open3.popen3(command.env, *command.argv) do |stdin, stdout, stderr, wait_thread|
      stdout_reader = Thread.new { stdout.read }
      stderr_reader = Thread.new { stderr.read }
      IO.copy_stream(file, stdin)
      stdin.close
      out = stdout_reader.value
      err = stderr_reader.value
      status = wait_thread.value
      raise_command_error(command, status, out, err) unless status.success?

      CommandResult.new(stdout: out, stderr: err, status: status.exitstatus)
    end
  end
rescue Errno::ENOENT => e
  raise CommandError.new("command not found: #{command.argv.first}", command: command, status: 127, stderr: e.message)
end

#backup_path(path, tags:) ⇒ Object



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

def backup_path(path, tags:)
  backup_paths([path], tags: tags)
end

#backup_paths(paths, tags:) ⇒ Object



63
64
65
66
67
68
69
70
71
# File 'lib/kamal_backup/restic.rb', line 63

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

  if paths.any?
    path_tags = paths.map { |path| "path:#{config.backup_path_label(path)}" }
    log("backing up #{paths.size} file path(s): #{paths.join(", ")}")
    run(["backup"] + host_args + paths + tag_args(common_tags + tags + path_tags))
  end
end

#backup_stream(command, filename:, tags:) ⇒ Object



29
30
31
32
33
34
35
36
# File 'lib/kamal_backup/restic.rb', line 29

def backup_stream(command, filename:, tags:)
  restic_command = CommandSpec.new(
    argv: ["restic", "backup"] + host_args + ["--stdin", "--stdin-filename", filename] + tag_args(common_tags + tags),
    env: restic_env
  )
  log("backing up stream as #{filename}")
  pipe_commands(command, restic_command, producer_label: "dump", consumer_label: "restic backup")
end

#checkObject



85
86
87
88
89
90
91
92
93
94
95
# File 'lib/kamal_backup/restic.rb', line 85

def check
  args = %w[check]
  args.concat(["--read-data-subset", config.check_read_data_subset]) if config.check_read_data_subset
  started_at = Time.now.utc
  result = run(args)
  write_last_check(status: "ok", started_at: started_at, finished_at: Time.now.utc, output: result.stdout)
  result
rescue CommandError => e
  write_last_check(status: "failed", started_at: started_at || Time.now.utc, finished_at: Time.now.utc, error: e.message)
  raise
end

#common_tagsObject



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

def common_tags
  ["kamal-backup", "app:#{config.app_name}"]
end

#database_file(snapshot, adapter, database_name: nil) ⇒ Object



127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
# File 'lib/kamal_backup/restic.rb', line 127

def database_file(snapshot, adapter, database_name: nil)
  legacy_prefix = "databases/#{config.app_name}/#{adapter}/"
  app = config.app_name.gsub(/[^A-Za-z0-9_.-]+/, "-")
  database = database_name.to_s.gsub(/[^A-Za-z0-9_.-]+/, "-")
  stable_prefix = database.empty? ? nil : "databases/#{app}/#{database}/#{adapter}."
  flat_prefix = "databases-#{app}-#{adapter}-"
  named_flat_prefix = database.empty? ? nil : "databases-#{app}-#{database}-#{adapter}-"
  ls_json(snapshot).find do |entry|
    next false unless entry["type"] == "file"

    normalized = entry["path"].to_s.sub(%r{\A/+}, "")
    (stable_prefix && normalized.start_with?(stable_prefix)) ||
      normalized.start_with?(legacy_prefix) ||
      File.basename(normalized).start_with?(flat_prefix) ||
      (named_flat_prefix && File.basename(normalized).start_with?(named_flat_prefix))
  end&.fetch("path")
end

#ensure_repositoryObject



18
19
20
21
22
23
24
25
26
27
# File 'lib/kamal_backup/restic.rb', line 18

def ensure_repository
  run(%w[snapshots --json])
rescue CommandError => e
  if config.restic_init_if_missing?
    log("restic repository not ready, running restic init")
    run(%w[init])
  else
    raise e
  end
end

#forget_after_successObject



77
78
79
80
81
82
83
# File 'lib/kamal_backup/restic.rb', line 77

def forget_after_success
  retention_tag_sets.each do |tags|
    args = ["forget", "--prune", "--group-by", "host"] + config.retention_args + filter_tag_args(tags)
    log("running restic forget/prune with retention policy for #{retention_scope(tags)}")
    run(args)
  end
end

#latest_snapshot(tags:) ⇒ Object



111
112
113
114
115
116
# File 'lib/kamal_backup/restic.rb', line 111

def latest_snapshot(tags:)
  snapshots = snapshots_json(tags: common_tags + tags)
  snapshots.max_by { |snapshot| Time.parse(snapshot.fetch("time")) }
rescue JSON::ParserError
  nil
end

#ls_json(snapshot) ⇒ Object



118
119
120
121
122
123
124
125
# File 'lib/kamal_backup/restic.rb', line 118

def ls_json(snapshot)
  output = run(["ls", "--json", snapshot]).stdout
  output.lines.filter_map do |line|
    JSON.parse(line)
  rescue JSON::ParserError
    nil
  end
end

#pipe_dump_to_command(snapshot, filename, command) ⇒ Object



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

def pipe_dump_to_command(snapshot, filename, command)
  restic_command = CommandSpec.new(argv: ["restic", "dump", snapshot, filename], env: restic_env)
  pipe_commands(restic_command, command, producer_label: "restic dump", consumer_label: command.argv.first)
end

#restore_snapshot(snapshot, target) ⇒ Object



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

def restore_snapshot(snapshot, target)
  log("restoring file snapshot #{snapshot} to #{target}")
  run(["restore", snapshot, "--target", target])
end

#run(args) ⇒ Object



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

def run(args)
  Command.capture(CommandSpec.new(argv: ["restic"] + args, env: restic_env), redactor: redactor)
end

#snapshots(tags: common_tags) ⇒ Object



97
98
99
# File 'lib/kamal_backup/restic.rb', line 97

def snapshots(tags: common_tags)
  run(["snapshots"] + filter_tag_args(tags))
end

#snapshots_json(tags: common_tags) ⇒ Object



101
102
103
104
105
106
107
108
109
# File 'lib/kamal_backup/restic.rb', line 101

def snapshots_json(tags: common_tags)
  output = run(["snapshots", "--json"] + filter_tag_args(tags)).stdout
  snapshots = JSON.parse(output)
  required_tags = tags.compact
  snapshots.select do |snapshot|
    snapshot_tags = Array(snapshot["tags"])
    required_tags.all? { |tag| snapshot_tags.include?(tag) }
  end
end

#write_dump_to_path(snapshot, filename, target_path) ⇒ Object



150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
# File 'lib/kamal_backup/restic.rb', line 150

def write_dump_to_path(snapshot, filename, target_path)
  command = CommandSpec.new(argv: ["restic", "dump", snapshot, filename], env: restic_env)
  target_path = File.expand_path(target_path)
  FileUtils.mkdir_p(File.dirname(target_path))
  temp_path = "#{target_path}.kamal-backup-#{$$}.tmp"

  Open3.popen3(command.env, *command.argv) do |stdin, stdout, stderr, wait_thread|
    stdin.close
    stderr_reader = Thread.new { stderr.read }
    File.open(temp_path, "wb") { |file| IO.copy_stream(stdout, file) }
    err = stderr_reader.value
    status = wait_thread.value
    raise_command_error(command, status, "", err) unless status.success?
  end
  File.rename(temp_path, target_path)
  target_path
rescue Errno::ENOENT => e
  FileUtils.rm_f(temp_path) if temp_path
  raise CommandError.new("command not found: #{command.argv.first}", command: command, status: 127, stderr: e.message)
rescue StandardError
  FileUtils.rm_f(temp_path) if temp_path
  raise
end