ruborg 0.3.1 → 0.4.0
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/.rubocop.yml +175 -0
- data/CHANGELOG.md +26 -0
- data/CLAUDE.md +67 -1
- data/README.md +337 -80
- data/Rakefile +1 -1
- data/SECURITY.md +41 -2
- data/exe/ruborg +1 -1
- data/lib/ruborg/backup.rb +97 -12
- data/lib/ruborg/cli.rb +257 -78
- data/lib/ruborg/config.rb +18 -61
- data/lib/ruborg/logger.rb +4 -5
- data/lib/ruborg/passbolt.rb +5 -5
- data/lib/ruborg/repository.rb +275 -7
- data/lib/ruborg/version.rb +2 -2
- data/lib/ruborg.rb +1 -1
- data/ruborg.yml.example +136 -22
- metadata +41 -12
data/lib/ruborg/backup.rb
CHANGED
|
@@ -3,14 +3,26 @@
|
|
|
3
3
|
module Ruborg
|
|
4
4
|
# Backup operations using Borg
|
|
5
5
|
class Backup
|
|
6
|
-
def initialize(repository, config:)
|
|
6
|
+
def initialize(repository, config:, retention_mode: "standard", repo_name: nil)
|
|
7
7
|
@repository = repository
|
|
8
8
|
@config = config
|
|
9
|
+
@retention_mode = retention_mode
|
|
10
|
+
@repo_name = repo_name
|
|
9
11
|
end
|
|
10
12
|
|
|
11
13
|
def create(name: nil, remove_source: false)
|
|
12
14
|
raise BorgError, "Repository does not exist" unless @repository.exists?
|
|
13
15
|
|
|
16
|
+
if @retention_mode == "per_file"
|
|
17
|
+
create_per_file_archives(name, remove_source)
|
|
18
|
+
else
|
|
19
|
+
create_standard_archive(name, remove_source)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def create_standard_archive(name, remove_source)
|
|
14
26
|
archive_name = name || Time.now.strftime("%Y-%m-%d_%H-%M-%S")
|
|
15
27
|
cmd = build_create_command(archive_name)
|
|
16
28
|
|
|
@@ -19,14 +31,90 @@ module Ruborg
|
|
|
19
31
|
remove_source_files if remove_source
|
|
20
32
|
end
|
|
21
33
|
|
|
34
|
+
def create_per_file_archives(name_prefix, remove_source)
|
|
35
|
+
# Collect all files from backup paths
|
|
36
|
+
files_to_backup = collect_files_from_paths(@config.backup_paths, @config.exclude_patterns)
|
|
37
|
+
|
|
38
|
+
raise BorgError, "No files found to backup" if files_to_backup.empty?
|
|
39
|
+
|
|
40
|
+
timestamp = Time.now.strftime("%Y-%m-%d_%H-%M-%S")
|
|
41
|
+
|
|
42
|
+
files_to_backup.each do |file_path|
|
|
43
|
+
# Generate hash-based archive name
|
|
44
|
+
path_hash = generate_path_hash(file_path)
|
|
45
|
+
archive_name = name_prefix || "#{@repo_name}-#{path_hash}-#{timestamp}"
|
|
46
|
+
|
|
47
|
+
# Create archive for single file with original path as comment
|
|
48
|
+
cmd = build_per_file_create_command(archive_name, file_path)
|
|
49
|
+
|
|
50
|
+
execute_borg_command(cmd)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# NOTE: remove_source handled per file after successful backup
|
|
54
|
+
remove_source_files if remove_source
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def collect_files_from_paths(paths, exclude_patterns)
|
|
58
|
+
require "find"
|
|
59
|
+
files = []
|
|
60
|
+
|
|
61
|
+
paths.each do |base_path|
|
|
62
|
+
base_path = File.expand_path(base_path)
|
|
63
|
+
|
|
64
|
+
if File.file?(base_path)
|
|
65
|
+
files << base_path unless excluded?(base_path, exclude_patterns)
|
|
66
|
+
elsif File.directory?(base_path)
|
|
67
|
+
Find.find(base_path) do |path|
|
|
68
|
+
next unless File.file?(path)
|
|
69
|
+
next if excluded?(path, exclude_patterns)
|
|
70
|
+
|
|
71
|
+
files << path
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
files
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def excluded?(path, patterns)
|
|
80
|
+
patterns.any? do |pattern|
|
|
81
|
+
# Try matching against full path and just the filename
|
|
82
|
+
File.fnmatch?(pattern, path, File::FNM_PATHNAME | File::FNM_DOTMATCH) ||
|
|
83
|
+
File.fnmatch?(pattern, File.basename(path), File::FNM_DOTMATCH)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def generate_path_hash(file_path)
|
|
88
|
+
require "digest"
|
|
89
|
+
# Use SHA256 and take first 12 characters for uniqueness
|
|
90
|
+
Digest::SHA256.hexdigest(file_path)[0...12]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def build_per_file_create_command(archive_name, file_path)
|
|
94
|
+
cmd = [@repository.borg_path, "create"]
|
|
95
|
+
cmd += ["--compression", @config.compression]
|
|
96
|
+
|
|
97
|
+
# Store original path in archive comment for retrieval
|
|
98
|
+
cmd += ["--comment", file_path]
|
|
99
|
+
|
|
100
|
+
cmd << "#{@repository.path}::#{archive_name}"
|
|
101
|
+
cmd << file_path
|
|
102
|
+
|
|
103
|
+
cmd
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
public
|
|
107
|
+
|
|
22
108
|
def extract(archive_name, destination: ".", path: nil)
|
|
23
109
|
raise BorgError, "Repository does not exist" unless @repository.exists?
|
|
24
110
|
|
|
25
|
-
cmd = [
|
|
111
|
+
cmd = [@repository.borg_path, "extract", "#{@repository.path}::#{archive_name}"]
|
|
26
112
|
cmd << path if path
|
|
27
113
|
|
|
28
114
|
# Change to destination directory if specified
|
|
29
|
-
if destination
|
|
115
|
+
if destination == "."
|
|
116
|
+
execute_borg_command(cmd)
|
|
117
|
+
else
|
|
30
118
|
require "fileutils"
|
|
31
119
|
|
|
32
120
|
# Validate and normalize destination path
|
|
@@ -36,8 +124,6 @@ module Ruborg
|
|
|
36
124
|
Dir.chdir(validated_dest) do
|
|
37
125
|
execute_borg_command(cmd)
|
|
38
126
|
end
|
|
39
|
-
else
|
|
40
|
-
execute_borg_command(cmd)
|
|
41
127
|
end
|
|
42
128
|
end
|
|
43
129
|
|
|
@@ -46,14 +132,14 @@ module Ruborg
|
|
|
46
132
|
end
|
|
47
133
|
|
|
48
134
|
def delete(archive_name)
|
|
49
|
-
cmd = [
|
|
135
|
+
cmd = [@repository.borg_path, "delete", "#{@repository.path}::#{archive_name}"]
|
|
50
136
|
execute_borg_command(cmd)
|
|
51
137
|
end
|
|
52
138
|
|
|
53
139
|
private
|
|
54
140
|
|
|
55
141
|
def build_create_command(archive_name)
|
|
56
|
-
cmd = [
|
|
142
|
+
cmd = [@repository.borg_path, "create"]
|
|
57
143
|
cmd += ["--compression", @config.compression]
|
|
58
144
|
|
|
59
145
|
@config.exclude_patterns.each do |pattern|
|
|
@@ -77,7 +163,7 @@ module Ruborg
|
|
|
77
163
|
env["BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK"] = "yes"
|
|
78
164
|
|
|
79
165
|
result = system(env, *cmd, in: "/dev/null")
|
|
80
|
-
raise BorgError, "Borg command failed: #{cmd.join(
|
|
166
|
+
raise BorgError, "Borg command failed: #{cmd.join(" ")}" unless result
|
|
81
167
|
|
|
82
168
|
result
|
|
83
169
|
end
|
|
@@ -95,9 +181,7 @@ module Ruborg
|
|
|
95
181
|
end
|
|
96
182
|
|
|
97
183
|
# Security check: ensure path hasn't been tampered with
|
|
98
|
-
unless File.exist?(real_path)
|
|
99
|
-
next
|
|
100
|
-
end
|
|
184
|
+
next unless File.exist?(real_path)
|
|
101
185
|
|
|
102
186
|
# Additional safety: don't delete root or system directories
|
|
103
187
|
if real_path == "/" || real_path.start_with?("/bin", "/sbin", "/usr", "/etc", "/sys", "/proc")
|
|
@@ -132,8 +216,9 @@ module Ruborg
|
|
|
132
216
|
|
|
133
217
|
paths.map do |path|
|
|
134
218
|
raise BorgError, "Empty backup path specified" if path.nil? || path.to_s.strip.empty?
|
|
219
|
+
|
|
135
220
|
File.expand_path(path)
|
|
136
221
|
end
|
|
137
222
|
end
|
|
138
223
|
end
|
|
139
|
-
end
|
|
224
|
+
end
|
data/lib/ruborg/cli.rb
CHANGED
|
@@ -17,7 +17,11 @@ module Ruborg
|
|
|
17
17
|
# Try to load config to get log_file setting
|
|
18
18
|
config_path = options[:config] || "ruborg.yml"
|
|
19
19
|
if File.exist?(config_path)
|
|
20
|
-
config_data =
|
|
20
|
+
config_data = begin
|
|
21
|
+
YAML.safe_load_file(config_path, permitted_classes: [Symbol], aliases: true)
|
|
22
|
+
rescue StandardError
|
|
23
|
+
{}
|
|
24
|
+
end
|
|
21
25
|
log_path = config_data["log_file"]
|
|
22
26
|
end
|
|
23
27
|
end
|
|
@@ -46,16 +50,11 @@ module Ruborg
|
|
|
46
50
|
desc "backup", "Create a backup using configuration file"
|
|
47
51
|
option :name, type: :string, desc: "Archive name"
|
|
48
52
|
option :remove_source, type: :boolean, default: false, desc: "Remove source files after successful backup"
|
|
49
|
-
option :all, type: :boolean, default: false, desc: "Backup all repositories
|
|
53
|
+
option :all, type: :boolean, default: false, desc: "Backup all repositories"
|
|
50
54
|
def backup
|
|
51
55
|
@logger.info("Starting backup operation with config: #{options[:config]}")
|
|
52
56
|
config = Config.new(options[:config])
|
|
53
|
-
|
|
54
|
-
if config.multi_repo?
|
|
55
|
-
backup_multi_repo(config)
|
|
56
|
-
else
|
|
57
|
-
backup_single_repo(config)
|
|
58
|
-
end
|
|
57
|
+
backup_repositories(config)
|
|
59
58
|
rescue Error => e
|
|
60
59
|
@logger.error("Backup failed: #{e.message}")
|
|
61
60
|
error_exit(e)
|
|
@@ -65,15 +64,26 @@ module Ruborg
|
|
|
65
64
|
def list
|
|
66
65
|
@logger.info("Listing archives in repository")
|
|
67
66
|
config = Config.new(options[:config])
|
|
68
|
-
passphrase = fetch_passphrase_from_config(config)
|
|
69
67
|
|
|
70
|
-
|
|
68
|
+
raise ConfigError, "Please specify --repository" unless options[:repository]
|
|
69
|
+
|
|
70
|
+
repo_config = config.get_repository(options[:repository])
|
|
71
|
+
raise ConfigError, "Repository '#{options[:repository]}' not found" unless repo_config
|
|
72
|
+
|
|
73
|
+
global_settings = config.global_settings
|
|
74
|
+
merged_config = global_settings.merge(repo_config)
|
|
75
|
+
passphrase = fetch_passphrase_for_repo(merged_config)
|
|
76
|
+
borg_opts = merged_config["borg_options"] || {}
|
|
77
|
+
borg_path = merged_config["borg_path"]
|
|
78
|
+
|
|
79
|
+
repo = Repository.new(repo_config["path"], passphrase: passphrase, borg_options: borg_opts, borg_path: borg_path)
|
|
71
80
|
|
|
72
81
|
# Auto-initialize repository if configured
|
|
73
|
-
|
|
74
|
-
|
|
82
|
+
auto_init = merged_config["auto_init"] || false
|
|
83
|
+
if auto_init && !repo.exists?
|
|
84
|
+
@logger.info("Auto-initializing repository at #{repo_config["path"]}")
|
|
75
85
|
repo.create
|
|
76
|
-
puts "Repository auto-initialized at #{
|
|
86
|
+
puts "Repository auto-initialized at #{repo_config["path"]}"
|
|
77
87
|
end
|
|
78
88
|
|
|
79
89
|
repo.list
|
|
@@ -90,10 +100,23 @@ module Ruborg
|
|
|
90
100
|
restore_target = options[:path] ? "#{options[:path]} from #{archive_name}" : archive_name
|
|
91
101
|
@logger.info("Restoring #{restore_target} to #{options[:destination]}")
|
|
92
102
|
config = Config.new(options[:config])
|
|
93
|
-
passphrase = fetch_passphrase_from_config(config)
|
|
94
103
|
|
|
95
|
-
|
|
96
|
-
|
|
104
|
+
raise ConfigError, "Please specify --repository" unless options[:repository]
|
|
105
|
+
|
|
106
|
+
repo_config = config.get_repository(options[:repository])
|
|
107
|
+
raise ConfigError, "Repository '#{options[:repository]}' not found" unless repo_config
|
|
108
|
+
|
|
109
|
+
global_settings = config.global_settings
|
|
110
|
+
merged_config = global_settings.merge(repo_config)
|
|
111
|
+
passphrase = fetch_passphrase_for_repo(merged_config)
|
|
112
|
+
borg_opts = merged_config["borg_options"] || {}
|
|
113
|
+
borg_path = merged_config["borg_path"]
|
|
114
|
+
|
|
115
|
+
repo = Repository.new(repo_config["path"], passphrase: passphrase, borg_options: borg_opts, borg_path: borg_path)
|
|
116
|
+
|
|
117
|
+
# Create backup config wrapper for compatibility
|
|
118
|
+
backup_config = BackupConfig.new(repo_config, merged_config)
|
|
119
|
+
backup = Backup.new(repo, config: backup_config)
|
|
97
120
|
|
|
98
121
|
backup.extract(archive_name, destination: options[:destination], path: options[:path])
|
|
99
122
|
@logger.info("Successfully restored #{restore_target} to #{options[:destination]}")
|
|
@@ -112,15 +135,30 @@ module Ruborg
|
|
|
112
135
|
def info
|
|
113
136
|
@logger.info("Retrieving repository information")
|
|
114
137
|
config = Config.new(options[:config])
|
|
115
|
-
passphrase = fetch_passphrase_from_config(config)
|
|
116
138
|
|
|
117
|
-
|
|
139
|
+
# If no repository specified, show summary of all repositories
|
|
140
|
+
unless options[:repository]
|
|
141
|
+
show_repositories_summary(config)
|
|
142
|
+
return
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
repo_config = config.get_repository(options[:repository])
|
|
146
|
+
raise ConfigError, "Repository '#{options[:repository]}' not found" unless repo_config
|
|
147
|
+
|
|
148
|
+
global_settings = config.global_settings
|
|
149
|
+
merged_config = global_settings.merge(repo_config)
|
|
150
|
+
passphrase = fetch_passphrase_for_repo(merged_config)
|
|
151
|
+
borg_opts = merged_config["borg_options"] || {}
|
|
152
|
+
borg_path = merged_config["borg_path"]
|
|
153
|
+
|
|
154
|
+
repo = Repository.new(repo_config["path"], passphrase: passphrase, borg_options: borg_opts, borg_path: borg_path)
|
|
118
155
|
|
|
119
156
|
# Auto-initialize repository if configured
|
|
120
|
-
|
|
121
|
-
|
|
157
|
+
auto_init = merged_config["auto_init"] || false
|
|
158
|
+
if auto_init && !repo.exists?
|
|
159
|
+
@logger.info("Auto-initializing repository at #{repo_config["path"]}")
|
|
122
160
|
repo.create
|
|
123
|
-
puts "Repository auto-initialized at #{
|
|
161
|
+
puts "Repository auto-initialized at #{repo_config["path"]}"
|
|
124
162
|
end
|
|
125
163
|
|
|
126
164
|
repo.info
|
|
@@ -130,8 +168,164 @@ module Ruborg
|
|
|
130
168
|
error_exit(e)
|
|
131
169
|
end
|
|
132
170
|
|
|
171
|
+
desc "check", "Check repository integrity and compatibility"
|
|
172
|
+
option :verify_data, type: :boolean, default: false, desc: "Verify repository data (slower)"
|
|
173
|
+
option :all, type: :boolean, default: false, desc: "Check all repositories"
|
|
174
|
+
def check
|
|
175
|
+
@logger.info("Checking repository compatibility")
|
|
176
|
+
config = Config.new(options[:config])
|
|
177
|
+
global_settings = config.global_settings
|
|
178
|
+
|
|
179
|
+
# Show Borg version first
|
|
180
|
+
borg_version = Repository.borg_version
|
|
181
|
+
puts "\nBorg version: #{borg_version}\n\n"
|
|
182
|
+
|
|
183
|
+
repos_to_check = if options[:all]
|
|
184
|
+
config.repositories
|
|
185
|
+
elsif options[:repository]
|
|
186
|
+
repo_config = config.get_repository(options[:repository])
|
|
187
|
+
raise ConfigError, "Repository '#{options[:repository]}' not found" unless repo_config
|
|
188
|
+
|
|
189
|
+
[repo_config]
|
|
190
|
+
else
|
|
191
|
+
raise ConfigError, "Please specify --repository or --all"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
repos_to_check.each do |repo_config|
|
|
195
|
+
check_repository(repo_config, global_settings)
|
|
196
|
+
end
|
|
197
|
+
rescue Error => e
|
|
198
|
+
@logger.error("Check failed: #{e.message}")
|
|
199
|
+
error_exit(e)
|
|
200
|
+
end
|
|
201
|
+
|
|
133
202
|
private
|
|
134
203
|
|
|
204
|
+
def check_repository(repo_config, global_settings)
|
|
205
|
+
repo_name = repo_config["name"]
|
|
206
|
+
puts "--- Checking repository: #{repo_name} ---"
|
|
207
|
+
@logger.info("Checking repository: #{repo_name}")
|
|
208
|
+
|
|
209
|
+
merged_config = global_settings.merge(repo_config)
|
|
210
|
+
passphrase = fetch_passphrase_for_repo(merged_config)
|
|
211
|
+
borg_opts = merged_config["borg_options"] || {}
|
|
212
|
+
borg_path = merged_config["borg_path"]
|
|
213
|
+
|
|
214
|
+
repo = Repository.new(repo_config["path"], passphrase: passphrase, borg_options: borg_opts, borg_path: borg_path)
|
|
215
|
+
|
|
216
|
+
unless repo.exists?
|
|
217
|
+
puts " ✗ Repository does not exist at #{repo_config["path"]}"
|
|
218
|
+
@logger.error("Repository does not exist: #{repo_name}")
|
|
219
|
+
puts ""
|
|
220
|
+
return
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Check compatibility
|
|
224
|
+
compatibility = repo.check_compatibility
|
|
225
|
+
puts " Repository version: #{compatibility[:repository_version]}"
|
|
226
|
+
|
|
227
|
+
if compatibility[:compatible]
|
|
228
|
+
puts " ✓ Compatible with Borg #{compatibility[:borg_version]}"
|
|
229
|
+
@logger.info("Repository #{repo_name} is compatible")
|
|
230
|
+
else
|
|
231
|
+
puts " ✗ INCOMPATIBLE with Borg #{compatibility[:borg_version]}"
|
|
232
|
+
repo_ver = compatibility[:repository_version]
|
|
233
|
+
borg_ver = compatibility[:borg_version]
|
|
234
|
+
puts " Repository version #{repo_ver} cannot be read by Borg #{borg_ver}"
|
|
235
|
+
puts " Please upgrade Borg or migrate the repository"
|
|
236
|
+
@logger.error("Repository #{repo_name} is incompatible with installed Borg version")
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Run integrity check if requested
|
|
240
|
+
if options[:verify_data]
|
|
241
|
+
puts " Running integrity check..."
|
|
242
|
+
@logger.info("Running integrity check on #{repo_name}")
|
|
243
|
+
repo.check
|
|
244
|
+
puts " ✓ Integrity check passed"
|
|
245
|
+
@logger.info("Integrity check passed for #{repo_name}")
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
puts ""
|
|
249
|
+
rescue BorgError => e
|
|
250
|
+
puts " ✗ Check failed: #{e.message}"
|
|
251
|
+
@logger.error("Check failed for #{repo_name}: #{e.message}")
|
|
252
|
+
puts ""
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def show_repositories_summary(config)
|
|
256
|
+
repositories = config.repositories
|
|
257
|
+
global_settings = config.global_settings
|
|
258
|
+
|
|
259
|
+
if repositories.empty?
|
|
260
|
+
puts "No repositories configured."
|
|
261
|
+
return
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
puts "\n═══════════════════════════════════════════════════════════════"
|
|
265
|
+
puts " RUBORG REPOSITORIES SUMMARY"
|
|
266
|
+
puts "═══════════════════════════════════════════════════════════════\n\n"
|
|
267
|
+
|
|
268
|
+
# Show global settings
|
|
269
|
+
puts "Global Settings:"
|
|
270
|
+
puts " Compression: #{global_settings["compression"] || "lz4 (default)"}"
|
|
271
|
+
puts " Encryption: #{global_settings["encryption"] || "repokey (default)"}"
|
|
272
|
+
puts " Auto-init: #{global_settings["auto_init"] || false}"
|
|
273
|
+
puts " Retention: #{format_retention(global_settings["retention"])}" if global_settings["retention"]
|
|
274
|
+
puts ""
|
|
275
|
+
|
|
276
|
+
puts "Configured Repositories (#{repositories.size}):"
|
|
277
|
+
puts "─────────────────────────────────────────────────────────────────\n\n"
|
|
278
|
+
|
|
279
|
+
repositories.each_with_index do |repo, index|
|
|
280
|
+
merged_config = global_settings.merge(repo)
|
|
281
|
+
|
|
282
|
+
puts "#{index + 1}. #{repo["name"]}"
|
|
283
|
+
puts " Path: #{repo["path"]}"
|
|
284
|
+
puts " Description: #{repo["description"]}" if repo["description"]
|
|
285
|
+
|
|
286
|
+
# Show repo-specific overrides
|
|
287
|
+
puts " Compression: #{repo["compression"]}" if repo["compression"]
|
|
288
|
+
puts " Encryption: #{repo["encryption"]}" if repo["encryption"]
|
|
289
|
+
puts " Auto-init: #{repo["auto_init"]}" unless repo["auto_init"].nil?
|
|
290
|
+
if repo["retention"]
|
|
291
|
+
puts " Retention: #{format_retention(repo["retention"])}"
|
|
292
|
+
elsif merged_config["retention"]
|
|
293
|
+
puts " Retention: #{format_retention(merged_config["retention"])} (global)"
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# Show sources
|
|
297
|
+
sources = repo["sources"] || []
|
|
298
|
+
puts " Sources (#{sources.size}):"
|
|
299
|
+
sources.each do |source|
|
|
300
|
+
paths = source["paths"] || []
|
|
301
|
+
puts " - #{source["name"]}: #{paths.size} path(s)"
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
puts ""
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
puts "─────────────────────────────────────────────────────────────────"
|
|
308
|
+
puts "Use 'ruborg info --repository NAME' for detailed information\n\n"
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def format_retention(retention)
|
|
312
|
+
return "none" if retention.nil? || retention.empty?
|
|
313
|
+
|
|
314
|
+
parts = []
|
|
315
|
+
# Count-based retention
|
|
316
|
+
parts << "#{retention["keep_hourly"]}h" if retention["keep_hourly"]
|
|
317
|
+
parts << "#{retention["keep_daily"]}d" if retention["keep_daily"]
|
|
318
|
+
parts << "#{retention["keep_weekly"]}w" if retention["keep_weekly"]
|
|
319
|
+
parts << "#{retention["keep_monthly"]}m" if retention["keep_monthly"]
|
|
320
|
+
parts << "#{retention["keep_yearly"]}y" if retention["keep_yearly"]
|
|
321
|
+
|
|
322
|
+
# Time-based retention
|
|
323
|
+
parts << "within #{retention["keep_within"]}" if retention["keep_within"]
|
|
324
|
+
parts << "last #{retention["keep_last"]}" if retention["keep_last"]
|
|
325
|
+
|
|
326
|
+
parts.empty? ? "none" : parts.join(", ")
|
|
327
|
+
end
|
|
328
|
+
|
|
135
329
|
def get_passphrase(passphrase, passbolt_id)
|
|
136
330
|
return passphrase if passphrase
|
|
137
331
|
return Passbolt.new(resource_id: passbolt_id).get_password if passbolt_id
|
|
@@ -139,13 +333,6 @@ module Ruborg
|
|
|
139
333
|
nil
|
|
140
334
|
end
|
|
141
335
|
|
|
142
|
-
def fetch_passphrase_from_config(config)
|
|
143
|
-
passbolt_config = config.passbolt_integration
|
|
144
|
-
return nil if passbolt_config.empty?
|
|
145
|
-
|
|
146
|
-
Passbolt.new(resource_id: passbolt_config["resource_id"]).get_password
|
|
147
|
-
end
|
|
148
|
-
|
|
149
336
|
def error_exit(error)
|
|
150
337
|
puts "Error: #{error.message}"
|
|
151
338
|
exit 1
|
|
@@ -168,7 +355,7 @@ module Ruborg
|
|
|
168
355
|
unless File.directory?(log_dir)
|
|
169
356
|
begin
|
|
170
357
|
FileUtils.mkdir_p(log_dir)
|
|
171
|
-
rescue => e
|
|
358
|
+
rescue StandardError => e
|
|
172
359
|
raise ConfigError, "Cannot create log directory #{log_dir}: #{e.message}"
|
|
173
360
|
end
|
|
174
361
|
end
|
|
@@ -176,46 +363,18 @@ module Ruborg
|
|
|
176
363
|
normalized_path
|
|
177
364
|
end
|
|
178
365
|
|
|
179
|
-
#
|
|
180
|
-
def
|
|
181
|
-
@logger.info("Backing up paths: #{config.backup_paths.join(', ')}")
|
|
182
|
-
passphrase = fetch_passphrase_from_config(config)
|
|
183
|
-
|
|
184
|
-
repo = Repository.new(config.repository, passphrase: passphrase, borg_options: config.borg_options)
|
|
185
|
-
|
|
186
|
-
# Auto-initialize repository if configured
|
|
187
|
-
if config.auto_init? && !repo.exists?
|
|
188
|
-
@logger.info("Auto-initializing repository at #{config.repository}")
|
|
189
|
-
repo.create
|
|
190
|
-
puts "Repository auto-initialized at #{config.repository}"
|
|
191
|
-
end
|
|
192
|
-
|
|
193
|
-
backup = Backup.new(repo, config: config)
|
|
194
|
-
|
|
195
|
-
archive_name = options[:name] ? sanitize_archive_name(options[:name]) : Time.now.strftime("%Y-%m-%d_%H-%M-%S")
|
|
196
|
-
@logger.info("Creating archive: #{archive_name}")
|
|
197
|
-
backup.create(name: archive_name, remove_source: options[:remove_source])
|
|
198
|
-
@logger.info("Backup created successfully: #{archive_name}")
|
|
199
|
-
|
|
200
|
-
if options[:remove_source]
|
|
201
|
-
@logger.info("Removed source files: #{config.backup_paths.join(', ')}")
|
|
202
|
-
end
|
|
203
|
-
|
|
204
|
-
puts "Backup created successfully"
|
|
205
|
-
puts "Source files removed" if options[:remove_source]
|
|
206
|
-
end
|
|
207
|
-
|
|
208
|
-
# Multi-repository backup
|
|
209
|
-
def backup_multi_repo(config)
|
|
366
|
+
# Backup repositories based on options
|
|
367
|
+
def backup_repositories(config)
|
|
210
368
|
global_settings = config.global_settings
|
|
211
369
|
repos_to_backup = if options[:all]
|
|
212
370
|
config.repositories
|
|
213
371
|
elsif options[:repository]
|
|
214
372
|
repo_config = config.get_repository(options[:repository])
|
|
215
373
|
raise ConfigError, "Repository '#{options[:repository]}' not found" unless repo_config
|
|
374
|
+
|
|
216
375
|
[repo_config]
|
|
217
376
|
else
|
|
218
|
-
raise ConfigError, "Please specify --repository or --all
|
|
377
|
+
raise ConfigError, "Please specify --repository or --all"
|
|
219
378
|
end
|
|
220
379
|
|
|
221
380
|
repos_to_backup.each do |repo_config|
|
|
@@ -233,31 +392,52 @@ module Ruborg
|
|
|
233
392
|
|
|
234
393
|
passphrase = fetch_passphrase_for_repo(merged_config)
|
|
235
394
|
borg_opts = merged_config["borg_options"] || {}
|
|
236
|
-
|
|
395
|
+
borg_path = merged_config["borg_path"]
|
|
396
|
+
repo = Repository.new(repo_config["path"], passphrase: passphrase, borg_options: borg_opts, borg_path: borg_path)
|
|
237
397
|
|
|
238
398
|
# Auto-initialize if configured
|
|
239
399
|
auto_init = merged_config["auto_init"] || false
|
|
240
400
|
if auto_init && !repo.exists?
|
|
241
|
-
@logger.info("Auto-initializing repository at #{repo_config[
|
|
401
|
+
@logger.info("Auto-initializing repository at #{repo_config["path"]}")
|
|
242
402
|
repo.create
|
|
243
|
-
puts "Repository auto-initialized at #{repo_config[
|
|
403
|
+
puts "Repository auto-initialized at #{repo_config["path"]}"
|
|
244
404
|
end
|
|
245
405
|
|
|
406
|
+
# Get retention mode (defaults to standard)
|
|
407
|
+
retention_mode = merged_config["retention_mode"] || "standard"
|
|
408
|
+
|
|
246
409
|
# Create backup config wrapper
|
|
247
410
|
backup_config = BackupConfig.new(repo_config, merged_config)
|
|
248
|
-
backup = Backup.new(repo, config: backup_config)
|
|
411
|
+
backup = Backup.new(repo, config: backup_config, retention_mode: retention_mode, repo_name: repo_name)
|
|
249
412
|
|
|
250
|
-
archive_name = options[:name] ? sanitize_archive_name(options[:name]) :
|
|
251
|
-
@logger.info("Creating archive: #{archive_name}")
|
|
413
|
+
archive_name = options[:name] ? sanitize_archive_name(options[:name]) : nil
|
|
414
|
+
@logger.info("Creating archive#{"s" if retention_mode == "per_file"}: #{archive_name || "auto-generated"}")
|
|
252
415
|
|
|
253
416
|
sources = repo_config["sources"] || []
|
|
254
|
-
@logger.info("Backing up #{sources.size} source(s)")
|
|
417
|
+
@logger.info("Backing up #{sources.size} source(s)#{" in per-file mode" if retention_mode == "per_file"}")
|
|
255
418
|
|
|
256
419
|
backup.create(name: archive_name, remove_source: options[:remove_source])
|
|
257
|
-
@logger.info("Backup created successfully
|
|
420
|
+
@logger.info("Backup created successfully")
|
|
258
421
|
|
|
259
|
-
|
|
422
|
+
if retention_mode == "per_file"
|
|
423
|
+
puts "✓ Per-file backups created"
|
|
424
|
+
else
|
|
425
|
+
puts "✓ Backup created: #{archive_name || "auto-generated"}"
|
|
426
|
+
end
|
|
260
427
|
puts " Sources removed" if options[:remove_source]
|
|
428
|
+
|
|
429
|
+
# Auto-prune if configured and retention policy exists
|
|
430
|
+
auto_prune = merged_config["auto_prune"] || false
|
|
431
|
+
retention_policy = merged_config["retention"]
|
|
432
|
+
|
|
433
|
+
return unless auto_prune && retention_policy && !retention_policy.empty?
|
|
434
|
+
|
|
435
|
+
mode_desc = retention_mode == "per_file" ? "per-file mode" : "standard mode"
|
|
436
|
+
@logger.info("Auto-pruning repository: #{repo_name} (#{mode_desc})")
|
|
437
|
+
puts " Pruning old backups (#{mode_desc})..."
|
|
438
|
+
repo.prune(retention_policy, retention_mode: retention_mode)
|
|
439
|
+
@logger.info("Pruning completed successfully for #{repo_name}")
|
|
440
|
+
puts " ✓ Pruning completed"
|
|
261
441
|
end
|
|
262
442
|
|
|
263
443
|
def fetch_passphrase_for_repo(repo_config)
|
|
@@ -272,16 +452,15 @@ module Ruborg
|
|
|
272
452
|
|
|
273
453
|
# Check if name contains at least one valid character before sanitization
|
|
274
454
|
unless name =~ /[a-zA-Z0-9._-]/
|
|
275
|
-
raise ConfigError,
|
|
455
|
+
raise ConfigError,
|
|
456
|
+
"Invalid archive name: must contain at least one valid character (alphanumeric, dot, dash, or underscore)"
|
|
276
457
|
end
|
|
277
458
|
|
|
278
459
|
# Allow only alphanumeric, dash, underscore, and dot
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
sanitized
|
|
460
|
+
name.gsub(/[^a-zA-Z0-9._-]/, "_")
|
|
282
461
|
end
|
|
283
462
|
|
|
284
|
-
# Wrapper class to adapt
|
|
463
|
+
# Wrapper class to adapt repository config to existing Backup class
|
|
285
464
|
class BackupConfig
|
|
286
465
|
def initialize(repo_config, merged_settings)
|
|
287
466
|
@repo_config = repo_config
|
|
@@ -299,9 +478,9 @@ module Ruborg
|
|
|
299
478
|
patterns = []
|
|
300
479
|
sources = @repo_config["sources"] || []
|
|
301
480
|
sources.each do |source|
|
|
302
|
-
patterns +=
|
|
481
|
+
patterns += source["exclude"] || []
|
|
303
482
|
end
|
|
304
|
-
patterns +=
|
|
483
|
+
patterns += @merged_settings["exclude_patterns"] || []
|
|
305
484
|
patterns.uniq
|
|
306
485
|
end
|
|
307
486
|
|
|
@@ -314,4 +493,4 @@ module Ruborg
|
|
|
314
493
|
end
|
|
315
494
|
end
|
|
316
495
|
end
|
|
317
|
-
end
|
|
496
|
+
end
|