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.
@@ -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
- borg_opts = merged_config["borg_options"] || {}
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
- borg_opts = merged_config["borg_options"] || {}
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
- borg_opts = merged_config["borg_options"] || {}
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
- borg_opts = merged_config["borg_options"] || {}
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
- borg_opts = merged_config["borg_options"] || {}
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 %s", size, units[unit_index])
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
- borg_opts = merged_config["borg_options"] || {}
597
- borg_path = merged_config["borg_path"]
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
- # Auto-initialize if configured
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
- # Get retention mode (defaults to standard)
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
- # Get skip_hash_check setting (defaults to false)
626
- skip_hash_check = merged_config["skip_hash_check"]
627
- skip_hash_check = false unless skip_hash_check == true
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
- # Auto-prune if configured and retention policy exists
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
- puts "Pruning completed"
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