Class: KamalBackup::Restic

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

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(config, redactor:) ⇒ Restic

Returns a new instance of Restic.



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

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

Instance Attribute Details

#configObject (readonly)

Returns the value of attribute config.



9
10
11
# File 'lib/kamal_backup/restic.rb', line 9

def config
  @config
end

#redactorObject (readonly)

Returns the value of attribute redactor.



9
10
11
# File 'lib/kamal_backup/restic.rb', line 9

def redactor
  @redactor
end

Instance Method Details

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



25
26
27
28
29
# File 'lib/kamal_backup/restic.rb', line 25

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

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



31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
# File 'lib/kamal_backup/restic.rb', line 31

def backup_file_content(path, filename:, tags:)
  command = CommandSpec.new(argv: ["restic", "backup", "--stdin", "--stdin-filename", filename] + tag_args(common_tags + tags))
  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



62
63
64
# File 'lib/kamal_backup/restic.rb', line 62

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

#backup_paths(paths, tags:) ⇒ Object



53
54
55
56
57
58
59
60
# File 'lib/kamal_backup/restic.rb', line 53

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

  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

#check!Object



72
73
74
75
76
77
78
79
80
81
82
# File 'lib/kamal_backup/restic.rb', line 72

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



163
164
165
# File 'lib/kamal_backup/restic.rb', line 163

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

#database_file(snapshot, adapter) ⇒ Object



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

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

#dump_file_to_command(snapshot, filename, command) ⇒ Object



125
126
127
128
# File 'lib/kamal_backup/restic.rb', line 125

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

#dump_file_to_path(snapshot, filename, target_path) ⇒ Object



130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
# File 'lib/kamal_backup/restic.rb', line 130

def dump_file_to_path(snapshot, filename, target_path)
  command = CommandSpec.new(argv: ["restic", "dump", snapshot, filename])
  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

#ensure_repository!Object



16
17
18
19
20
21
22
23
# File 'lib/kamal_backup/restic.rb', line 16

def ensure_repository!
  run(%w[snapshots --json])
rescue CommandError => e
  raise e unless config.restic_init_if_missing?

  log("restic repository not ready, running restic init")
  run(%w[init])
end

#forget_after_success!Object



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

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



98
99
100
101
102
103
# File 'lib/kamal_backup/restic.rb', line 98

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



105
106
107
108
109
110
111
112
# File 'lib/kamal_backup/restic.rb', line 105

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

#restore_snapshot(snapshot, target) ⇒ Object



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

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

#run(args) ⇒ Object



159
160
161
# File 'lib/kamal_backup/restic.rb', line 159

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

#snapshots(tags: common_tags) ⇒ Object



84
85
86
# File 'lib/kamal_backup/restic.rb', line 84

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

#snapshots_json(tags: common_tags) ⇒ Object



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

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