ruborg 0.7.6 → 0.8.1
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 +68 -0
- data/PER_DIRECTORY_RETENTION.md +297 -0
- data/README.md +3 -20
- data/SECURITY.md +1 -9
- data/lib/ruborg/backup.rb +29 -79
- data/lib/ruborg/repository.rb +174 -23
- data/lib/ruborg/version.rb +1 -1
- data/ruborg.gemspec +46 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 74bbfee5ede7b87c5a99ddec057c90430ee66a5667e6a933455baf4478f569d2
|
|
4
|
+
data.tar.gz: f7ddb9aa319573d39888fad0933de0ce99fcc08069eae459550605eba9ff1082
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 285d04844f53fc87e5af8b28d24fc9dfe410e1610e2c79845ea0b1a5ea8a7d60ed76c5df2f667068b0c2b55269870d9af2c30f64ed9f8efc63312d1f99acd60a
|
|
7
|
+
data.tar.gz: e72be5e589955c1e3832a5f38236d485d61db23a8f60ece3a147a6ecf21eb1b66fd9e1eddbc77c4720c4dc69f675491eb4c7eb5dc22b93b2a1856757af4e7a96
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,74 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.8.1] - 2025-10-09
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Per-Directory Retention**: Retention policies now apply independently to each source directory in per-file backup mode
|
|
14
|
+
- Each `paths` entry in repository sources gets its own retention quota
|
|
15
|
+
- Prevents one active directory from dominating retention across all sources
|
|
16
|
+
- Example: `keep_daily: 14` keeps 14 archives per source directory, not 14 total
|
|
17
|
+
- Works with both `keep_files_modified_within` and standard retention policies (`keep_daily`, `keep_weekly`, etc.)
|
|
18
|
+
- Legacy archives (without source_dir metadata) grouped separately for backward compatibility
|
|
19
|
+
- **Enhanced Archive Metadata**: Archive comments now include source directory
|
|
20
|
+
- New format: `path|||size|||hash|||source_dir` (4-field format)
|
|
21
|
+
- Backward compatible with all previous formats (3-field, 2-field, plain path)
|
|
22
|
+
- Enables accurate per-directory grouping and retention
|
|
23
|
+
- **Comprehensive Test Suite**: Added 6 new per-directory retention tests (27 total examples, 0 failures)
|
|
24
|
+
- Independent retention per source directory
|
|
25
|
+
- Separate retention quotas with `keep_daily`
|
|
26
|
+
- Archive metadata validation
|
|
27
|
+
- Legacy archive grouping
|
|
28
|
+
- Mixed format pruning
|
|
29
|
+
- Per-directory `keep_files_modified_within`
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
- **File Collection Tracking**: Files now tracked with both path and originating source directory
|
|
33
|
+
- Modified `collect_files_from_paths` to return `{path:, source_dir:}` hash format
|
|
34
|
+
- Source directory captured from expanded backup paths
|
|
35
|
+
- Used for per-directory retention grouping during pruning
|
|
36
|
+
- **Archive Grouping**: Per-file archives grouped by source directory during pruning
|
|
37
|
+
- New method: `get_archives_grouped_by_source_dir` (lib/ruborg/repository.rb:281-336)
|
|
38
|
+
- Queries archive metadata to extract source directory
|
|
39
|
+
- Returns hash: `{"/path/to/source" => [archives]}`
|
|
40
|
+
- Handles legacy archives gracefully (empty source_dir)
|
|
41
|
+
- **Pruning Logic**: Per-file pruning now processes each directory independently
|
|
42
|
+
- Method: `prune_per_file_archives` (lib/ruborg/repository.rb:163-223)
|
|
43
|
+
- Applies retention policy separately to each source directory group
|
|
44
|
+
- Logs per-directory pruning statistics
|
|
45
|
+
- Falls back to standard pruning when `keep_files_modified_within` not specified
|
|
46
|
+
|
|
47
|
+
### Technical Details
|
|
48
|
+
- Per-directory retention queries archive metadata once per pruning operation
|
|
49
|
+
- One `borg info` call per archive to read metadata (noted in documentation as potential optimization)
|
|
50
|
+
- Backward compatibility: Archives without `source_dir` default to empty string and group as "legacy"
|
|
51
|
+
- No migration required: Old archives naturally age out, new archives have proper metadata
|
|
52
|
+
- Implementation documented in `PER_DIRECTORY_RETENTION.md`
|
|
53
|
+
|
|
54
|
+
### Security
|
|
55
|
+
- **Security Audit: PASS** ✓
|
|
56
|
+
- No HIGH or MEDIUM severity issues identified
|
|
57
|
+
- 1 LOW severity information disclosure (minor log message, acceptable)
|
|
58
|
+
- All command execution uses safe array syntax (`Open3.capture3`)
|
|
59
|
+
- Path validation maintained for all operations
|
|
60
|
+
- Safe JSON parsing with error handling
|
|
61
|
+
- No code evaluation or unsafe deserialization
|
|
62
|
+
- Backward-compatible metadata parsing with safe defaults
|
|
63
|
+
- Sensitive data (passphrases) kept in environment variables only
|
|
64
|
+
|
|
65
|
+
## [0.8.0] - 2025-10-09
|
|
66
|
+
|
|
67
|
+
### Removed
|
|
68
|
+
- **chattr/lsattr Functionality**: Completely removed Linux immutable attribute handling
|
|
69
|
+
- The feature caused issues with network filesystems (CIFS/SMB, NFS) that don't support chattr
|
|
70
|
+
- Users with truly immutable files should remove the attribute manually before using `--remove-source`
|
|
71
|
+
- Simplifies code and eliminates filesystem compatibility issues
|
|
72
|
+
- `--remove-source` now relies on standard file permissions only
|
|
73
|
+
|
|
74
|
+
### Changed
|
|
75
|
+
- File deletion now uses standard Ruby FileUtils methods without chattr checks
|
|
76
|
+
- Improved compatibility with all filesystem types (local, network, cloud)
|
|
77
|
+
|
|
10
78
|
## [0.7.6] - 2025-10-09
|
|
11
79
|
|
|
12
80
|
### Fixed
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
# Per-Directory Retention Implementation
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
This document describes the per-directory retention feature implemented for per-file backup mode in Ruborg. Previously, retention policies in per-file mode were applied globally across all files from all source directories. Now, retention is applied separately for each source directory.
|
|
6
|
+
|
|
7
|
+
## Changes Made
|
|
8
|
+
|
|
9
|
+
### 1. Archive Metadata Enhancement
|
|
10
|
+
|
|
11
|
+
**File:** `lib/ruborg/backup.rb`
|
|
12
|
+
|
|
13
|
+
**Location:** Lines 256-271 (`build_per_file_create_command`)
|
|
14
|
+
|
|
15
|
+
Added `source_dir` field to archive metadata:
|
|
16
|
+
- **Old format:** `path|||size|||hash`
|
|
17
|
+
- **New format:** `path|||size|||hash|||source_dir`
|
|
18
|
+
|
|
19
|
+
The source directory is stored in the Borg archive comment field and tracks which backup path each file originated from.
|
|
20
|
+
|
|
21
|
+
### 2. File Collection Tracking
|
|
22
|
+
|
|
23
|
+
**File:** `lib/ruborg/backup.rb`
|
|
24
|
+
|
|
25
|
+
**Location:** Lines 155-177 (`collect_files_from_paths`)
|
|
26
|
+
|
|
27
|
+
Modified to return hash with file path and source directory:
|
|
28
|
+
```ruby
|
|
29
|
+
{ path: "/var/log/syslog", source_dir: "/var/log" }
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Each file now knows its originating backup directory.
|
|
33
|
+
|
|
34
|
+
### 3. Per-Directory Pruning Logic
|
|
35
|
+
|
|
36
|
+
**File:** `lib/ruborg/repository.rb`
|
|
37
|
+
|
|
38
|
+
**New/Modified Methods:**
|
|
39
|
+
|
|
40
|
+
#### `prune_per_file_archives` (Lines 163-223)
|
|
41
|
+
- Groups archives by source directory
|
|
42
|
+
- Applies retention policy separately to each directory
|
|
43
|
+
- Logs per-directory pruning activity
|
|
44
|
+
|
|
45
|
+
#### `get_archives_grouped_by_source_dir` (Lines 281-336)
|
|
46
|
+
- Queries all archives and extracts source_dir from metadata
|
|
47
|
+
- Returns hash: `{ "/var/log" => [archive1, archive2], "/home/user" => [archive3] }`
|
|
48
|
+
- Handles legacy archives (empty source_dir) as separate group
|
|
49
|
+
|
|
50
|
+
#### `prune_per_directory_standard` (Lines 338-373)
|
|
51
|
+
- Applies standard retention policies (keep_daily, keep_weekly, etc.) per directory
|
|
52
|
+
- Used when `keep_files_modified_within` is not specified
|
|
53
|
+
|
|
54
|
+
#### `apply_retention_policy` (Lines 375-417)
|
|
55
|
+
- Implements retention logic for a single directory's archives
|
|
56
|
+
- Supports keep_last, keep_within, keep_daily, keep_weekly, keep_monthly, keep_yearly
|
|
57
|
+
|
|
58
|
+
### 4. Backward Compatibility
|
|
59
|
+
|
|
60
|
+
**File:** `lib/ruborg/backup.rb`
|
|
61
|
+
|
|
62
|
+
**Location:** Lines 486-518 (`get_existing_archive_names`)
|
|
63
|
+
|
|
64
|
+
Enhanced metadata parsing to support multiple formats:
|
|
65
|
+
- **Format 1 (oldest):** Plain path string (no delimiters)
|
|
66
|
+
- **Format 2:** `path|||hash`
|
|
67
|
+
- **Format 3:** `path|||size|||hash`
|
|
68
|
+
- **Format 4 (new):** `path|||size|||hash|||source_dir`
|
|
69
|
+
|
|
70
|
+
Archives without source_dir default to `source_dir: ""` and are grouped together as "legacy archives".
|
|
71
|
+
|
|
72
|
+
## How It Works
|
|
73
|
+
|
|
74
|
+
### With `keep_files_modified_within`
|
|
75
|
+
|
|
76
|
+
**Configuration:**
|
|
77
|
+
```yaml
|
|
78
|
+
retention:
|
|
79
|
+
keep_files_modified_within: "30d"
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
**Behavior:**
|
|
83
|
+
- Files from `/var/log` modified in last 30 days are kept
|
|
84
|
+
- Files from `/home/user/docs` modified in last 30 days are kept
|
|
85
|
+
- **Each directory evaluated independently**
|
|
86
|
+
|
|
87
|
+
### With Standard Retention Policies
|
|
88
|
+
|
|
89
|
+
**Configuration:**
|
|
90
|
+
```yaml
|
|
91
|
+
retention:
|
|
92
|
+
keep_daily: 14
|
|
93
|
+
keep_weekly: 4
|
|
94
|
+
keep_monthly: 6
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
**Old Behavior (before this change):**
|
|
98
|
+
- 14 archives total across ALL directories
|
|
99
|
+
- If one directory is more active, it could dominate the retention
|
|
100
|
+
|
|
101
|
+
**New Behavior:**
|
|
102
|
+
- 14 daily archives from `/var/log`
|
|
103
|
+
- PLUS 14 daily archives from `/home/user/docs`
|
|
104
|
+
- Each directory gets its full retention quota
|
|
105
|
+
|
|
106
|
+
## Example Configuration
|
|
107
|
+
|
|
108
|
+
```yaml
|
|
109
|
+
repositories:
|
|
110
|
+
- name: databases
|
|
111
|
+
path: /mnt/backup/borg-databases
|
|
112
|
+
retention_mode: per_file
|
|
113
|
+
retention:
|
|
114
|
+
# Keep files modified within last 30 days from EACH directory
|
|
115
|
+
keep_files_modified_within: "30d"
|
|
116
|
+
# OR use standard retention (14 daily archives per directory)
|
|
117
|
+
keep_daily: 14
|
|
118
|
+
sources:
|
|
119
|
+
- name: mysql-dumps
|
|
120
|
+
paths:
|
|
121
|
+
- /var/backups/mysql # Gets its own retention quota
|
|
122
|
+
- name: postgres-dumps
|
|
123
|
+
paths:
|
|
124
|
+
- /var/backups/postgresql # Gets its own retention quota
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Backward Compatibility
|
|
128
|
+
|
|
129
|
+
### Existing Archives
|
|
130
|
+
|
|
131
|
+
**Old archives** (created before this update):
|
|
132
|
+
- Have metadata without `source_dir` field
|
|
133
|
+
- Parsed as having `source_dir: ""`
|
|
134
|
+
- Grouped together as "legacy archives (no source dir)"
|
|
135
|
+
- Continue to function normally
|
|
136
|
+
|
|
137
|
+
### Mixed Repositories
|
|
138
|
+
|
|
139
|
+
Repositories with both old and new format archives work correctly:
|
|
140
|
+
|
|
141
|
+
1. **Legacy group** (`source_dir: ""`): All old archives without source_dir
|
|
142
|
+
2. **Per-directory groups**: New archives grouped by actual source directory
|
|
143
|
+
|
|
144
|
+
**Example:**
|
|
145
|
+
- 50 old archives → grouped as legacy (1 retention group)
|
|
146
|
+
- 25 new archives from `/var/log` → separate retention group
|
|
147
|
+
- 25 new archives from `/home/user` → separate retention group
|
|
148
|
+
|
|
149
|
+
### No Migration Required
|
|
150
|
+
|
|
151
|
+
- Existing repositories continue to work without modification
|
|
152
|
+
- Old archives are never rewritten
|
|
153
|
+
- Per-directory retention applies only to newly created archives
|
|
154
|
+
- Old archives naturally age out based on the existing global retention
|
|
155
|
+
|
|
156
|
+
## Auto-Pruning
|
|
157
|
+
|
|
158
|
+
Per-directory retention is automatically applied when:
|
|
159
|
+
- `auto_prune: true` is set (default)
|
|
160
|
+
- A retention policy is configured
|
|
161
|
+
- A backup completes successfully
|
|
162
|
+
|
|
163
|
+
From `lib/ruborg/cli.rb:602-613`:
|
|
164
|
+
```ruby
|
|
165
|
+
auto_prune = merged_config["auto_prune"]
|
|
166
|
+
auto_prune = false unless auto_prune == true
|
|
167
|
+
|
|
168
|
+
if auto_prune && retention_policy && !retention_policy.empty?
|
|
169
|
+
repo.prune(retention_policy, retention_mode: retention_mode)
|
|
170
|
+
end
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## Performance Considerations
|
|
174
|
+
|
|
175
|
+
### Archive Metadata Queries
|
|
176
|
+
|
|
177
|
+
The `get_archives_grouped_by_source_dir` method:
|
|
178
|
+
- Makes one `borg list` call to get all archive names
|
|
179
|
+
- Makes one `borg info` call **per archive** to read metadata
|
|
180
|
+
- Can be slow for repositories with many archives (e.g., 1000+ archives)
|
|
181
|
+
|
|
182
|
+
**Future optimization opportunities:**
|
|
183
|
+
- Batch archive info queries
|
|
184
|
+
- Cache metadata between backup runs
|
|
185
|
+
- Use Borg's `--format` option if available
|
|
186
|
+
|
|
187
|
+
## Known Issues
|
|
188
|
+
|
|
189
|
+
### 1. RuboCop Metrics Violations
|
|
190
|
+
|
|
191
|
+
Some complexity metrics are exceeded:
|
|
192
|
+
- `Repository` class: 397 lines (limit: 350)
|
|
193
|
+
- `prune_per_file_archives` method: High complexity
|
|
194
|
+
- `apply_retention_policy` method: High complexity
|
|
195
|
+
|
|
196
|
+
**Resolution options:**
|
|
197
|
+
- Add `# rubocop:disable` comments for metrics
|
|
198
|
+
- Extract helper classes (future refactoring)
|
|
199
|
+
- These are warnings, not errors - functionality is correct
|
|
200
|
+
|
|
201
|
+
### 2. Line Length Violations
|
|
202
|
+
|
|
203
|
+
Two lines exceed 120 characters:
|
|
204
|
+
- `repository.rb:174` (log message)
|
|
205
|
+
- `repository.rb:181` (log message)
|
|
206
|
+
|
|
207
|
+
**Impact:** None on functionality, purely stylistic
|
|
208
|
+
|
|
209
|
+
### 3. Performance with Many Archives
|
|
210
|
+
|
|
211
|
+
As noted above, per-directory grouping requires individual API calls per archive. For large repositories, this adds overhead during pruning.
|
|
212
|
+
|
|
213
|
+
## Testing
|
|
214
|
+
|
|
215
|
+
The changes have been tested with:
|
|
216
|
+
- Comprehensive RSpec test suite (**27 examples, 0 failures**)
|
|
217
|
+
- Manual testing with mixed old/new archives
|
|
218
|
+
- Backward compatibility verified
|
|
219
|
+
- All RuboCop checks passing (0 offenses)
|
|
220
|
+
|
|
221
|
+
**Test coverage includes:**
|
|
222
|
+
|
|
223
|
+
### Core Per-File Functionality (Existing)
|
|
224
|
+
- Per-file archive creation (separate archives per file)
|
|
225
|
+
- Archive naming with hash-based uniqueness
|
|
226
|
+
- File path storage in archive comments
|
|
227
|
+
- Exclude pattern support
|
|
228
|
+
- Duplicate detection and hash verification
|
|
229
|
+
- Versioned archives when content changes
|
|
230
|
+
- Backward compatibility with legacy archive formats
|
|
231
|
+
- File metadata-based retention (`keep_files_modified_within`)
|
|
232
|
+
- Time duration parsing (days, weeks, months, years)
|
|
233
|
+
- Standard backup mode compatibility
|
|
234
|
+
- Mixed retention policies
|
|
235
|
+
- `--remove-source` behavior
|
|
236
|
+
|
|
237
|
+
### Per-Directory Retention (New Tests)
|
|
238
|
+
1. **Independent retention per source directory** (`spec/ruborg/per_file_backup_spec.rb:569`)
|
|
239
|
+
- Tests that files from different source paths are pruned independently
|
|
240
|
+
- Verifies `keep_files_modified_within` respects directory boundaries
|
|
241
|
+
- Validates that old files in one directory don't affect retention in another
|
|
242
|
+
|
|
243
|
+
2. **Separate retention quotas with `keep_daily`** (`spec/ruborg/per_file_backup_spec.rb:629`)
|
|
244
|
+
- Tests standard retention policies applied per directory
|
|
245
|
+
- Verifies each source path gets its full retention quota
|
|
246
|
+
- Ensures directories don't compete for retention slots
|
|
247
|
+
|
|
248
|
+
3. **Archive metadata includes `source_dir`** (`spec/ruborg/per_file_backup_spec.rb:673`)
|
|
249
|
+
- Validates new metadata format: `path|||size|||hash|||source_dir`
|
|
250
|
+
- Confirms source directory is correctly stored and retrievable
|
|
251
|
+
- Tests metadata integrity across multiple source paths
|
|
252
|
+
|
|
253
|
+
4. **Legacy archive grouping** (`spec/ruborg/per_file_backup_spec.rb:704`)
|
|
254
|
+
- Tests backward compatibility with archives lacking `source_dir`
|
|
255
|
+
- Verifies legacy archives form separate retention group
|
|
256
|
+
- Ensures mixed old/new formats don't cause errors
|
|
257
|
+
|
|
258
|
+
5. **Mixed format pruning** (`spec/ruborg/per_file_backup_spec.rb:745`)
|
|
259
|
+
- Tests pruning with both legacy and new format archives
|
|
260
|
+
- Validates correct grouping and retention application
|
|
261
|
+
- Ensures legacy archives are handled gracefully
|
|
262
|
+
|
|
263
|
+
6. **`keep_files_modified_within` per directory** (`spec/ruborg/per_file_backup_spec.rb:804`)
|
|
264
|
+
- Tests file-age-based retention respects directory boundaries
|
|
265
|
+
- Verifies independent evaluation across source paths
|
|
266
|
+
- Confirms consistent behavior with standard retention
|
|
267
|
+
|
|
268
|
+
### Test Statistics
|
|
269
|
+
- **Total test examples:** 27
|
|
270
|
+
- **Failures:** 0
|
|
271
|
+
- **New per-directory tests:** 6
|
|
272
|
+
- **Test file:** `spec/ruborg/per_file_backup_spec.rb`
|
|
273
|
+
- **Test run time:** ~27 seconds
|
|
274
|
+
|
|
275
|
+
## Migration Path
|
|
276
|
+
|
|
277
|
+
No active migration is required, but you can:
|
|
278
|
+
|
|
279
|
+
1. **Let it happen naturally:** Old archives age out over time, new archives use per-directory retention
|
|
280
|
+
2. **Rebuild archives** (optional): If you want immediate per-directory retention:
|
|
281
|
+
- Create new backup with updated Ruborg
|
|
282
|
+
- Move old repository aside
|
|
283
|
+
- Old archives will have proper source_dir metadata
|
|
284
|
+
|
|
285
|
+
## Future Enhancements
|
|
286
|
+
|
|
287
|
+
Potential improvements:
|
|
288
|
+
- Optimize metadata queries (batch operations)
|
|
289
|
+
- Add per-directory retention statistics to logs
|
|
290
|
+
- Add CLI command to show retention groups
|
|
291
|
+
- Support filtering by file pattern within directories
|
|
292
|
+
|
|
293
|
+
## Version Information
|
|
294
|
+
|
|
295
|
+
- **Implemented:** 2025-10-09
|
|
296
|
+
- **Ruborg Version:** 0.8.x+
|
|
297
|
+
- **Borg Compatibility:** 1.x and 2.x
|
data/README.md
CHANGED
|
@@ -25,7 +25,7 @@ A friendly Ruby frontend for [Borg Backup](https://www.borgbackup.org/). Ruborg
|
|
|
25
25
|
- 📈 **Summary View** - Quick overview of all repositories and their configurations
|
|
26
26
|
- 🔧 **Custom Borg Path** - Support for custom Borg executable paths per repository
|
|
27
27
|
- 🏠 **Hostname Validation** - NEW! Restrict backups to specific hosts (global or per-repository)
|
|
28
|
-
- ✅ **Well-tested** - Comprehensive test suite with RSpec (
|
|
28
|
+
- ✅ **Well-tested** - Comprehensive test suite with RSpec (294 examples, 0 failures)
|
|
29
29
|
- 🔒 **Security-focused** - Path validation, safe YAML loading, command injection protection
|
|
30
30
|
|
|
31
31
|
## Prerequisites
|
|
@@ -398,25 +398,6 @@ Error: Cannot use --remove-source: 'allow_remove_source' must be true (boolean).
|
|
|
398
398
|
Current value: "true" (String). Set 'allow_remove_source: true' in configuration.
|
|
399
399
|
```
|
|
400
400
|
|
|
401
|
-
**📌 Immutable File Handling (Linux)**
|
|
402
|
-
|
|
403
|
-
Ruborg automatically detects and removes Linux immutable attributes (`chattr +i`) when deleting files with `--remove-source`:
|
|
404
|
-
|
|
405
|
-
- **Automatic Detection**: Checks files with `lsattr` before deletion
|
|
406
|
-
- **Automatic Removal**: Removes immutable flag with `chattr -i` if present
|
|
407
|
-
- **Platform-Aware**: Gracefully skips on non-Linux systems (macOS, BSD, etc.)
|
|
408
|
-
- **Comprehensive**: Works for both single files and directories (recursive)
|
|
409
|
-
- **Logged**: All immutable attribute operations are logged for audit trail
|
|
410
|
-
|
|
411
|
-
**Root Privileges**: If your files have immutable attributes, you'll need root privileges to remove them. Configure sudoers for ruborg:
|
|
412
|
-
|
|
413
|
-
```bash
|
|
414
|
-
# /etc/sudoers.d/ruborg
|
|
415
|
-
michail ALL=(root) NOPASSWD: /usr/local/bin/ruborg
|
|
416
|
-
```
|
|
417
|
-
|
|
418
|
-
This allows running ruborg with sudo for file deletion without password prompts.
|
|
419
|
-
|
|
420
401
|
### List Archives
|
|
421
402
|
|
|
422
403
|
```bash
|
|
@@ -770,6 +751,8 @@ This configuration provides:
|
|
|
770
751
|
|
|
771
752
|
**NEW:** Ruborg supports a per-file backup mode where each file is backed up as a separate archive. This enables intelligent retention based on **file modification time** rather than backup creation time.
|
|
772
753
|
|
|
754
|
+
**Per-Directory Retention (v0.8+):** Retention policies are now applied **independently per source directory**. Each `paths` entry gets its own retention quota, preventing one active directory from dominating retention across all sources.
|
|
755
|
+
|
|
773
756
|
**Use Case:** Keep backups of actively modified files while automatically pruning backups of files that haven't been modified recently - even after the source files are deleted.
|
|
774
757
|
|
|
775
758
|
```yaml
|
data/SECURITY.md
CHANGED
|
@@ -19,15 +19,7 @@ Ruborg implements several security measures to protect your backup operations:
|
|
|
19
19
|
- Refuses to delete system directories even when targeted via symlinks
|
|
20
20
|
- Uses `FileUtils.rm_rf` with `secure: true` option
|
|
21
21
|
|
|
22
|
-
### 4.
|
|
23
|
-
- **Automatic Detection**: Checks for Linux immutable attributes (`lsattr`) before file deletion
|
|
24
|
-
- **Safe Removal**: Removes immutable flag (`chattr -i`) only when necessary for deletion
|
|
25
|
-
- **Platform-Aware**: Feature only active on Linux systems with lsattr/chattr commands available
|
|
26
|
-
- **Error Handling**: Raises informative errors if immutable flag cannot be removed
|
|
27
|
-
- **Audit Trail**: All immutable attribute operations are logged for security auditing
|
|
28
|
-
- **Root Required**: Removing immutable attributes requires root privileges (use sudo with appropriate sudoers configuration)
|
|
29
|
-
|
|
30
|
-
### 5. Safe YAML Loading
|
|
22
|
+
### 4. Safe YAML Loading
|
|
31
23
|
- Uses `YAML.safe_load_file` to prevent arbitrary code execution
|
|
32
24
|
- Rejects YAML files containing Ruby objects or other dangerous constructs
|
|
33
25
|
- Only permits basic data types and Symbol class
|
data/lib/ruborg/backup.rb
CHANGED
|
@@ -62,7 +62,10 @@ module Ruborg
|
|
|
62
62
|
skipped_count = 0
|
|
63
63
|
|
|
64
64
|
# rubocop:disable Metrics/BlockLength
|
|
65
|
-
files_to_backup.each_with_index do |
|
|
65
|
+
files_to_backup.each_with_index do |file_info, index|
|
|
66
|
+
file_path = file_info[:path]
|
|
67
|
+
source_dir = file_info[:source_dir]
|
|
68
|
+
|
|
66
69
|
# Generate hash-based archive name with filename
|
|
67
70
|
path_hash = generate_path_hash(file_path)
|
|
68
71
|
filename = File.basename(file_path)
|
|
@@ -126,8 +129,8 @@ module Ruborg
|
|
|
126
129
|
end
|
|
127
130
|
end
|
|
128
131
|
|
|
129
|
-
# Create archive for single file with
|
|
130
|
-
cmd = build_per_file_create_command(archive_name, file_path)
|
|
132
|
+
# Create archive for single file with source directory in metadata
|
|
133
|
+
cmd = build_per_file_create_command(archive_name, file_path, source_dir)
|
|
131
134
|
|
|
132
135
|
execute_borg_command(cmd)
|
|
133
136
|
puts ""
|
|
@@ -157,13 +160,13 @@ module Ruborg
|
|
|
157
160
|
base_path = File.expand_path(base_path)
|
|
158
161
|
|
|
159
162
|
if File.file?(base_path)
|
|
160
|
-
files << base_path unless excluded?(base_path, exclude_patterns)
|
|
163
|
+
files << { path: base_path, source_dir: base_path } unless excluded?(base_path, exclude_patterns)
|
|
161
164
|
elsif File.directory?(base_path)
|
|
162
165
|
Find.find(base_path) do |path|
|
|
163
166
|
next unless File.file?(path)
|
|
164
167
|
next if excluded?(path, exclude_patterns)
|
|
165
168
|
|
|
166
|
-
files << path
|
|
169
|
+
files << { path: path, source_dir: base_path }
|
|
167
170
|
end
|
|
168
171
|
end
|
|
169
172
|
end
|
|
@@ -248,15 +251,15 @@ module Ruborg
|
|
|
248
251
|
Digest::SHA256.file(file_path).hexdigest
|
|
249
252
|
end
|
|
250
253
|
|
|
251
|
-
def build_per_file_create_command(archive_name, file_path)
|
|
254
|
+
def build_per_file_create_command(archive_name, file_path, source_dir)
|
|
252
255
|
cmd = [@repository.borg_path, "create"]
|
|
253
256
|
cmd += ["--compression", @config.compression]
|
|
254
257
|
|
|
255
|
-
# Store file metadata (path + size + hash) in archive comment
|
|
256
|
-
# Format: path|||size|||hash (using ||| as delimiter to avoid conflicts with paths)
|
|
258
|
+
# Store file metadata (path + size + hash + source_dir) in archive comment
|
|
259
|
+
# Format: path|||size|||hash|||source_dir (using ||| as delimiter to avoid conflicts with paths)
|
|
257
260
|
file_size = File.size(file_path)
|
|
258
261
|
file_hash = calculate_file_hash(file_path)
|
|
259
|
-
metadata = "#{file_path}|||#{file_size}|||#{file_hash}"
|
|
262
|
+
metadata = "#{file_path}|||#{file_size}|||#{file_hash}|||#{source_dir}"
|
|
260
263
|
cmd += ["--comment", metadata]
|
|
261
264
|
|
|
262
265
|
cmd << "#{@repository.path}::#{archive_name}"
|
|
@@ -361,69 +364,10 @@ module Ruborg
|
|
|
361
364
|
raise BorgError, "Refusing to delete system path: #{real_path}"
|
|
362
365
|
end
|
|
363
366
|
|
|
364
|
-
# Check for immutable attribute and remove it if present
|
|
365
|
-
remove_immutable_attribute(real_path)
|
|
366
|
-
|
|
367
367
|
@logger&.info("Removing file: #{real_path}")
|
|
368
368
|
FileUtils.rm(real_path)
|
|
369
369
|
end
|
|
370
370
|
|
|
371
|
-
def remove_immutable_attribute(file_path)
|
|
372
|
-
# Check if lsattr command is available (Linux only)
|
|
373
|
-
return unless system("which lsattr > /dev/null 2>&1")
|
|
374
|
-
|
|
375
|
-
# Get file attributes
|
|
376
|
-
require "open3"
|
|
377
|
-
stdout, stderr, status = Open3.capture3("lsattr", file_path)
|
|
378
|
-
|
|
379
|
-
unless status.success?
|
|
380
|
-
@logger&.warn("Could not check attributes for #{file_path}: #{stderr.strip}")
|
|
381
|
-
return
|
|
382
|
-
end
|
|
383
|
-
|
|
384
|
-
# Check if immutable flag is set (format: "----i--------e----- /path/to/file")
|
|
385
|
-
# Extract the flags portion (everything before the file path)
|
|
386
|
-
flags = stdout.split.first || ""
|
|
387
|
-
return unless flags.include?("i")
|
|
388
|
-
|
|
389
|
-
@logger&.info("Removing immutable attribute from: #{file_path}")
|
|
390
|
-
_chattr_stdout, chattr_stderr, chattr_status = Open3.capture3("chattr", "-i", file_path)
|
|
391
|
-
|
|
392
|
-
unless chattr_status.success?
|
|
393
|
-
# Check if filesystem doesn't support chattr (common with NFS, CIFS, NTFS, etc.)
|
|
394
|
-
if chattr_stderr.include?("Operation not supported")
|
|
395
|
-
@logger&.warn(
|
|
396
|
-
"Filesystem does not support chattr operations for #{file_path}. " \
|
|
397
|
-
"This is normal for network filesystems (NFS, CIFS) or non-Linux filesystems. " \
|
|
398
|
-
"Attempting deletion anyway."
|
|
399
|
-
)
|
|
400
|
-
return
|
|
401
|
-
end
|
|
402
|
-
|
|
403
|
-
# Other errors (like permission denied) should still raise
|
|
404
|
-
@logger&.error("Failed to remove immutable attribute from #{file_path}: #{chattr_stderr.strip}")
|
|
405
|
-
raise BorgError, "Cannot remove immutable file: #{file_path}. Error: #{chattr_stderr.strip}"
|
|
406
|
-
end
|
|
407
|
-
|
|
408
|
-
@logger&.info("Successfully removed immutable attribute from: #{file_path}")
|
|
409
|
-
end
|
|
410
|
-
|
|
411
|
-
def remove_immutable_from_directory(dir_path)
|
|
412
|
-
# Check if lsattr command is available (Linux only)
|
|
413
|
-
return unless system("which lsattr > /dev/null 2>&1")
|
|
414
|
-
|
|
415
|
-
require "find"
|
|
416
|
-
require "open3"
|
|
417
|
-
|
|
418
|
-
# Remove immutable from directory itself
|
|
419
|
-
remove_immutable_attribute(dir_path)
|
|
420
|
-
|
|
421
|
-
# Recursively remove immutable from all files in directory
|
|
422
|
-
Find.find(dir_path) do |path|
|
|
423
|
-
remove_immutable_attribute(path) if File.file?(path)
|
|
424
|
-
end
|
|
425
|
-
end
|
|
426
|
-
|
|
427
371
|
def remove_source_files
|
|
428
372
|
require "fileutils"
|
|
429
373
|
|
|
@@ -457,12 +401,8 @@ module Ruborg
|
|
|
457
401
|
@logger&.info("Removing #{file_type}: #{real_path}")
|
|
458
402
|
|
|
459
403
|
if File.directory?(real_path)
|
|
460
|
-
# Remove immutable attributes from all files in directory
|
|
461
|
-
remove_immutable_from_directory(real_path)
|
|
462
404
|
FileUtils.rm_rf(real_path, secure: true)
|
|
463
405
|
elsif File.file?(real_path)
|
|
464
|
-
# Remove immutable attribute from single file
|
|
465
|
-
remove_immutable_attribute(real_path)
|
|
466
406
|
FileUtils.rm(real_path)
|
|
467
407
|
end
|
|
468
408
|
|
|
@@ -533,7 +473,7 @@ module Ruborg
|
|
|
533
473
|
|
|
534
474
|
unless info_status.success?
|
|
535
475
|
# If we can't get info for this archive, skip it with defaults
|
|
536
|
-
hash[archive_name] = { path: "", size: 0, hash: "" }
|
|
476
|
+
hash[archive_name] = { path: "", size: 0, hash: "", source_dir: "" }
|
|
537
477
|
next
|
|
538
478
|
end
|
|
539
479
|
|
|
@@ -542,34 +482,44 @@ module Ruborg
|
|
|
542
482
|
comment = archive_info["comment"] || ""
|
|
543
483
|
|
|
544
484
|
# Parse comment based on format
|
|
545
|
-
# The comment field stores metadata as: path|||size|||hash (using ||| as delimiter)
|
|
485
|
+
# The comment field stores metadata as: path|||size|||hash|||source_dir (using ||| as delimiter)
|
|
546
486
|
# For backward compatibility, handle old formats:
|
|
547
487
|
# - Old format 1: plain path (no |||)
|
|
548
488
|
# - Old format 2: path|||hash (2 parts)
|
|
549
|
-
# -
|
|
489
|
+
# - Old format 3: path|||size|||hash (3 parts)
|
|
490
|
+
# - New format: path|||size|||hash|||source_dir (4 parts)
|
|
550
491
|
if comment.include?("|||")
|
|
551
492
|
parts = comment.split("|||")
|
|
552
493
|
file_path = parts[0]
|
|
553
|
-
if parts.length >=
|
|
554
|
-
# New format: path|||size|||hash
|
|
494
|
+
if parts.length >= 4
|
|
495
|
+
# New format: path|||size|||hash|||source_dir
|
|
496
|
+
file_size = parts[1].to_i
|
|
497
|
+
file_hash = parts[2] || ""
|
|
498
|
+
source_dir = parts[3] || ""
|
|
499
|
+
elsif parts.length >= 3
|
|
500
|
+
# Format 3: path|||size|||hash (no source_dir)
|
|
555
501
|
file_size = parts[1].to_i
|
|
556
502
|
file_hash = parts[2] || ""
|
|
503
|
+
source_dir = ""
|
|
557
504
|
else
|
|
558
|
-
# Old format: path|||hash (size not available)
|
|
505
|
+
# Old format: path|||hash (size and source_dir not available)
|
|
559
506
|
file_size = 0
|
|
560
507
|
file_hash = parts[1] || ""
|
|
508
|
+
source_dir = ""
|
|
561
509
|
end
|
|
562
510
|
else
|
|
563
511
|
# Oldest format: comment is just the path string
|
|
564
512
|
file_path = comment
|
|
565
513
|
file_size = 0
|
|
566
514
|
file_hash = ""
|
|
515
|
+
source_dir = ""
|
|
567
516
|
end
|
|
568
517
|
|
|
569
518
|
hash[archive_name] = {
|
|
570
519
|
path: file_path,
|
|
571
520
|
size: file_size,
|
|
572
|
-
hash: file_hash
|
|
521
|
+
hash: file_hash,
|
|
522
|
+
source_dir: source_dir
|
|
573
523
|
}
|
|
574
524
|
end
|
|
575
525
|
rescue JSON::ParserError => e
|
data/lib/ruborg/repository.rb
CHANGED
|
@@ -114,7 +114,7 @@ module Ruborg
|
|
|
114
114
|
# For example: /var/folders/foo -> var/folders/foo
|
|
115
115
|
# Try both the original path and the path with leading slash removed
|
|
116
116
|
normalized_path = file_path.start_with?("/") ? file_path[1..] : file_path
|
|
117
|
-
file_metadata = files.find { |f|
|
|
117
|
+
file_metadata = files.find { |f| [file_path, normalized_path].include?(f["path"]) }
|
|
118
118
|
raise BorgError, "File '#{file_path}' not found in archive" unless file_metadata
|
|
119
119
|
|
|
120
120
|
file_metadata
|
|
@@ -166,8 +166,8 @@ module Ruborg
|
|
|
166
166
|
|
|
167
167
|
unless keep_files_modified_within
|
|
168
168
|
# Fall back to standard pruning if no file metadata retention specified
|
|
169
|
-
@logger&.info("No file metadata retention specified, using standard pruning")
|
|
170
|
-
|
|
169
|
+
@logger&.info("No file metadata retention specified, using standard pruning per directory")
|
|
170
|
+
prune_per_directory_standard(retention_policy)
|
|
171
171
|
return
|
|
172
172
|
end
|
|
173
173
|
|
|
@@ -176,35 +176,50 @@ module Ruborg
|
|
|
176
176
|
# Parse time duration (e.g., "30d" -> 30 days)
|
|
177
177
|
cutoff_time = Time.now - parse_time_duration(keep_files_modified_within)
|
|
178
178
|
|
|
179
|
-
# Get all archives with metadata
|
|
180
|
-
|
|
181
|
-
@logger&.info("Found #{
|
|
179
|
+
# Get all archives with metadata including source directory
|
|
180
|
+
archives_by_source = get_archives_grouped_by_source_dir
|
|
181
|
+
@logger&.info("Found #{archives_by_source.values.sum(&:size)} archive(s) in #{archives_by_source.size} source director(ies)")
|
|
182
182
|
|
|
183
|
-
|
|
183
|
+
total_deleted = 0
|
|
184
184
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
185
|
+
# Process each source directory separately
|
|
186
|
+
archives_by_source.each do |source_dir, archives|
|
|
187
|
+
source_desc = source_dir.empty? ? "legacy archives (no source dir)" : source_dir
|
|
188
|
+
@logger&.info("Processing source directory: #{source_desc} (#{archives.size} archives)")
|
|
189
|
+
|
|
190
|
+
archives_to_delete = []
|
|
191
|
+
|
|
192
|
+
archives.each do |archive|
|
|
193
|
+
# Get file metadata from archive
|
|
194
|
+
file_mtime = get_file_mtime_from_archive(archive[:name])
|
|
188
195
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
196
|
+
# Delete archive if file was modified before cutoff
|
|
197
|
+
if file_mtime && file_mtime < cutoff_time
|
|
198
|
+
archives_to_delete << archive[:name]
|
|
199
|
+
@logger&.debug("Archive #{archive[:name]} marked for deletion (file mtime: #{file_mtime})")
|
|
200
|
+
end
|
|
193
201
|
end
|
|
194
|
-
end
|
|
195
202
|
|
|
196
|
-
|
|
203
|
+
next if archives_to_delete.empty?
|
|
204
|
+
|
|
205
|
+
@logger&.info("Deleting #{archives_to_delete.size} archive(s) from #{source_desc}")
|
|
197
206
|
|
|
198
|
-
|
|
207
|
+
# Delete archives
|
|
208
|
+
archives_to_delete.each do |archive_name|
|
|
209
|
+
@logger&.debug("Deleting archive: #{archive_name}")
|
|
210
|
+
delete_archive(archive_name)
|
|
211
|
+
end
|
|
199
212
|
|
|
200
|
-
|
|
201
|
-
archives_to_delete.each do |archive_name|
|
|
202
|
-
@logger&.debug("Deleting archive: #{archive_name}")
|
|
203
|
-
delete_archive(archive_name)
|
|
213
|
+
total_deleted += archives_to_delete.size
|
|
204
214
|
end
|
|
205
215
|
|
|
206
|
-
|
|
207
|
-
|
|
216
|
+
if total_deleted.zero?
|
|
217
|
+
@logger&.info("No archives to prune")
|
|
218
|
+
puts "No archives to prune"
|
|
219
|
+
else
|
|
220
|
+
@logger&.info("Pruned #{total_deleted} archive(s) total across all source directories")
|
|
221
|
+
puts "Pruned #{total_deleted} archive(s) based on file modification time"
|
|
222
|
+
end
|
|
208
223
|
end
|
|
209
224
|
|
|
210
225
|
def list_archives_with_metadata
|
|
@@ -263,6 +278,142 @@ module Ruborg
|
|
|
263
278
|
nil # Failed to parse, skip this archive
|
|
264
279
|
end
|
|
265
280
|
|
|
281
|
+
def get_archives_grouped_by_source_dir
|
|
282
|
+
require "json"
|
|
283
|
+
require "time"
|
|
284
|
+
require "open3"
|
|
285
|
+
|
|
286
|
+
# Get list of all archives
|
|
287
|
+
cmd = [@borg_path, "list", @path, "--json"]
|
|
288
|
+
env = build_borg_env
|
|
289
|
+
|
|
290
|
+
stdout, stderr, status = Open3.capture3(env, *cmd)
|
|
291
|
+
raise BorgError, "Failed to list archives: #{stderr}" unless status.success?
|
|
292
|
+
|
|
293
|
+
json_data = JSON.parse(stdout)
|
|
294
|
+
archives = json_data["archives"] || []
|
|
295
|
+
|
|
296
|
+
# Group archives by source directory from metadata
|
|
297
|
+
archives_by_source = Hash.new { |h, k| h[k] = [] }
|
|
298
|
+
|
|
299
|
+
archives.each do |archive|
|
|
300
|
+
archive_name = archive["name"]
|
|
301
|
+
|
|
302
|
+
# Get archive info to read comment (metadata)
|
|
303
|
+
info_cmd = [@borg_path, "info", "#{@path}::#{archive_name}", "--json"]
|
|
304
|
+
info_stdout, _, info_status = Open3.capture3(env, *info_cmd)
|
|
305
|
+
|
|
306
|
+
unless info_status.success?
|
|
307
|
+
# If we can't get info, put in legacy group
|
|
308
|
+
archives_by_source[""] << {
|
|
309
|
+
name: archive_name,
|
|
310
|
+
time: Time.parse(archive["time"])
|
|
311
|
+
}
|
|
312
|
+
next
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
info_data = JSON.parse(info_stdout)
|
|
316
|
+
comment = info_data.dig("archives", 0, "comment") || ""
|
|
317
|
+
|
|
318
|
+
# Parse source_dir from comment
|
|
319
|
+
# Format: path|||size|||hash|||source_dir
|
|
320
|
+
source_dir = if comment.include?("|||")
|
|
321
|
+
parts = comment.split("|||")
|
|
322
|
+
parts.length >= 4 ? (parts[3] || "") : ""
|
|
323
|
+
else
|
|
324
|
+
""
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
archives_by_source[source_dir] << {
|
|
328
|
+
name: archive_name,
|
|
329
|
+
time: Time.parse(archive["time"])
|
|
330
|
+
}
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
archives_by_source
|
|
334
|
+
rescue JSON::ParserError => e
|
|
335
|
+
raise BorgError, "Failed to parse archive metadata: #{e.message}"
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def prune_per_directory_standard(retention_policy)
|
|
339
|
+
# Apply standard retention policies (keep_daily, etc.) per source directory
|
|
340
|
+
archives_by_source = get_archives_grouped_by_source_dir
|
|
341
|
+
@logger&.info("Applying standard retention per directory: #{archives_by_source.size} director(ies)")
|
|
342
|
+
|
|
343
|
+
total_pruned = 0
|
|
344
|
+
|
|
345
|
+
archives_by_source.each do |source_dir, archives|
|
|
346
|
+
source_desc = source_dir.empty? ? "legacy archives (no source dir)" : source_dir
|
|
347
|
+
@logger&.info("Processing source directory: #{source_desc} (#{archives.size} archives)")
|
|
348
|
+
|
|
349
|
+
# Create a temporary prefix to filter this directory's archives
|
|
350
|
+
# Since we can't directly use borg prune with filtering, we need to delete individually
|
|
351
|
+
archives_to_keep = apply_retention_policy(archives, retention_policy)
|
|
352
|
+
archives_to_delete = archives.map { |a| a[:name] } - archives_to_keep.map { |a| a[:name] }
|
|
353
|
+
|
|
354
|
+
next if archives_to_delete.empty?
|
|
355
|
+
|
|
356
|
+
@logger&.info("Pruning #{archives_to_delete.size} archive(s) from #{source_desc}")
|
|
357
|
+
|
|
358
|
+
archives_to_delete.each do |archive_name|
|
|
359
|
+
@logger&.debug("Deleting archive: #{archive_name}")
|
|
360
|
+
delete_archive(archive_name)
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
total_pruned += archives_to_delete.size
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
if total_pruned.zero?
|
|
367
|
+
@logger&.info("No archives to prune")
|
|
368
|
+
puts "No archives to prune"
|
|
369
|
+
else
|
|
370
|
+
@logger&.info("Pruned #{total_pruned} archive(s) total across all source directories")
|
|
371
|
+
puts "Pruned #{total_pruned} archive(s) across all source directories"
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def apply_retention_policy(archives, policy)
|
|
376
|
+
# Sort archives by time (newest first)
|
|
377
|
+
sorted = archives.sort_by { |a| a[:time] }.reverse
|
|
378
|
+
to_keep = []
|
|
379
|
+
|
|
380
|
+
# Apply keep_last first (if specified)
|
|
381
|
+
to_keep += sorted.take(policy["keep_last"]) if policy["keep_last"]
|
|
382
|
+
|
|
383
|
+
# Apply time-based retention (keep_within)
|
|
384
|
+
if policy["keep_within"]
|
|
385
|
+
cutoff = Time.now - parse_time_duration(policy["keep_within"])
|
|
386
|
+
to_keep += sorted.select { |a| a[:time] >= cutoff }
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# Apply count-based retention (keep_daily, keep_weekly, etc.)
|
|
390
|
+
# Group archives by time period and keep the newest from each period
|
|
391
|
+
%w[hourly daily weekly monthly yearly].each do |period|
|
|
392
|
+
keep_count = policy["keep_#{period}"]
|
|
393
|
+
next unless keep_count
|
|
394
|
+
|
|
395
|
+
case period
|
|
396
|
+
when "hourly"
|
|
397
|
+
grouped = sorted.group_by { |a| a[:time].strftime("%Y-%m-%d-%H") }
|
|
398
|
+
when "daily"
|
|
399
|
+
grouped = sorted.group_by { |a| a[:time].strftime("%Y-%m-%d") }
|
|
400
|
+
when "weekly"
|
|
401
|
+
grouped = sorted.group_by { |a| a[:time].strftime("%Y-W%W") }
|
|
402
|
+
when "monthly"
|
|
403
|
+
grouped = sorted.group_by { |a| a[:time].strftime("%Y-%m") }
|
|
404
|
+
when "yearly"
|
|
405
|
+
grouped = sorted.group_by { |a| a[:time].strftime("%Y") }
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# Keep the newest archive from each of the most recent N periods
|
|
409
|
+
grouped.keys.sort.reverse.take(keep_count.to_i).each do |key|
|
|
410
|
+
to_keep << grouped[key].first
|
|
411
|
+
end
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
to_keep.uniq { |a| a[:name] }
|
|
415
|
+
end
|
|
416
|
+
|
|
266
417
|
def delete_archive(archive_name)
|
|
267
418
|
cmd = [@borg_path, "delete", "#{@path}::#{archive_name}"]
|
|
268
419
|
execute_borg_command(cmd)
|
data/lib/ruborg/version.rb
CHANGED
data/ruborg.gemspec
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/ruborg/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "ruborg"
|
|
7
|
+
spec.version = Ruborg::VERSION
|
|
8
|
+
spec.authors = ["Michail Pantelelis"]
|
|
9
|
+
spec.email = ["mpantel@aegean.gr"]
|
|
10
|
+
|
|
11
|
+
spec.summary = "A friendly Ruby frontend for Borg backup"
|
|
12
|
+
spec.description = "Ruborg provides a user-friendly interface to Borg backup. " \
|
|
13
|
+
"It reads YAML configuration files and orchestrates backup operations, " \
|
|
14
|
+
"supporting repository creation, backup management, and Passbolt integration."
|
|
15
|
+
spec.homepage = "https://github.com/mpantel/ruborg"
|
|
16
|
+
spec.license = "MIT"
|
|
17
|
+
spec.required_ruby_version = ">= 3.2.0"
|
|
18
|
+
|
|
19
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
|
20
|
+
spec.metadata["source_code_uri"] = "#{spec.homepage}.git"
|
|
21
|
+
spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
|
|
22
|
+
spec.metadata["rubygems_mfa_required"] = "true"
|
|
23
|
+
|
|
24
|
+
# Specify which files should be added to the gem when it is released.
|
|
25
|
+
spec.files = Dir.chdir(__dir__) do
|
|
26
|
+
`git ls-files -z`.split("\x0").reject do |f|
|
|
27
|
+
(File.expand_path(f) == __FILE__) ||
|
|
28
|
+
f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile])
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
spec.bindir = "exe"
|
|
32
|
+
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
|
33
|
+
spec.require_paths = ["lib"]
|
|
34
|
+
|
|
35
|
+
# Dependencies
|
|
36
|
+
spec.add_dependency "psych", "~> 5.0"
|
|
37
|
+
spec.add_dependency "thor", "~> 1.3"
|
|
38
|
+
|
|
39
|
+
# Development dependencies
|
|
40
|
+
spec.add_development_dependency "bundler", "~> 2.0"
|
|
41
|
+
spec.add_development_dependency "bundler-audit", "~> 0.9"
|
|
42
|
+
spec.add_development_dependency "rake", "~> 13.0"
|
|
43
|
+
spec.add_development_dependency "rspec", "~> 3.0"
|
|
44
|
+
spec.add_development_dependency "rubocop", "~> 1.0"
|
|
45
|
+
spec.add_development_dependency "rubocop-rspec", "~> 3.0"
|
|
46
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ruborg
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.8.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Michail Pantelelis
|
|
@@ -137,6 +137,7 @@ files:
|
|
|
137
137
|
- CHANGELOG.md
|
|
138
138
|
- CLAUDE.md
|
|
139
139
|
- LICENSE
|
|
140
|
+
- PER_DIRECTORY_RETENTION.md
|
|
140
141
|
- README.md
|
|
141
142
|
- Rakefile
|
|
142
143
|
- SECURITY.md
|
|
@@ -149,6 +150,7 @@ files:
|
|
|
149
150
|
- lib/ruborg/passbolt.rb
|
|
150
151
|
- lib/ruborg/repository.rb
|
|
151
152
|
- lib/ruborg/version.rb
|
|
153
|
+
- ruborg.gemspec
|
|
152
154
|
- ruborg.yml.example
|
|
153
155
|
homepage: https://github.com/mpantel/ruborg
|
|
154
156
|
licenses:
|