ruborg 0.8.1 → 0.9.3
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 +85 -0
- data/README.md +109 -18
- data/lib/ruborg/archive_cache.rb +189 -0
- data/lib/ruborg/backup.rb +85 -92
- data/lib/ruborg/catalog.rb +36 -0
- data/lib/ruborg/cli.rb +312 -126
- data/lib/ruborg/config.rb +7 -5
- data/lib/ruborg/progress.rb +81 -0
- data/lib/ruborg/repository.rb +109 -33
- data/lib/ruborg/version.rb +1 -1
- data/lib/ruborg.rb +4 -0
- metadata +4 -1
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ruborg
|
|
4
|
+
# Terminal progress display: named stages, inline progress bar, and spinner.
|
|
5
|
+
# Writes to $stderr so stdout remains clean for --json or piped output.
|
|
6
|
+
# Degrades to plain text lines when output is not a TTY (piped / redirected).
|
|
7
|
+
class Progress
|
|
8
|
+
SPINNER_FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
|
|
9
|
+
BAR_WIDTH = 28
|
|
10
|
+
LINE_WIDTH = 80
|
|
11
|
+
|
|
12
|
+
def initialize(output: $stderr)
|
|
13
|
+
@output = output
|
|
14
|
+
@tty = output.respond_to?(:isatty) && output.isatty
|
|
15
|
+
@spinner_thread = nil
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Print a numbered stage header: "[2/3] Label"
|
|
19
|
+
def stage(index, total, label)
|
|
20
|
+
stop_spin
|
|
21
|
+
clear_line if @tty
|
|
22
|
+
@output.puts "[#{index}/#{total}] #{label}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Start a spinner on the current line for an indeterminate operation.
|
|
26
|
+
# Call stop_spin (or done) to halt it.
|
|
27
|
+
def spin(label)
|
|
28
|
+
stop_spin
|
|
29
|
+
return unless @tty
|
|
30
|
+
|
|
31
|
+
frame = 0
|
|
32
|
+
@spinner_thread = Thread.new do
|
|
33
|
+
loop do
|
|
34
|
+
@output.print "\r #{SPINNER_FRAMES[frame % SPINNER_FRAMES.size]} #{label}"
|
|
35
|
+
frame += 1
|
|
36
|
+
sleep 0.1
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Stop the spinner and erase its line.
|
|
42
|
+
def stop_spin
|
|
43
|
+
return unless @spinner_thread
|
|
44
|
+
|
|
45
|
+
@spinner_thread.kill
|
|
46
|
+
@spinner_thread.join(0.2)
|
|
47
|
+
@spinner_thread = nil
|
|
48
|
+
clear_line if @tty
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Redraw an inline progress bar. Call once per item in a loop.
|
|
52
|
+
# label is truncated to fit the terminal line.
|
|
53
|
+
def bar(current, total, label = "")
|
|
54
|
+
return unless @tty
|
|
55
|
+
|
|
56
|
+
pct = total.positive? ? (current.to_f / total) : 0
|
|
57
|
+
filled = (BAR_WIDTH * pct).round
|
|
58
|
+
bar_str = filled.positive? ? "#{"=" * (filled - 1)}>" : ""
|
|
59
|
+
bar_str = bar_str.ljust(BAR_WIDTH)
|
|
60
|
+
short_label = truncate_left(label.to_s, 28)
|
|
61
|
+
@output.print "\r [#{bar_str}] #{current}/#{total} #{short_label.ljust(28)}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Halt any in-progress display and print a completion line.
|
|
65
|
+
def done(label = nil)
|
|
66
|
+
stop_spin
|
|
67
|
+
clear_line if @tty
|
|
68
|
+
@output.puts " ✓ #{label}" if label
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def clear_line
|
|
74
|
+
@output.print "\r#{" " * LINE_WIDTH}\r"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def truncate_left(str, max)
|
|
78
|
+
str.length > max ? "...#{str[-(max - 3)..]}" : str
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
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,37 @@ 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 break_lock
|
|
31
|
+
raise BorgError, "Repository does not exist at #{@path}" unless exists?
|
|
32
|
+
|
|
33
|
+
check_borg_version!
|
|
34
|
+
cmd = [@borg_path, "break-lock", @path]
|
|
35
|
+
execute_borg_command(cmd)
|
|
36
|
+
@logger&.info("Lock broken for repository at #{@path}")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def force_break_lock
|
|
40
|
+
raise BorgError, "Repository does not exist at #{@path}" unless exists?
|
|
41
|
+
|
|
42
|
+
require "fileutils"
|
|
43
|
+
removed = %w[lock.exclusive lock.roster].select do |name|
|
|
44
|
+
target = File.join(@path, name)
|
|
45
|
+
next false unless File.exist?(target)
|
|
46
|
+
|
|
47
|
+
FileUtils.rm_rf(target)
|
|
48
|
+
true
|
|
49
|
+
end
|
|
50
|
+
@logger&.info("Force-removed lock files at #{@path}: #{removed.join(", ")}")
|
|
51
|
+
removed
|
|
52
|
+
end
|
|
53
|
+
|
|
21
54
|
def create
|
|
22
55
|
raise BorgError, "Repository already exists at #{@path}" if exists?
|
|
23
56
|
|
|
@@ -278,62 +311,67 @@ module Ruborg
|
|
|
278
311
|
nil # Failed to parse, skip this archive
|
|
279
312
|
end
|
|
280
313
|
|
|
314
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
281
315
|
def get_archives_grouped_by_source_dir
|
|
282
316
|
require "json"
|
|
283
317
|
require "time"
|
|
284
318
|
require "open3"
|
|
285
319
|
|
|
286
|
-
# Get list of all archives
|
|
287
320
|
cmd = [@borg_path, "list", @path, "--json"]
|
|
288
321
|
env = build_borg_env
|
|
289
322
|
|
|
290
323
|
stdout, stderr, status = Open3.capture3(env, *cmd)
|
|
291
324
|
raise BorgError, "Failed to list archives: #{stderr}" unless status.success?
|
|
292
325
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
# Group archives by source directory from metadata
|
|
326
|
+
archives = JSON.parse(stdout)["archives"] || []
|
|
327
|
+
cache = ArchiveCache.new(@original_path).fetch
|
|
297
328
|
archives_by_source = Hash.new { |h, k| h[k] = [] }
|
|
298
329
|
|
|
299
330
|
archives.each do |archive|
|
|
300
331
|
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] || "") : ""
|
|
332
|
+
archive_time = Time.parse(archive["time"])
|
|
333
|
+
|
|
334
|
+
metadata = if (cached = cache[archive_name])
|
|
335
|
+
cached
|
|
336
|
+
else
|
|
337
|
+
info_cmd = [@borg_path, "info", "#{@path}::#{archive_name}", "--json"]
|
|
338
|
+
info_stdout, _, info_status = Open3.capture3(env, *info_cmd)
|
|
339
|
+
|
|
340
|
+
if info_status.success?
|
|
341
|
+
comment = JSON.parse(info_stdout).dig("archives", 0, "comment") || ""
|
|
342
|
+
parsed = parse_archive_comment(comment)
|
|
343
|
+
cache.store(archive_name, parsed)
|
|
344
|
+
parsed
|
|
323
345
|
else
|
|
324
|
-
""
|
|
346
|
+
cache.store(archive_name, { path: "", size: 0, hash: "", source_dir: "" })
|
|
347
|
+
{ path: "", size: 0, hash: "", source_dir: "" }
|
|
325
348
|
end
|
|
349
|
+
end
|
|
326
350
|
|
|
327
|
-
archives_by_source[source_dir] << {
|
|
328
|
-
name: archive_name,
|
|
329
|
-
time: Time.parse(archive["time"])
|
|
330
|
-
}
|
|
351
|
+
archives_by_source[metadata[:source_dir] || ""] << { name: archive_name, time: archive_time }
|
|
331
352
|
end
|
|
332
353
|
|
|
354
|
+
cache.save_if_changed
|
|
333
355
|
archives_by_source
|
|
334
356
|
rescue JSON::ParserError => e
|
|
335
357
|
raise BorgError, "Failed to parse archive metadata: #{e.message}"
|
|
336
358
|
end
|
|
359
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
360
|
+
|
|
361
|
+
def parse_archive_comment(comment)
|
|
362
|
+
if comment.include?("|||")
|
|
363
|
+
parts = comment.split("|||")
|
|
364
|
+
if parts.length >= 4
|
|
365
|
+
{ path: parts[0], size: parts[1].to_i, hash: parts[2] || "", source_dir: parts[3] || "" }
|
|
366
|
+
elsif parts.length >= 3
|
|
367
|
+
{ path: parts[0], size: parts[1].to_i, hash: parts[2] || "", source_dir: "" }
|
|
368
|
+
else
|
|
369
|
+
{ path: parts[0], size: 0, hash: parts[1] || "", source_dir: "" }
|
|
370
|
+
end
|
|
371
|
+
else
|
|
372
|
+
{ path: comment, size: 0, hash: "", source_dir: "" }
|
|
373
|
+
end
|
|
374
|
+
end
|
|
337
375
|
|
|
338
376
|
def prune_per_directory_standard(retention_policy)
|
|
339
377
|
# Apply standard retention policies (keep_daily, etc.) per source directory
|
|
@@ -477,6 +515,21 @@ module Ruborg
|
|
|
477
515
|
match[1]
|
|
478
516
|
end
|
|
479
517
|
|
|
518
|
+
# Get Borg path (full path to executable)
|
|
519
|
+
def self.borg_path(borg_command = "borg")
|
|
520
|
+
# If it's an absolute or relative path, expand it
|
|
521
|
+
return File.expand_path(borg_command) if borg_command.include?("/")
|
|
522
|
+
|
|
523
|
+
# Otherwise, search in PATH
|
|
524
|
+
ENV["PATH"].split(File::PATH_SEPARATOR).each do |directory|
|
|
525
|
+
path = File.join(directory, borg_command)
|
|
526
|
+
return path if File.executable?(path)
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
# Not found in PATH, return the command as-is
|
|
530
|
+
borg_command
|
|
531
|
+
end
|
|
532
|
+
|
|
480
533
|
# Execute borg version command (extracted for testing)
|
|
481
534
|
def self.execute_version_command(borg_path = "borg")
|
|
482
535
|
require "open3"
|
|
@@ -572,6 +625,26 @@ module Ruborg
|
|
|
572
625
|
borg_path
|
|
573
626
|
end
|
|
574
627
|
|
|
628
|
+
def inject_lock_wait(cmd)
|
|
629
|
+
return cmd if @lock_wait.nil? || cmd[1] == "break-lock"
|
|
630
|
+
|
|
631
|
+
[cmd[0], "--lock-wait", @lock_wait.to_s] + cmd[1..]
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
def check_borg_version!
|
|
635
|
+
version = self.class.borg_version(@borg_path)
|
|
636
|
+
return if version_sufficient?(version, MINIMUM_BORG_VERSION)
|
|
637
|
+
|
|
638
|
+
raise BorgError,
|
|
639
|
+
"Borg #{MINIMUM_BORG_VERSION}+ is required but found #{version}. Please upgrade Borg."
|
|
640
|
+
end
|
|
641
|
+
|
|
642
|
+
def version_sufficient?(actual, minimum)
|
|
643
|
+
actual_parts = actual.split(".").map(&:to_i)
|
|
644
|
+
minimum_parts = minimum.split(".").map(&:to_i)
|
|
645
|
+
(actual_parts <=> minimum_parts) >= 0
|
|
646
|
+
end
|
|
647
|
+
|
|
575
648
|
def find_in_path(command)
|
|
576
649
|
ENV["PATH"].split(File::PATH_SEPARATOR).each do |directory|
|
|
577
650
|
path = File.join(directory, command)
|
|
@@ -595,6 +668,9 @@ module Ruborg
|
|
|
595
668
|
env["BORG_RELOCATED_REPO_ACCESS_IS_OK"] = allow_relocated ? "yes" : "no"
|
|
596
669
|
env["BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK"] = allow_unencrypted ? "yes" : "no"
|
|
597
670
|
|
|
671
|
+
# Inject --lock-wait for all commands except break-lock (which breaks, not acquires)
|
|
672
|
+
cmd = inject_lock_wait(cmd)
|
|
673
|
+
|
|
598
674
|
# Redirect stdin from /dev/null to prevent interactive prompts
|
|
599
675
|
result = system(env, *cmd, in: "/dev/null")
|
|
600
676
|
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,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ruborg
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.9.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Michail Pantelelis
|
|
@@ -143,11 +143,14 @@ files:
|
|
|
143
143
|
- SECURITY.md
|
|
144
144
|
- exe/ruborg
|
|
145
145
|
- lib/ruborg.rb
|
|
146
|
+
- lib/ruborg/archive_cache.rb
|
|
146
147
|
- lib/ruborg/backup.rb
|
|
148
|
+
- lib/ruborg/catalog.rb
|
|
147
149
|
- lib/ruborg/cli.rb
|
|
148
150
|
- lib/ruborg/config.rb
|
|
149
151
|
- lib/ruborg/logger.rb
|
|
150
152
|
- lib/ruborg/passbolt.rb
|
|
153
|
+
- lib/ruborg/progress.rb
|
|
151
154
|
- lib/ruborg/repository.rb
|
|
152
155
|
- lib/ruborg/version.rb
|
|
153
156
|
- ruborg.gemspec
|