ruborg 0.9.0 → 0.9.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +69 -0
- data/README.md +56 -4
- data/lib/ruborg/archive_cache.rb +189 -0
- data/lib/ruborg/backup.rb +82 -86
- data/lib/ruborg/catalog.rb +36 -0
- data/lib/ruborg/cli.rb +201 -57
- data/lib/ruborg/config.rb +3 -3
- data/lib/ruborg/progress.rb +94 -0
- data/lib/ruborg/repository.rb +118 -33
- data/lib/ruborg/version.rb +1 -1
- data/lib/ruborg.rb +4 -0
- metadata +6 -6
data/lib/ruborg/repository.rb
CHANGED
|
@@ -6,11 +6,13 @@ module Ruborg
|
|
|
6
6
|
class Repository
|
|
7
7
|
attr_reader :path, :borg_path
|
|
8
8
|
|
|
9
|
-
def initialize(path, passphrase: nil, borg_options: {}, borg_path: nil, logger: nil)
|
|
9
|
+
def initialize(path, passphrase: nil, borg_options: {}, borg_path: nil, lock_wait: nil, logger: nil)
|
|
10
|
+
@original_path = path
|
|
10
11
|
@path = validate_repo_path(path)
|
|
11
12
|
@passphrase = passphrase
|
|
12
13
|
@borg_options = borg_options
|
|
13
14
|
@borg_path = validate_borg_path(borg_path || "borg")
|
|
15
|
+
@lock_wait = lock_wait&.to_i
|
|
14
16
|
@logger = logger
|
|
15
17
|
end
|
|
16
18
|
|
|
@@ -18,6 +20,48 @@ module Ruborg
|
|
|
18
20
|
File.directory?(@path) && File.exist?(File.join(@path, "config"))
|
|
19
21
|
end
|
|
20
22
|
|
|
23
|
+
MINIMUM_BORG_VERSION = "1.4.0"
|
|
24
|
+
|
|
25
|
+
def locked?
|
|
26
|
+
File.exist?(File.join(@path, "lock.exclusive")) ||
|
|
27
|
+
File.exist?(File.join(@path, "lock.roster"))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def cache_locked?
|
|
31
|
+
File.exist?(cache_lock_path)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def break_lock
|
|
35
|
+
raise BorgError, "Repository does not exist at #{@path}" unless exists?
|
|
36
|
+
|
|
37
|
+
check_borg_version!
|
|
38
|
+
cmd = [@borg_path, "break-lock", @path]
|
|
39
|
+
execute_borg_command(cmd)
|
|
40
|
+
@logger&.info("Lock broken for repository at #{@path}")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def force_break_lock
|
|
44
|
+
raise BorgError, "Repository does not exist at #{@path}" unless exists?
|
|
45
|
+
|
|
46
|
+
require "fileutils"
|
|
47
|
+
removed = %w[lock.exclusive lock.roster].select do |name|
|
|
48
|
+
target = File.join(@path, name)
|
|
49
|
+
next false unless File.exist?(target)
|
|
50
|
+
|
|
51
|
+
FileUtils.rm_rf(target)
|
|
52
|
+
true
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
cache_lock = cache_lock_path
|
|
56
|
+
if File.exist?(cache_lock)
|
|
57
|
+
FileUtils.rm_f(cache_lock)
|
|
58
|
+
removed << "cache:lock.exclusive"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
@logger&.info("Force-removed lock files at #{@path}: #{removed.join(", ")}")
|
|
62
|
+
removed
|
|
63
|
+
end
|
|
64
|
+
|
|
21
65
|
def create
|
|
22
66
|
raise BorgError, "Repository already exists at #{@path}" if exists?
|
|
23
67
|
|
|
@@ -278,62 +322,67 @@ module Ruborg
|
|
|
278
322
|
nil # Failed to parse, skip this archive
|
|
279
323
|
end
|
|
280
324
|
|
|
325
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
281
326
|
def get_archives_grouped_by_source_dir
|
|
282
327
|
require "json"
|
|
283
328
|
require "time"
|
|
284
329
|
require "open3"
|
|
285
330
|
|
|
286
|
-
# Get list of all archives
|
|
287
331
|
cmd = [@borg_path, "list", @path, "--json"]
|
|
288
332
|
env = build_borg_env
|
|
289
333
|
|
|
290
334
|
stdout, stderr, status = Open3.capture3(env, *cmd)
|
|
291
335
|
raise BorgError, "Failed to list archives: #{stderr}" unless status.success?
|
|
292
336
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
# Group archives by source directory from metadata
|
|
337
|
+
archives = JSON.parse(stdout)["archives"] || []
|
|
338
|
+
cache = ArchiveCache.new(@original_path).fetch
|
|
297
339
|
archives_by_source = Hash.new { |h, k| h[k] = [] }
|
|
298
340
|
|
|
299
341
|
archives.each do |archive|
|
|
300
342
|
archive_name = archive["name"]
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
info_data = JSON.parse(info_stdout)
|
|
316
|
-
comment = info_data.dig("archives", 0, "comment") || ""
|
|
317
|
-
|
|
318
|
-
# Parse source_dir from comment
|
|
319
|
-
# Format: path|||size|||hash|||source_dir
|
|
320
|
-
source_dir = if comment.include?("|||")
|
|
321
|
-
parts = comment.split("|||")
|
|
322
|
-
parts.length >= 4 ? (parts[3] || "") : ""
|
|
343
|
+
archive_time = Time.parse(archive["time"])
|
|
344
|
+
|
|
345
|
+
metadata = if (cached = cache[archive_name])
|
|
346
|
+
cached
|
|
347
|
+
else
|
|
348
|
+
info_cmd = [@borg_path, "info", "#{@path}::#{archive_name}", "--json"]
|
|
349
|
+
info_stdout, _, info_status = Open3.capture3(env, *info_cmd)
|
|
350
|
+
|
|
351
|
+
if info_status.success?
|
|
352
|
+
comment = JSON.parse(info_stdout).dig("archives", 0, "comment") || ""
|
|
353
|
+
parsed = parse_archive_comment(comment)
|
|
354
|
+
cache.store(archive_name, parsed)
|
|
355
|
+
parsed
|
|
323
356
|
else
|
|
324
|
-
""
|
|
357
|
+
cache.store(archive_name, { path: "", size: 0, hash: "", source_dir: "" })
|
|
358
|
+
{ path: "", size: 0, hash: "", source_dir: "" }
|
|
325
359
|
end
|
|
360
|
+
end
|
|
326
361
|
|
|
327
|
-
archives_by_source[source_dir] << {
|
|
328
|
-
name: archive_name,
|
|
329
|
-
time: Time.parse(archive["time"])
|
|
330
|
-
}
|
|
362
|
+
archives_by_source[metadata[:source_dir] || ""] << { name: archive_name, time: archive_time }
|
|
331
363
|
end
|
|
332
364
|
|
|
365
|
+
cache.save_if_changed
|
|
333
366
|
archives_by_source
|
|
334
367
|
rescue JSON::ParserError => e
|
|
335
368
|
raise BorgError, "Failed to parse archive metadata: #{e.message}"
|
|
336
369
|
end
|
|
370
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
371
|
+
|
|
372
|
+
def parse_archive_comment(comment)
|
|
373
|
+
if comment.include?("|||")
|
|
374
|
+
parts = comment.split("|||")
|
|
375
|
+
if parts.length >= 4
|
|
376
|
+
{ path: parts[0], size: parts[1].to_i, hash: parts[2] || "", source_dir: parts[3] || "" }
|
|
377
|
+
elsif parts.length >= 3
|
|
378
|
+
{ path: parts[0], size: parts[1].to_i, hash: parts[2] || "", source_dir: "" }
|
|
379
|
+
else
|
|
380
|
+
{ path: parts[0], size: 0, hash: parts[1] || "", source_dir: "" }
|
|
381
|
+
end
|
|
382
|
+
else
|
|
383
|
+
{ path: comment, size: 0, hash: "", source_dir: "" }
|
|
384
|
+
end
|
|
385
|
+
end
|
|
337
386
|
|
|
338
387
|
def prune_per_directory_standard(retention_policy)
|
|
339
388
|
# Apply standard retention policies (keep_daily, etc.) per source directory
|
|
@@ -587,6 +636,39 @@ module Ruborg
|
|
|
587
636
|
borg_path
|
|
588
637
|
end
|
|
589
638
|
|
|
639
|
+
def inject_lock_wait(cmd)
|
|
640
|
+
return cmd if @lock_wait.nil? || cmd[1] == "break-lock"
|
|
641
|
+
|
|
642
|
+
[cmd[0], "--lock-wait", @lock_wait.to_s] + cmd[1..]
|
|
643
|
+
end
|
|
644
|
+
|
|
645
|
+
def check_borg_version!
|
|
646
|
+
version = self.class.borg_version(@borg_path)
|
|
647
|
+
return if version_sufficient?(version, MINIMUM_BORG_VERSION)
|
|
648
|
+
|
|
649
|
+
raise BorgError,
|
|
650
|
+
"Borg #{MINIMUM_BORG_VERSION}+ is required but found #{version}. Please upgrade Borg."
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
def version_sufficient?(actual, minimum)
|
|
654
|
+
actual_parts = actual.split(".").map(&:to_i)
|
|
655
|
+
minimum_parts = minimum.split(".").map(&:to_i)
|
|
656
|
+
(actual_parts <=> minimum_parts) >= 0
|
|
657
|
+
end
|
|
658
|
+
|
|
659
|
+
def cache_lock_path
|
|
660
|
+
cache_dir = ENV.fetch("BORG_CACHE_DIR", File.join(Dir.home, ".cache", "borg"))
|
|
661
|
+
repo_id = read_repo_id
|
|
662
|
+
File.join(cache_dir, repo_id, "lock.exclusive")
|
|
663
|
+
end
|
|
664
|
+
|
|
665
|
+
def read_repo_id
|
|
666
|
+
config_file = File.join(@path, "config")
|
|
667
|
+
return "" unless File.exist?(config_file)
|
|
668
|
+
|
|
669
|
+
File.read(config_file).match(/^\s*id\s*=\s*([0-9a-f]+)/)&.captures&.first || ""
|
|
670
|
+
end
|
|
671
|
+
|
|
590
672
|
def find_in_path(command)
|
|
591
673
|
ENV["PATH"].split(File::PATH_SEPARATOR).each do |directory|
|
|
592
674
|
path = File.join(directory, command)
|
|
@@ -610,6 +692,9 @@ module Ruborg
|
|
|
610
692
|
env["BORG_RELOCATED_REPO_ACCESS_IS_OK"] = allow_relocated ? "yes" : "no"
|
|
611
693
|
env["BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK"] = allow_unencrypted ? "yes" : "no"
|
|
612
694
|
|
|
695
|
+
# Inject --lock-wait for all commands except break-lock (which breaks, not acquires)
|
|
696
|
+
cmd = inject_lock_wait(cmd)
|
|
697
|
+
|
|
613
698
|
# Redirect stdin from /dev/null to prevent interactive prompts
|
|
614
699
|
result = system(env, *cmd, in: "/dev/null")
|
|
615
700
|
raise BorgError, "Borg command failed: #{cmd.join(" ")}" unless result
|
data/lib/ruborg/version.rb
CHANGED
data/lib/ruborg.rb
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
require_relative "ruborg/version"
|
|
4
4
|
require_relative "ruborg/logger"
|
|
5
5
|
require_relative "ruborg/config"
|
|
6
|
+
require_relative "ruborg/archive_cache"
|
|
7
|
+
require_relative "ruborg/catalog"
|
|
8
|
+
require_relative "ruborg/progress"
|
|
6
9
|
require_relative "ruborg/repository"
|
|
7
10
|
require_relative "ruborg/backup"
|
|
8
11
|
require_relative "ruborg/passbolt"
|
|
@@ -13,4 +16,5 @@ module Ruborg
|
|
|
13
16
|
class ConfigError < Error; end
|
|
14
17
|
class BorgError < Error; end
|
|
15
18
|
class PassboltError < Error; end
|
|
19
|
+
class CatalogError < Error; end
|
|
16
20
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ruborg
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.9.
|
|
4
|
+
version: 0.9.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Michail Pantelelis
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: exe
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: psych
|
|
@@ -144,11 +143,14 @@ files:
|
|
|
144
143
|
- SECURITY.md
|
|
145
144
|
- exe/ruborg
|
|
146
145
|
- lib/ruborg.rb
|
|
146
|
+
- lib/ruborg/archive_cache.rb
|
|
147
147
|
- lib/ruborg/backup.rb
|
|
148
|
+
- lib/ruborg/catalog.rb
|
|
148
149
|
- lib/ruborg/cli.rb
|
|
149
150
|
- lib/ruborg/config.rb
|
|
150
151
|
- lib/ruborg/logger.rb
|
|
151
152
|
- lib/ruborg/passbolt.rb
|
|
153
|
+
- lib/ruborg/progress.rb
|
|
152
154
|
- lib/ruborg/repository.rb
|
|
153
155
|
- lib/ruborg/version.rb
|
|
154
156
|
- ruborg.gemspec
|
|
@@ -161,7 +163,6 @@ metadata:
|
|
|
161
163
|
source_code_uri: https://github.com/mpantel/ruborg.git
|
|
162
164
|
changelog_uri: https://github.com/mpantel/ruborg/blob/main/CHANGELOG.md
|
|
163
165
|
rubygems_mfa_required: 'true'
|
|
164
|
-
post_install_message:
|
|
165
166
|
rdoc_options: []
|
|
166
167
|
require_paths:
|
|
167
168
|
- lib
|
|
@@ -176,8 +177,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
176
177
|
- !ruby/object:Gem::Version
|
|
177
178
|
version: '0'
|
|
178
179
|
requirements: []
|
|
179
|
-
rubygems_version: 3.
|
|
180
|
-
signing_key:
|
|
180
|
+
rubygems_version: 3.7.1
|
|
181
181
|
specification_version: 4
|
|
182
182
|
summary: A friendly Ruby frontend for Borg backup
|
|
183
183
|
test_files: []
|