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
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
|
|
@@ -184,8 +174,23 @@ module Ruborg
|
|
|
184
174
|
raise
|
|
185
175
|
end
|
|
186
176
|
|
|
187
|
-
desc "validate", "Validate configuration file
|
|
188
|
-
|
|
177
|
+
desc "validate TYPE", "Validate configuration file or repository (TYPE: config or repo)"
|
|
178
|
+
option :verify_data, type: :boolean, default: false, desc: "Verify repository data (slower, only for 'repo' type)"
|
|
179
|
+
option :all, type: :boolean, default: false, desc: "Validate all repositories (only for 'repo' type)"
|
|
180
|
+
def validate(type)
|
|
181
|
+
case type
|
|
182
|
+
when "config"
|
|
183
|
+
validate_config_implementation
|
|
184
|
+
when "repo"
|
|
185
|
+
validate_repo_implementation
|
|
186
|
+
else
|
|
187
|
+
raise ConfigError, "Invalid validation type: #{type}. Use 'config' or 'repo'"
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
private
|
|
192
|
+
|
|
193
|
+
def validate_config_implementation
|
|
189
194
|
@logger.info("Validating configuration file: #{options[:config]}")
|
|
190
195
|
config = Config.new(options[:config])
|
|
191
196
|
|
|
@@ -201,6 +206,7 @@ module Ruborg
|
|
|
201
206
|
errors.concat(validate_boolean_setting(global_settings, "auto_init", "global"))
|
|
202
207
|
errors.concat(validate_boolean_setting(global_settings, "auto_prune", "global"))
|
|
203
208
|
errors.concat(validate_boolean_setting(global_settings, "allow_remove_source", "global"))
|
|
209
|
+
errors.concat(validate_boolean_setting(global_settings, "skip_hash_check", "global"))
|
|
204
210
|
|
|
205
211
|
# Validate borg_options booleans
|
|
206
212
|
if global_settings["borg_options"]
|
|
@@ -214,6 +220,7 @@ module Ruborg
|
|
|
214
220
|
errors.concat(validate_boolean_setting(repo, "auto_init", repo_name))
|
|
215
221
|
errors.concat(validate_boolean_setting(repo, "auto_prune", repo_name))
|
|
216
222
|
errors.concat(validate_boolean_setting(repo, "allow_remove_source", repo_name))
|
|
223
|
+
errors.concat(validate_boolean_setting(repo, "skip_hash_check", repo_name))
|
|
217
224
|
|
|
218
225
|
if repo["borg_options"]
|
|
219
226
|
warnings.concat(validate_borg_option(repo["borg_options"], "allow_relocated_repo", repo_name))
|
|
@@ -257,11 +264,203 @@ module Ruborg
|
|
|
257
264
|
raise
|
|
258
265
|
end
|
|
259
266
|
|
|
260
|
-
|
|
267
|
+
def validate_repo_implementation
|
|
268
|
+
@logger.info("Validating repository compatibility")
|
|
269
|
+
config = Config.new(options[:config])
|
|
270
|
+
global_settings = config.global_settings
|
|
271
|
+
validate_hostname(global_settings)
|
|
272
|
+
|
|
273
|
+
# Show Borg version first
|
|
274
|
+
borg_version = Repository.borg_version
|
|
275
|
+
puts "\nBorg version: #{borg_version}\n\n"
|
|
276
|
+
|
|
277
|
+
repos_to_validate = if options[:all]
|
|
278
|
+
config.repositories
|
|
279
|
+
elsif options[:repository]
|
|
280
|
+
repo_config = config.get_repository(options[:repository])
|
|
281
|
+
raise ConfigError, "Repository '#{options[:repository]}' not found" unless repo_config
|
|
282
|
+
|
|
283
|
+
[repo_config]
|
|
284
|
+
else
|
|
285
|
+
raise ConfigError, "Please specify --repository or --all"
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
repos_to_validate.each do |repo_config|
|
|
289
|
+
validate_repository(repo_config, global_settings)
|
|
290
|
+
end
|
|
291
|
+
rescue Error => e
|
|
292
|
+
@logger.error("Validation failed: #{e.message}")
|
|
293
|
+
raise
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def validate_repository(repo_config, global_settings)
|
|
297
|
+
repo_name = repo_config["name"]
|
|
298
|
+
puts "--- Validating repository: #{repo_name} ---"
|
|
299
|
+
@logger.info("Validating repository: #{repo_name}")
|
|
300
|
+
|
|
301
|
+
merged_config = global_settings.merge(repo_config)
|
|
302
|
+
validate_hostname(merged_config)
|
|
303
|
+
passphrase = fetch_passphrase_for_repo(merged_config)
|
|
304
|
+
repo = build_repo(repo_config["path"], merged_config, passphrase)
|
|
305
|
+
|
|
306
|
+
unless repo.exists?
|
|
307
|
+
puts " ✗ Repository does not exist at #{repo_config["path"]}"
|
|
308
|
+
@logger.error("Repository does not exist: #{repo_name}")
|
|
309
|
+
puts ""
|
|
310
|
+
return
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Check compatibility
|
|
314
|
+
compatibility = repo.check_compatibility
|
|
315
|
+
puts " Repository version: #{compatibility[:repository_version]}"
|
|
316
|
+
|
|
317
|
+
if compatibility[:compatible]
|
|
318
|
+
puts " ✓ Compatible with Borg #{compatibility[:borg_version]}"
|
|
319
|
+
@logger.info("Repository #{repo_name} is compatible")
|
|
320
|
+
else
|
|
321
|
+
puts " ✗ INCOMPATIBLE with Borg #{compatibility[:borg_version]}"
|
|
322
|
+
repo_ver = compatibility[:repository_version]
|
|
323
|
+
borg_ver = compatibility[:borg_version]
|
|
324
|
+
puts " Repository version #{repo_ver} cannot be read by Borg #{borg_ver}"
|
|
325
|
+
puts " Please upgrade Borg or migrate the repository"
|
|
326
|
+
@logger.error("Repository #{repo_name} is incompatible with installed Borg version")
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# Run integrity check if requested
|
|
330
|
+
if options[:verify_data]
|
|
331
|
+
puts " Running integrity check..."
|
|
332
|
+
@logger.info("Running integrity check on #{repo_name}")
|
|
333
|
+
repo.check
|
|
334
|
+
puts " ✓ Integrity check passed"
|
|
335
|
+
@logger.info("Integrity check passed for #{repo_name}")
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
puts ""
|
|
339
|
+
rescue BorgError => e
|
|
340
|
+
puts " ✗ Validation failed: #{e.message}"
|
|
341
|
+
@logger.error("Validation failed for #{repo_name}: #{e.message}")
|
|
342
|
+
puts ""
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
public
|
|
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
|
+
unless repo.locked?
|
|
399
|
+
puts "No lock found for repository '#{repo_config["name"]}'"
|
|
400
|
+
@logger.info("Lock check: no lock found for '#{repo_config["name"]}'")
|
|
401
|
+
return
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
warn "Lock detected on repository '#{repo_config["name"]}' (#{repo_config["path"]})"
|
|
405
|
+
@logger.warn("Lock detected on repository '#{repo_config["name"]}'")
|
|
406
|
+
|
|
407
|
+
unless options[:break] || options[:force]
|
|
408
|
+
warn " Run with --break --yes (via borg) or --force --yes (direct removal)."
|
|
409
|
+
exit 1
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
unless options[:yes]
|
|
413
|
+
warn " Add --yes to confirm."
|
|
414
|
+
exit 1
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
if options[:force]
|
|
418
|
+
removed = repo.force_break_lock
|
|
419
|
+
puts "Force-removed lock files for '#{repo_config["name"]}': #{removed.join(", ")}"
|
|
420
|
+
@logger.info("Force-removed lock files for '#{repo_config["name"]}'")
|
|
421
|
+
else
|
|
422
|
+
repo.break_lock
|
|
423
|
+
puts "Lock broken for repository '#{repo_config["name"]}'"
|
|
424
|
+
@logger.info("Lock broken for repository '#{repo_config["name"]}'")
|
|
425
|
+
end
|
|
426
|
+
rescue Error => e
|
|
427
|
+
@logger.error("Lock command failed: #{e.message}")
|
|
428
|
+
raise
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
desc "version", "Show ruborg and borg versions"
|
|
261
432
|
def version
|
|
262
433
|
require_relative "version"
|
|
263
434
|
puts "ruborg #{Ruborg::VERSION}"
|
|
264
435
|
@logger.info("Version checked: #{Ruborg::VERSION}")
|
|
436
|
+
|
|
437
|
+
begin
|
|
438
|
+
borg_version = Repository.borg_version
|
|
439
|
+
borg_path = Repository.borg_path
|
|
440
|
+
puts "borg #{borg_version} (#{borg_path})"
|
|
441
|
+
@logger.info("Borg version: #{borg_version}, path: #{borg_path}")
|
|
442
|
+
rescue BorgError => e
|
|
443
|
+
puts "borg: not found or not executable"
|
|
444
|
+
@logger.warn("Could not determine Borg version: #{e.message}")
|
|
445
|
+
end
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
desc "check", "DEPRECATED: Use 'ruborg validate repo' instead"
|
|
449
|
+
option :verify_data, type: :boolean, default: false, desc: "Verify repository data (slower)"
|
|
450
|
+
option :all, type: :boolean, default: false, desc: "Validate all repositories"
|
|
451
|
+
def check
|
|
452
|
+
puts "\n⚠️ DEPRECATED COMMAND"
|
|
453
|
+
puts "══════════════════════════════════════════════════════════════════\n\n"
|
|
454
|
+
puts "The 'ruborg check' command has been renamed for consistency.\n"
|
|
455
|
+
puts "Please use: ruborg validate repo\n\n"
|
|
456
|
+
puts "Examples:"
|
|
457
|
+
puts " ruborg validate repo --repository documents"
|
|
458
|
+
puts " ruborg validate repo --all"
|
|
459
|
+
puts " ruborg validate repo --repository documents --verify-data\n\n"
|
|
460
|
+
puts "══════════════════════════════════════════════════════════════════\n"
|
|
461
|
+
|
|
462
|
+
@logger.warn("Deprecated command 'check' was called. User should use 'validate repo' instead.")
|
|
463
|
+
exit 1
|
|
265
464
|
end
|
|
266
465
|
|
|
267
466
|
desc "metadata ARCHIVE", "Get file metadata from an archive"
|
|
@@ -279,11 +478,7 @@ module Ruborg
|
|
|
279
478
|
merged_config = global_settings.merge(repo_config)
|
|
280
479
|
validate_hostname(merged_config)
|
|
281
480
|
passphrase = fetch_passphrase_for_repo(merged_config)
|
|
282
|
-
|
|
283
|
-
borg_path = merged_config["borg_path"]
|
|
284
|
-
|
|
285
|
-
repo = Repository.new(repo_config["path"], passphrase: passphrase, borg_options: borg_opts, borg_path: borg_path,
|
|
286
|
-
logger: @logger)
|
|
481
|
+
repo = build_repo(repo_config["path"], merged_config, passphrase)
|
|
287
482
|
|
|
288
483
|
raise BorgError, "Repository does not exist at #{repo_config["path"]}" unless repo.exists?
|
|
289
484
|
|
|
@@ -310,93 +505,51 @@ module Ruborg
|
|
|
310
505
|
raise
|
|
311
506
|
end
|
|
312
507
|
|
|
313
|
-
desc "check", "Check repository integrity and compatibility"
|
|
314
|
-
option :verify_data, type: :boolean, default: false, desc: "Verify repository data (slower)"
|
|
315
|
-
option :all, type: :boolean, default: false, desc: "Check all repositories"
|
|
316
|
-
def check
|
|
317
|
-
@logger.info("Checking repository compatibility")
|
|
318
|
-
config = Config.new(options[:config])
|
|
319
|
-
global_settings = config.global_settings
|
|
320
|
-
validate_hostname(global_settings)
|
|
321
|
-
|
|
322
|
-
# Show Borg version first
|
|
323
|
-
borg_version = Repository.borg_version
|
|
324
|
-
puts "\nBorg version: #{borg_version}\n\n"
|
|
325
|
-
|
|
326
|
-
repos_to_check = if options[:all]
|
|
327
|
-
config.repositories
|
|
328
|
-
elsif options[:repository]
|
|
329
|
-
repo_config = config.get_repository(options[:repository])
|
|
330
|
-
raise ConfigError, "Repository '#{options[:repository]}' not found" unless repo_config
|
|
331
|
-
|
|
332
|
-
[repo_config]
|
|
333
|
-
else
|
|
334
|
-
raise ConfigError, "Please specify --repository or --all"
|
|
335
|
-
end
|
|
336
|
-
|
|
337
|
-
repos_to_check.each do |repo_config|
|
|
338
|
-
check_repository(repo_config, global_settings)
|
|
339
|
-
end
|
|
340
|
-
rescue Error => e
|
|
341
|
-
@logger.error("Check failed: #{e.message}")
|
|
342
|
-
raise
|
|
343
|
-
end
|
|
344
|
-
|
|
345
508
|
private
|
|
346
509
|
|
|
347
|
-
def
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
@logger.info("Checking repository: #{repo_name}")
|
|
351
|
-
|
|
352
|
-
merged_config = global_settings.merge(repo_config)
|
|
353
|
-
validate_hostname(merged_config)
|
|
354
|
-
passphrase = fetch_passphrase_for_repo(merged_config)
|
|
355
|
-
borg_opts = merged_config["borg_options"] || {}
|
|
356
|
-
borg_path = merged_config["borg_path"]
|
|
357
|
-
|
|
358
|
-
repo = Repository.new(repo_config["path"], passphrase: passphrase, borg_options: borg_opts, borg_path: borg_path,
|
|
359
|
-
logger: @logger)
|
|
360
|
-
|
|
361
|
-
unless repo.exists?
|
|
362
|
-
puts " ✗ Repository does not exist at #{repo_config["path"]}"
|
|
363
|
-
@logger.error("Repository does not exist: #{repo_name}")
|
|
364
|
-
puts ""
|
|
510
|
+
def print_catalog_entries(entries, as_json)
|
|
511
|
+
if as_json
|
|
512
|
+
puts JSON.generate(entries.map { |e| stringify_entry(e) })
|
|
365
513
|
return
|
|
366
514
|
end
|
|
367
515
|
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
516
|
+
if entries.empty?
|
|
517
|
+
puts "No entries found."
|
|
518
|
+
return
|
|
519
|
+
end
|
|
371
520
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
puts " ✗ INCOMPATIBLE with Borg #{compatibility[:borg_version]}"
|
|
377
|
-
repo_ver = compatibility[:repository_version]
|
|
378
|
-
borg_ver = compatibility[:borg_version]
|
|
379
|
-
puts " Repository version #{repo_ver} cannot be read by Borg #{borg_ver}"
|
|
380
|
-
puts " Please upgrade Borg or migrate the repository"
|
|
381
|
-
@logger.error("Repository #{repo_name} is incompatible with installed Borg version")
|
|
521
|
+
puts "\n#{"FILE PATH".ljust(55)} #{"SIZE".ljust(10)} ARCHIVE"
|
|
522
|
+
puts "-" * 90
|
|
523
|
+
entries.each do |e|
|
|
524
|
+
puts "#{truncate(e[:path].to_s, 55).ljust(55)} #{format_size(e[:size].to_i).ljust(10)} #{e[:archive_name]}"
|
|
382
525
|
end
|
|
526
|
+
puts "\n#{entries.size} entry/entries."
|
|
527
|
+
end
|
|
383
528
|
|
|
384
|
-
|
|
385
|
-
if
|
|
386
|
-
puts
|
|
387
|
-
|
|
388
|
-
repo.check
|
|
389
|
-
puts " ✓ Integrity check passed"
|
|
390
|
-
@logger.info("Integrity check passed for #{repo_name}")
|
|
529
|
+
def print_catalog_stats(stats, as_json)
|
|
530
|
+
if as_json
|
|
531
|
+
puts JSON.generate(stats)
|
|
532
|
+
return
|
|
391
533
|
end
|
|
392
534
|
|
|
393
|
-
puts ""
|
|
394
|
-
|
|
395
|
-
puts "
|
|
396
|
-
|
|
535
|
+
puts "\n═══════════════════════════════════════════════════════════════"
|
|
536
|
+
puts " CATALOG STATISTICS"
|
|
537
|
+
puts "═══════════════════════════════════════════════════════════════\n\n"
|
|
538
|
+
puts " Total archives : #{stats[:total_archives]}"
|
|
539
|
+
puts " Unique files : #{stats[:unique_paths]}"
|
|
540
|
+
puts " Source dirs : #{stats[:source_dirs]}"
|
|
541
|
+
puts " Total size : #{format_size(stats[:total_size])}"
|
|
397
542
|
puts ""
|
|
398
543
|
end
|
|
399
544
|
|
|
545
|
+
def stringify_entry(entry)
|
|
546
|
+
entry.transform_keys(&:to_s)
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
def truncate(str, max)
|
|
550
|
+
str.length > max ? "...#{str[-(max - 3)..]}" : str
|
|
551
|
+
end
|
|
552
|
+
|
|
400
553
|
def show_repositories_summary(config)
|
|
401
554
|
repositories = config.repositories
|
|
402
555
|
global_settings = config.global_settings
|
|
@@ -485,7 +638,7 @@ module Ruborg
|
|
|
485
638
|
unit_index += 1
|
|
486
639
|
end
|
|
487
640
|
|
|
488
|
-
format("%.2f
|
|
641
|
+
"#{format("%.2f", size)} #{units[unit_index]}"
|
|
489
642
|
end
|
|
490
643
|
|
|
491
644
|
def get_passphrase(passphrase, passbolt_id)
|
|
@@ -544,30 +697,31 @@ module Ruborg
|
|
|
544
697
|
puts "\n--- Backing up repository: #{repo_name} ---"
|
|
545
698
|
@logger.info("Backing up repository: #{repo_name}")
|
|
546
699
|
|
|
547
|
-
# Merge global settings with repo-specific settings (repo-specific takes precedence)
|
|
548
700
|
merged_config = global_settings.merge(repo_config)
|
|
549
701
|
validate_hostname(merged_config)
|
|
550
702
|
|
|
703
|
+
retention_mode = merged_config["retention_mode"] || "standard"
|
|
704
|
+
auto_prune = merged_config["auto_prune"] == true
|
|
705
|
+
retention_policy = merged_config["retention"]
|
|
706
|
+
will_prune = auto_prune && retention_policy && !retention_policy.empty?
|
|
707
|
+
stage_total = will_prune ? 3 : 2
|
|
708
|
+
|
|
709
|
+
progress = Progress.new
|
|
710
|
+
progress.stage(1, stage_total, "Verifying repository: #{repo_name}")
|
|
711
|
+
|
|
551
712
|
passphrase = fetch_passphrase_for_repo(merged_config)
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
repo = Repository.new(repo_config["path"], passphrase: passphrase, borg_options: borg_opts, borg_path: borg_path,
|
|
555
|
-
logger: @logger)
|
|
713
|
+
lock_wait = (merged_config["lock_wait"] || DEFAULT_LOCK_WAIT).to_i
|
|
714
|
+
repo = build_repo(repo_config["path"], merged_config, passphrase)
|
|
556
715
|
|
|
557
|
-
|
|
558
|
-
# Use strict boolean checking: only true enables, everything else disables
|
|
559
|
-
auto_init = merged_config["auto_init"]
|
|
560
|
-
auto_init = false unless auto_init == true
|
|
716
|
+
auto_init = merged_config["auto_init"] == true
|
|
561
717
|
if auto_init && !repo.exists?
|
|
562
718
|
@logger.info("Auto-initializing repository at #{repo_config["path"]}")
|
|
563
719
|
repo.create
|
|
564
720
|
puts "Repository auto-initialized at #{repo_config["path"]}"
|
|
565
721
|
end
|
|
566
722
|
|
|
567
|
-
|
|
568
|
-
retention_mode = merged_config["retention_mode"] || "standard"
|
|
723
|
+
wait_for_lock_clear(repo, repo_name, lock_wait, progress)
|
|
569
724
|
|
|
570
|
-
# Validate remove_source permission with strict type checking
|
|
571
725
|
if options[:remove_source]
|
|
572
726
|
allow_remove_source = merged_config["allow_remove_source"]
|
|
573
727
|
unless allow_remove_source.is_a?(TrueClass)
|
|
@@ -578,10 +732,14 @@ module Ruborg
|
|
|
578
732
|
end
|
|
579
733
|
end
|
|
580
734
|
|
|
581
|
-
|
|
735
|
+
skip_hash_check = merged_config["skip_hash_check"] == true
|
|
736
|
+
|
|
737
|
+
backup_label = retention_mode == "per_file" ? "Backing up files (per-file mode)" : "Creating archive"
|
|
738
|
+
progress.stage(2, stage_total, backup_label)
|
|
739
|
+
|
|
582
740
|
backup_config = BackupConfig.new(repo_config, merged_config)
|
|
583
741
|
backup = Backup.new(repo, config: backup_config, retention_mode: retention_mode, repo_name: repo_name,
|
|
584
|
-
logger: @logger)
|
|
742
|
+
logger: @logger, skip_hash_check: skip_hash_check, progress: progress)
|
|
585
743
|
|
|
586
744
|
archive_name = options[:name] ? sanitize_archive_name(options[:name]) : nil
|
|
587
745
|
@logger.info("Creating archive#{"s" if retention_mode == "per_file"}: #{archive_name || "auto-generated"}")
|
|
@@ -592,27 +750,55 @@ module Ruborg
|
|
|
592
750
|
backup.create(name: archive_name, remove_source: options[:remove_source])
|
|
593
751
|
@logger.info("Backup created successfully")
|
|
594
752
|
|
|
595
|
-
if retention_mode == "per_file"
|
|
596
|
-
puts "✓ Per-file backups created"
|
|
597
|
-
else
|
|
598
|
-
puts "✓ Backup created: #{archive_name || "auto-generated"}"
|
|
599
|
-
end
|
|
600
753
|
puts " Sources removed" if options[:remove_source]
|
|
601
754
|
|
|
602
|
-
|
|
603
|
-
# Use strict boolean checking: only true enables, everything else disables
|
|
604
|
-
auto_prune = merged_config["auto_prune"]
|
|
605
|
-
auto_prune = false unless auto_prune == true
|
|
606
|
-
retention_policy = merged_config["retention"]
|
|
607
|
-
|
|
608
|
-
return unless auto_prune && retention_policy && !retention_policy.empty?
|
|
755
|
+
return unless will_prune
|
|
609
756
|
|
|
610
757
|
mode_desc = retention_mode == "per_file" ? "per-file mode" : "standard mode"
|
|
758
|
+
progress.stage(3, stage_total, "Pruning old archives (#{mode_desc})")
|
|
759
|
+
progress.spin("Pruning...")
|
|
611
760
|
@logger.info("Auto-pruning repository: #{repo_name} (#{mode_desc})")
|
|
612
|
-
puts " Pruning old backups (#{mode_desc})..."
|
|
613
761
|
repo.prune(retention_policy, retention_mode: retention_mode)
|
|
614
762
|
@logger.info("Pruning completed successfully for #{repo_name}")
|
|
615
|
-
|
|
763
|
+
progress.done("Pruning completed")
|
|
764
|
+
end
|
|
765
|
+
|
|
766
|
+
def wait_for_lock_clear(repo, repo_name, lock_wait, progress)
|
|
767
|
+
return unless repo.locked?
|
|
768
|
+
|
|
769
|
+
@logger.warn("Repository '#{repo_name}' is locked — waiting up to #{lock_wait}s")
|
|
770
|
+
elapsed = 0
|
|
771
|
+
interval = 5
|
|
772
|
+
|
|
773
|
+
progress.spin("Repository locked — waiting for lock to clear (0s / #{lock_wait}s)…")
|
|
774
|
+
|
|
775
|
+
while repo.locked? && elapsed < lock_wait
|
|
776
|
+
sleep interval
|
|
777
|
+
elapsed += interval
|
|
778
|
+
progress.spin("Repository locked — waiting for lock to clear (#{elapsed}s / #{lock_wait}s)…")
|
|
779
|
+
end
|
|
780
|
+
|
|
781
|
+
progress.stop_spin
|
|
782
|
+
|
|
783
|
+
if repo.locked?
|
|
784
|
+
raise BorgError,
|
|
785
|
+
"Repository '#{repo_name}' is still locked after #{lock_wait}s. " \
|
|
786
|
+
"Run 'ruborg lock --repository #{repo_name}' to inspect, or " \
|
|
787
|
+
"'ruborg lock --repository #{repo_name} --break --yes' to clear."
|
|
788
|
+
end
|
|
789
|
+
|
|
790
|
+
@logger.info("Lock cleared for '#{repo_name}' after #{elapsed}s")
|
|
791
|
+
end
|
|
792
|
+
|
|
793
|
+
def build_repo(repo_path, merged_config, passphrase)
|
|
794
|
+
Repository.new(
|
|
795
|
+
repo_path,
|
|
796
|
+
passphrase: passphrase,
|
|
797
|
+
borg_options: merged_config["borg_options"] || {},
|
|
798
|
+
borg_path: merged_config["borg_path"],
|
|
799
|
+
lock_wait: merged_config["lock_wait"]&.to_i,
|
|
800
|
+
logger: @logger
|
|
801
|
+
)
|
|
616
802
|
end
|
|
617
803
|
|
|
618
804
|
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")
|
|
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
|
|
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
|
|
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
|
|
@@ -122,8 +122,9 @@ module Ruborg
|
|
|
122
122
|
errors.concat(validate_boolean_config(@data, "auto_init", "global"))
|
|
123
123
|
errors.concat(validate_boolean_config(@data, "auto_prune", "global"))
|
|
124
124
|
errors.concat(validate_boolean_config(@data, "allow_remove_source", "global"))
|
|
125
|
+
errors.concat(validate_boolean_config(@data, "skip_hash_check", "global"))
|
|
125
126
|
|
|
126
|
-
#
|
|
127
|
+
# NOTE: borg_options are validated as warnings in CLI validate command, not as errors here
|
|
127
128
|
|
|
128
129
|
# Validate global passbolt
|
|
129
130
|
errors.concat(validate_passbolt_config(@data["passbolt"], "global")) if @data["passbolt"]
|
|
@@ -151,6 +152,7 @@ module Ruborg
|
|
|
151
152
|
errors.concat(validate_boolean_config(repo, "auto_init", repo_name))
|
|
152
153
|
errors.concat(validate_boolean_config(repo, "auto_prune", repo_name))
|
|
153
154
|
errors.concat(validate_boolean_config(repo, "allow_remove_source", repo_name))
|
|
155
|
+
errors.concat(validate_boolean_config(repo, "skip_hash_check", repo_name))
|
|
154
156
|
|
|
155
157
|
# Validate retention_mode
|
|
156
158
|
if repo["retention_mode"] && !VALID_RETENTION_MODES.include?(repo["retention_mode"])
|
|
@@ -158,7 +160,7 @@ module Ruborg
|
|
|
158
160
|
"Must be one of: #{VALID_RETENTION_MODES.join(", ")}"
|
|
159
161
|
end
|
|
160
162
|
|
|
161
|
-
#
|
|
163
|
+
# NOTE: borg_options are validated as warnings in CLI validate command, not as errors here
|
|
162
164
|
|
|
163
165
|
errors.concat(validate_passbolt_config(repo["passbolt"], repo_name)) if repo["passbolt"]
|
|
164
166
|
|