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
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ruborg
|
|
4
|
+
# Read-only view over the ArchiveCache for searching and reporting.
|
|
5
|
+
# Never writes back to the cache.
|
|
6
|
+
class Catalog
|
|
7
|
+
def initialize(repo_path)
|
|
8
|
+
@cache = ArchiveCache.new(repo_path).fetch
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Returns all cached entries sorted by file path.
|
|
12
|
+
def list
|
|
13
|
+
@cache.entries.sort_by { |e| e[:path].to_s }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Returns entries whose :path matches +pattern+ (a Regexp or regex string).
|
|
17
|
+
# Raises CatalogError on invalid regex.
|
|
18
|
+
def search(pattern)
|
|
19
|
+
regex = pattern.is_a?(Regexp) ? pattern : Regexp.new(pattern)
|
|
20
|
+
list.select { |e| regex.match?(e[:path].to_s) }
|
|
21
|
+
rescue RegexpError => e
|
|
22
|
+
raise CatalogError, "Invalid regex pattern: #{e.message}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Returns a summary hash with aggregate statistics.
|
|
26
|
+
def stats
|
|
27
|
+
all = list
|
|
28
|
+
{
|
|
29
|
+
total_archives: all.size,
|
|
30
|
+
unique_paths: all.map { |e| e[:path] }.uniq.size,
|
|
31
|
+
total_size: all.sum { |e| e[:size].to_i },
|
|
32
|
+
source_dirs: all.map { |e| e[:source_dir] }.uniq.reject(&:empty?).size
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
data/lib/ruborg/cli.rb
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "thor"
|
|
4
|
+
require "json"
|
|
4
5
|
|
|
5
6
|
module Ruborg
|
|
6
7
|
# Command-line interface for ruborg
|
|
7
8
|
class CLI < Thor
|
|
9
|
+
DEFAULT_LOCK_WAIT = 300
|
|
8
10
|
class_option :config, type: :string, default: "ruborg.yml", desc: "Path to configuration file"
|
|
9
11
|
class_option :log, type: :string, desc: "Path to log file"
|
|
10
12
|
class_option :repository, type: :string, aliases: "-r", desc: "Repository name (for multi-repo configs)"
|
|
@@ -74,11 +76,7 @@ module Ruborg
|
|
|
74
76
|
merged_config = global_settings.merge(repo_config)
|
|
75
77
|
validate_hostname(merged_config)
|
|
76
78
|
passphrase = fetch_passphrase_for_repo(merged_config)
|
|
77
|
-
|
|
78
|
-
borg_path = merged_config["borg_path"]
|
|
79
|
-
|
|
80
|
-
repo = Repository.new(repo_config["path"], passphrase: passphrase, borg_options: borg_opts, borg_path: borg_path,
|
|
81
|
-
logger: @logger)
|
|
79
|
+
repo = build_repo(repo_config["path"], merged_config, passphrase)
|
|
82
80
|
|
|
83
81
|
# Auto-initialize repository if configured
|
|
84
82
|
# Use strict boolean checking: only true enables, everything else disables
|
|
@@ -121,11 +119,7 @@ module Ruborg
|
|
|
121
119
|
merged_config = global_settings.merge(repo_config)
|
|
122
120
|
validate_hostname(merged_config)
|
|
123
121
|
passphrase = fetch_passphrase_for_repo(merged_config)
|
|
124
|
-
|
|
125
|
-
borg_path = merged_config["borg_path"]
|
|
126
|
-
|
|
127
|
-
repo = Repository.new(repo_config["path"], passphrase: passphrase, borg_options: borg_opts, borg_path: borg_path,
|
|
128
|
-
logger: @logger)
|
|
122
|
+
repo = build_repo(repo_config["path"], merged_config, passphrase)
|
|
129
123
|
|
|
130
124
|
# Create backup config wrapper for compatibility
|
|
131
125
|
backup_config = BackupConfig.new(repo_config, merged_config)
|
|
@@ -161,11 +155,7 @@ module Ruborg
|
|
|
161
155
|
global_settings = config.global_settings
|
|
162
156
|
merged_config = global_settings.merge(repo_config)
|
|
163
157
|
passphrase = fetch_passphrase_for_repo(merged_config)
|
|
164
|
-
|
|
165
|
-
borg_path = merged_config["borg_path"]
|
|
166
|
-
|
|
167
|
-
repo = Repository.new(repo_config["path"], passphrase: passphrase, borg_options: borg_opts, borg_path: borg_path,
|
|
168
|
-
logger: @logger)
|
|
158
|
+
repo = build_repo(repo_config["path"], merged_config, passphrase)
|
|
169
159
|
|
|
170
160
|
# Auto-initialize repository if configured
|
|
171
161
|
# Use strict boolean checking: only true enables, everything else disables
|
|
@@ -311,11 +301,7 @@ module Ruborg
|
|
|
311
301
|
merged_config = global_settings.merge(repo_config)
|
|
312
302
|
validate_hostname(merged_config)
|
|
313
303
|
passphrase = fetch_passphrase_for_repo(merged_config)
|
|
314
|
-
|
|
315
|
-
borg_path = merged_config["borg_path"]
|
|
316
|
-
|
|
317
|
-
repo = Repository.new(repo_config["path"], passphrase: passphrase, borg_options: borg_opts, borg_path: borg_path,
|
|
318
|
-
logger: @logger)
|
|
304
|
+
repo = build_repo(repo_config["path"], merged_config, passphrase)
|
|
319
305
|
|
|
320
306
|
unless repo.exists?
|
|
321
307
|
puts " ✗ Repository does not exist at #{repo_config["path"]}"
|
|
@@ -358,6 +344,95 @@ module Ruborg
|
|
|
358
344
|
|
|
359
345
|
public
|
|
360
346
|
|
|
347
|
+
desc "catalog", "Search or browse the local archive metadata catalog (no borg calls)"
|
|
348
|
+
option :search, type: :string, desc: "Regex pattern to filter by file path"
|
|
349
|
+
option :stats, type: :boolean, default: false, desc: "Show catalog statistics instead of listing entries"
|
|
350
|
+
option :json, type: :boolean, default: false, desc: "Output as JSON"
|
|
351
|
+
def catalog
|
|
352
|
+
config = Config.new(options[:config])
|
|
353
|
+
|
|
354
|
+
raise ConfigError, "Please specify --repository" unless options[:repository]
|
|
355
|
+
|
|
356
|
+
repo_config = config.get_repository(options[:repository])
|
|
357
|
+
raise ConfigError, "Repository '#{options[:repository]}' not found" unless repo_config
|
|
358
|
+
|
|
359
|
+
global_settings = config.global_settings
|
|
360
|
+
merged_config = global_settings.merge(repo_config)
|
|
361
|
+
cat = Catalog.new(repo_config["path"])
|
|
362
|
+
|
|
363
|
+
if options[:stats]
|
|
364
|
+
print_catalog_stats(cat.stats, options[:json])
|
|
365
|
+
elsif options[:search]
|
|
366
|
+
results = cat.search(options[:search])
|
|
367
|
+
print_catalog_entries(results, options[:json])
|
|
368
|
+
else
|
|
369
|
+
print_catalog_entries(cat.list, options[:json])
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
@logger.info("Catalog query on repository '#{merged_config["name"]}'")
|
|
373
|
+
rescue Error => e
|
|
374
|
+
@logger.error("Catalog failed: #{e.message}")
|
|
375
|
+
raise
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
desc "lock", "Check for and optionally break a Borg repository lock"
|
|
379
|
+
option :break, type: :boolean, default: false,
|
|
380
|
+
desc: "Break the lock via borg break-lock (requires --yes)"
|
|
381
|
+
option :force, type: :boolean, default: false,
|
|
382
|
+
desc: "Force-remove lock files directly without invoking borg (requires --yes)"
|
|
383
|
+
option :yes, type: :boolean, default: false, desc: "Confirm the destructive operation"
|
|
384
|
+
def lock
|
|
385
|
+
config = Config.new(options[:config])
|
|
386
|
+
|
|
387
|
+
raise ConfigError, "Please specify --repository" unless options[:repository]
|
|
388
|
+
raise ConfigError, "Use --break or --force, not both" if options[:break] && options[:force]
|
|
389
|
+
|
|
390
|
+
repo_config = config.get_repository(options[:repository])
|
|
391
|
+
raise ConfigError, "Repository '#{options[:repository]}' not found" unless repo_config
|
|
392
|
+
|
|
393
|
+
global_settings = config.global_settings
|
|
394
|
+
merged_config = global_settings.merge(repo_config)
|
|
395
|
+
passphrase = fetch_passphrase_for_repo(merged_config)
|
|
396
|
+
repo = build_repo(repo_config["path"], merged_config, passphrase)
|
|
397
|
+
|
|
398
|
+
repo_locked = repo.locked?
|
|
399
|
+
cache_locked = repo.cache_locked?
|
|
400
|
+
|
|
401
|
+
unless repo_locked || cache_locked
|
|
402
|
+
puts "No lock found for repository '#{repo_config["name"]}'"
|
|
403
|
+
@logger.info("Lock check: no lock found for '#{repo_config["name"]}'")
|
|
404
|
+
return
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
warn "Lock detected on repository '#{repo_config["name"]}' (#{repo_config["path"]}):"
|
|
408
|
+
warn " Repository lock : #{repo_locked ? "LOCKED" : "clear"}"
|
|
409
|
+
warn " Cache lock : #{cache_locked ? "LOCKED" : "clear"}"
|
|
410
|
+
@logger.warn("Lock detected on '#{repo_config["name"]}' — repo=#{repo_locked}, cache=#{cache_locked}")
|
|
411
|
+
|
|
412
|
+
unless options[:break] || options[:force]
|
|
413
|
+
warn " Run with --break --yes (via borg) or --force --yes (direct removal)."
|
|
414
|
+
exit 1
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
unless options[:yes]
|
|
418
|
+
warn " Add --yes to confirm."
|
|
419
|
+
exit 1
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
if options[:force]
|
|
423
|
+
removed = repo.force_break_lock
|
|
424
|
+
puts "Force-removed lock files for '#{repo_config["name"]}': #{removed.join(", ")}"
|
|
425
|
+
@logger.info("Force-removed lock files for '#{repo_config["name"]}'")
|
|
426
|
+
else
|
|
427
|
+
repo.break_lock
|
|
428
|
+
puts "Lock broken for repository '#{repo_config["name"]}'"
|
|
429
|
+
@logger.info("Lock broken for repository '#{repo_config["name"]}'")
|
|
430
|
+
end
|
|
431
|
+
rescue Error => e
|
|
432
|
+
@logger.error("Lock command failed: #{e.message}")
|
|
433
|
+
raise
|
|
434
|
+
end
|
|
435
|
+
|
|
361
436
|
desc "version", "Show ruborg and borg versions"
|
|
362
437
|
def version
|
|
363
438
|
require_relative "version"
|
|
@@ -408,11 +483,7 @@ module Ruborg
|
|
|
408
483
|
merged_config = global_settings.merge(repo_config)
|
|
409
484
|
validate_hostname(merged_config)
|
|
410
485
|
passphrase = fetch_passphrase_for_repo(merged_config)
|
|
411
|
-
|
|
412
|
-
borg_path = merged_config["borg_path"]
|
|
413
|
-
|
|
414
|
-
repo = Repository.new(repo_config["path"], passphrase: passphrase, borg_options: borg_opts, borg_path: borg_path,
|
|
415
|
-
logger: @logger)
|
|
486
|
+
repo = build_repo(repo_config["path"], merged_config, passphrase)
|
|
416
487
|
|
|
417
488
|
raise BorgError, "Repository does not exist at #{repo_config["path"]}" unless repo.exists?
|
|
418
489
|
|
|
@@ -441,6 +512,49 @@ module Ruborg
|
|
|
441
512
|
|
|
442
513
|
private
|
|
443
514
|
|
|
515
|
+
def print_catalog_entries(entries, as_json)
|
|
516
|
+
if as_json
|
|
517
|
+
puts JSON.generate(entries.map { |e| stringify_entry(e) })
|
|
518
|
+
return
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
if entries.empty?
|
|
522
|
+
puts "No entries found."
|
|
523
|
+
return
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
puts "\n#{"FILE PATH".ljust(55)} #{"SIZE".ljust(10)} ARCHIVE"
|
|
527
|
+
puts "-" * 90
|
|
528
|
+
entries.each do |e|
|
|
529
|
+
puts "#{truncate(e[:path].to_s, 55).ljust(55)} #{format_size(e[:size].to_i).ljust(10)} #{e[:archive_name]}"
|
|
530
|
+
end
|
|
531
|
+
puts "\n#{entries.size} entry/entries."
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
def print_catalog_stats(stats, as_json)
|
|
535
|
+
if as_json
|
|
536
|
+
puts JSON.generate(stats)
|
|
537
|
+
return
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
puts "\n═══════════════════════════════════════════════════════════════"
|
|
541
|
+
puts " CATALOG STATISTICS"
|
|
542
|
+
puts "═══════════════════════════════════════════════════════════════\n\n"
|
|
543
|
+
puts " Total archives : #{stats[:total_archives]}"
|
|
544
|
+
puts " Unique files : #{stats[:unique_paths]}"
|
|
545
|
+
puts " Source dirs : #{stats[:source_dirs]}"
|
|
546
|
+
puts " Total size : #{format_size(stats[:total_size])}"
|
|
547
|
+
puts ""
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
def stringify_entry(entry)
|
|
551
|
+
entry.transform_keys(&:to_s)
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
def truncate(str, max)
|
|
555
|
+
str.length > max ? "...#{str[-(max - 3)..]}" : str
|
|
556
|
+
end
|
|
557
|
+
|
|
444
558
|
def show_repositories_summary(config)
|
|
445
559
|
repositories = config.repositories
|
|
446
560
|
global_settings = config.global_settings
|
|
@@ -529,7 +643,7 @@ module Ruborg
|
|
|
529
643
|
unit_index += 1
|
|
530
644
|
end
|
|
531
645
|
|
|
532
|
-
format("%.2f
|
|
646
|
+
"#{format("%.2f", size)} #{units[unit_index]}"
|
|
533
647
|
end
|
|
534
648
|
|
|
535
649
|
def get_passphrase(passphrase, passbolt_id)
|
|
@@ -588,30 +702,32 @@ module Ruborg
|
|
|
588
702
|
puts "\n--- Backing up repository: #{repo_name} ---"
|
|
589
703
|
@logger.info("Backing up repository: #{repo_name}")
|
|
590
704
|
|
|
591
|
-
# Merge global settings with repo-specific settings (repo-specific takes precedence)
|
|
592
705
|
merged_config = global_settings.merge(repo_config)
|
|
593
706
|
validate_hostname(merged_config)
|
|
594
707
|
|
|
708
|
+
retention_mode = merged_config["retention_mode"] || "standard"
|
|
709
|
+
auto_prune = merged_config["auto_prune"] == true
|
|
710
|
+
retention_policy = merged_config["retention"]
|
|
711
|
+
will_prune = auto_prune && retention_policy && !retention_policy.empty?
|
|
712
|
+
stage_total = will_prune ? 3 : 2
|
|
713
|
+
|
|
714
|
+
progress = Progress.new
|
|
715
|
+
progress.stage(1, stage_total, "Verifying repository: #{repo_name}")
|
|
716
|
+
progress.spin("Preparing...")
|
|
717
|
+
|
|
595
718
|
passphrase = fetch_passphrase_for_repo(merged_config)
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
repo = Repository.new(repo_config["path"], passphrase: passphrase, borg_options: borg_opts, borg_path: borg_path,
|
|
599
|
-
logger: @logger)
|
|
719
|
+
lock_wait = (merged_config["lock_wait"] || DEFAULT_LOCK_WAIT).to_i
|
|
720
|
+
repo = build_repo(repo_config["path"], merged_config, passphrase)
|
|
600
721
|
|
|
601
|
-
|
|
602
|
-
# Use strict boolean checking: only true enables, everything else disables
|
|
603
|
-
auto_init = merged_config["auto_init"]
|
|
604
|
-
auto_init = false unless auto_init == true
|
|
722
|
+
auto_init = merged_config["auto_init"] == true
|
|
605
723
|
if auto_init && !repo.exists?
|
|
606
724
|
@logger.info("Auto-initializing repository at #{repo_config["path"]}")
|
|
607
725
|
repo.create
|
|
608
726
|
puts "Repository auto-initialized at #{repo_config["path"]}"
|
|
609
727
|
end
|
|
610
728
|
|
|
611
|
-
|
|
612
|
-
retention_mode = merged_config["retention_mode"] || "standard"
|
|
729
|
+
wait_for_lock_clear(repo, repo_name, lock_wait, progress)
|
|
613
730
|
|
|
614
|
-
# Validate remove_source permission with strict type checking
|
|
615
731
|
if options[:remove_source]
|
|
616
732
|
allow_remove_source = merged_config["allow_remove_source"]
|
|
617
733
|
unless allow_remove_source.is_a?(TrueClass)
|
|
@@ -622,14 +738,14 @@ module Ruborg
|
|
|
622
738
|
end
|
|
623
739
|
end
|
|
624
740
|
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
741
|
+
skip_hash_check = merged_config["skip_hash_check"] == true
|
|
742
|
+
|
|
743
|
+
backup_label = retention_mode == "per_file" ? "Backing up files (per-file mode)" : "Creating archive"
|
|
744
|
+
progress.stage(2, stage_total, backup_label)
|
|
628
745
|
|
|
629
|
-
# Create backup config wrapper
|
|
630
746
|
backup_config = BackupConfig.new(repo_config, merged_config)
|
|
631
747
|
backup = Backup.new(repo, config: backup_config, retention_mode: retention_mode, repo_name: repo_name,
|
|
632
|
-
logger: @logger, skip_hash_check: skip_hash_check)
|
|
748
|
+
logger: @logger, skip_hash_check: skip_hash_check, progress: progress)
|
|
633
749
|
|
|
634
750
|
archive_name = options[:name] ? sanitize_archive_name(options[:name]) : nil
|
|
635
751
|
@logger.info("Creating archive#{"s" if retention_mode == "per_file"}: #{archive_name || "auto-generated"}")
|
|
@@ -640,27 +756,55 @@ module Ruborg
|
|
|
640
756
|
backup.create(name: archive_name, remove_source: options[:remove_source])
|
|
641
757
|
@logger.info("Backup created successfully")
|
|
642
758
|
|
|
643
|
-
if retention_mode == "per_file"
|
|
644
|
-
puts "✓ Per-file backups created"
|
|
645
|
-
else
|
|
646
|
-
puts "✓ Backup created: #{archive_name || "auto-generated"}"
|
|
647
|
-
end
|
|
648
759
|
puts " Sources removed" if options[:remove_source]
|
|
649
760
|
|
|
650
|
-
|
|
651
|
-
# Use strict boolean checking: only true enables, everything else disables
|
|
652
|
-
auto_prune = merged_config["auto_prune"]
|
|
653
|
-
auto_prune = false unless auto_prune == true
|
|
654
|
-
retention_policy = merged_config["retention"]
|
|
655
|
-
|
|
656
|
-
return unless auto_prune && retention_policy && !retention_policy.empty?
|
|
761
|
+
return unless will_prune
|
|
657
762
|
|
|
658
763
|
mode_desc = retention_mode == "per_file" ? "per-file mode" : "standard mode"
|
|
764
|
+
progress.stage(3, stage_total, "Pruning old archives (#{mode_desc})")
|
|
765
|
+
progress.spin("Pruning...")
|
|
659
766
|
@logger.info("Auto-pruning repository: #{repo_name} (#{mode_desc})")
|
|
660
|
-
puts " Pruning old backups (#{mode_desc})..."
|
|
661
767
|
repo.prune(retention_policy, retention_mode: retention_mode)
|
|
662
768
|
@logger.info("Pruning completed successfully for #{repo_name}")
|
|
663
|
-
|
|
769
|
+
progress.done("Pruning completed")
|
|
770
|
+
end
|
|
771
|
+
|
|
772
|
+
def wait_for_lock_clear(repo, repo_name, lock_wait, progress)
|
|
773
|
+
return unless repo.locked?
|
|
774
|
+
|
|
775
|
+
@logger.warn("Repository '#{repo_name}' is locked — waiting up to #{lock_wait}s")
|
|
776
|
+
elapsed = 0
|
|
777
|
+
interval = 5
|
|
778
|
+
|
|
779
|
+
progress.spin("Repository locked — waiting for lock to clear (0s / #{lock_wait}s)…")
|
|
780
|
+
|
|
781
|
+
while repo.locked? && elapsed < lock_wait
|
|
782
|
+
sleep interval
|
|
783
|
+
elapsed += interval
|
|
784
|
+
progress.spin("Repository locked — waiting for lock to clear (#{elapsed}s / #{lock_wait}s)…")
|
|
785
|
+
end
|
|
786
|
+
|
|
787
|
+
progress.stop_spin
|
|
788
|
+
|
|
789
|
+
if repo.locked?
|
|
790
|
+
raise BorgError,
|
|
791
|
+
"Repository '#{repo_name}' is still locked after #{lock_wait}s. " \
|
|
792
|
+
"Run 'ruborg lock --repository #{repo_name}' to inspect, or " \
|
|
793
|
+
"'ruborg lock --repository #{repo_name} --break --yes' to clear."
|
|
794
|
+
end
|
|
795
|
+
|
|
796
|
+
@logger.info("Lock cleared for '#{repo_name}' after #{elapsed}s")
|
|
797
|
+
end
|
|
798
|
+
|
|
799
|
+
def build_repo(repo_path, merged_config, passphrase)
|
|
800
|
+
Repository.new(
|
|
801
|
+
repo_path,
|
|
802
|
+
passphrase: passphrase,
|
|
803
|
+
borg_options: merged_config["borg_options"] || {},
|
|
804
|
+
borg_path: merged_config["borg_path"],
|
|
805
|
+
lock_wait: merged_config["lock_wait"]&.to_i,
|
|
806
|
+
logger: @logger
|
|
807
|
+
)
|
|
664
808
|
end
|
|
665
809
|
|
|
666
810
|
def fetch_passphrase_for_repo(repo_config)
|
data/lib/ruborg/config.rb
CHANGED
|
@@ -41,7 +41,7 @@ module Ruborg
|
|
|
41
41
|
|
|
42
42
|
def global_settings
|
|
43
43
|
@data.slice("passbolt", "compression", "encryption", "auto_init", "borg_options", "log_file", "retention",
|
|
44
|
-
"auto_prune", "hostname", "allow_remove_source", "borg_path", "skip_hash_check")
|
|
44
|
+
"auto_prune", "hostname", "allow_remove_source", "borg_path", "skip_hash_check", "lock_wait")
|
|
45
45
|
end
|
|
46
46
|
|
|
47
47
|
private
|
|
@@ -54,12 +54,12 @@ module Ruborg
|
|
|
54
54
|
# Valid configuration keys at each level
|
|
55
55
|
VALID_GLOBAL_KEYS = %w[
|
|
56
56
|
hostname compression encryption auto_init auto_prune allow_remove_source
|
|
57
|
-
log_file borg_path passbolt borg_options retention repositories skip_hash_check
|
|
57
|
+
log_file borg_path passbolt borg_options retention repositories skip_hash_check lock_wait
|
|
58
58
|
].freeze
|
|
59
59
|
|
|
60
60
|
VALID_REPOSITORY_KEYS = %w[
|
|
61
61
|
name description path hostname retention_mode passbolt retention sources
|
|
62
|
-
compression encryption auto_init auto_prune borg_options allow_remove_source skip_hash_check
|
|
62
|
+
compression encryption auto_init auto_prune borg_options allow_remove_source skip_hash_check lock_wait
|
|
63
63
|
].freeze
|
|
64
64
|
|
|
65
65
|
VALID_SOURCE_KEYS = %w[name paths exclude].freeze
|
|
@@ -0,0 +1,94 @@
|
|
|
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
|
+
@spin_label = nil
|
|
17
|
+
@spin_start = nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Print a numbered stage header: "[2/3] Label"
|
|
21
|
+
def stage(index, total, label)
|
|
22
|
+
stop_spin
|
|
23
|
+
clear_line if @tty
|
|
24
|
+
@output.puts "[#{index}/#{total}] #{label}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Start a spinner on the current line for an indeterminate operation.
|
|
28
|
+
# Appends elapsed time after 3 seconds so long-running steps are visible.
|
|
29
|
+
# Call stop_spin (or done) to halt it.
|
|
30
|
+
def spin(label)
|
|
31
|
+
stop_spin
|
|
32
|
+
return unless @tty
|
|
33
|
+
|
|
34
|
+
@spin_label = label
|
|
35
|
+
@spin_start = Time.now
|
|
36
|
+
frame = 0
|
|
37
|
+
@spinner_thread = Thread.new do
|
|
38
|
+
loop do
|
|
39
|
+
elapsed = (Time.now - @spin_start).to_i
|
|
40
|
+
time_str = elapsed >= 3 ? " (#{elapsed}s)" : ""
|
|
41
|
+
@output.print "\r #{SPINNER_FRAMES[frame % SPINNER_FRAMES.size]} #{@spin_label}#{time_str}"
|
|
42
|
+
frame += 1
|
|
43
|
+
sleep 0.1
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Update the label on a running spinner without restarting it.
|
|
49
|
+
# Safe to call from the main thread while the spinner runs in the background.
|
|
50
|
+
def update_spin(label)
|
|
51
|
+
@spin_label = label
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Stop the spinner and erase its line.
|
|
55
|
+
def stop_spin
|
|
56
|
+
return unless @spinner_thread
|
|
57
|
+
|
|
58
|
+
@spinner_thread.kill
|
|
59
|
+
@spinner_thread.join(0.2)
|
|
60
|
+
@spinner_thread = nil
|
|
61
|
+
clear_line if @tty
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Redraw an inline progress bar. Call once per item in a loop.
|
|
65
|
+
# label is truncated to fit the terminal line.
|
|
66
|
+
def bar(current, total, label = "")
|
|
67
|
+
return unless @tty
|
|
68
|
+
|
|
69
|
+
pct = total.positive? ? (current.to_f / total) : 0
|
|
70
|
+
filled = (BAR_WIDTH * pct).round
|
|
71
|
+
bar_str = filled.positive? ? "#{"=" * (filled - 1)}>" : ""
|
|
72
|
+
bar_str = bar_str.ljust(BAR_WIDTH)
|
|
73
|
+
short_label = truncate_left(label.to_s, 28)
|
|
74
|
+
@output.print "\r [#{bar_str}] #{current}/#{total} #{short_label.ljust(28)}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Halt any in-progress display and print a completion line.
|
|
78
|
+
def done(label = nil)
|
|
79
|
+
stop_spin
|
|
80
|
+
clear_line if @tty
|
|
81
|
+
@output.puts " ✓ #{label}" if label
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def clear_line
|
|
87
|
+
@output.print "\r#{" " * LINE_WIDTH}\r"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def truncate_left(str, max)
|
|
91
|
+
str.length > max ? "...#{str[-(max - 3)..]}" : str
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|