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", "--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"] + 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", "--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



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

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



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

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

#database_file(snapshot, adapter) ⇒ Object



125
126
127
128
129
130
131
132
133
134
# File 'lib/kamal_backup/restic.rb', line 125

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

    normalized = entry["path"].to_s.sub(%r{\A/+}, "")
    normalized.start_with?(legacy_prefix) || File.basename(normalized).start_with?(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
# File 'lib/kamal_backup/restic.rb', line 77

def forget_after_success
  args = ["forget", "--prune"] + config.retention_args + tag_args(common_tags)
  log("running restic forget/prune with retention policy")
  run(args)
end

#latest_snapshot(tags:) ⇒ Object



109
110
111
112
113
114
# File 'lib/kamal_backup/restic.rb', line 109

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



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

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



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

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



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

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

#run(args) ⇒ Object



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

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

#snapshots(tags: common_tags) ⇒ Object



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

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

#snapshots_json(tags: common_tags) ⇒ Object



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

def snapshots_json(tags: common_tags)
  output = run(["snapshots", "--json"] + 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



141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
# File 'lib/kamal_backup/restic.rb', line 141

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