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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8f3c9f239a72ec2f321419eb6f4349c25fe4cec4ca5174c156e2f1d3fe84ec68
|
|
4
|
+
data.tar.gz: 4930dbbc8dc2d4e2040628a8c57e62d7bf3ac20ca58a0e7e363a649f6f8d6546
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ba9bd867f83d24c88abf435f4b64368b9c414080290747f107a6b5e94db6e82ddba0a59b0f700a4625e689b54724aefceecccc56f5e51f3fc8271be74601bc5e
|
|
7
|
+
data.tar.gz: e95dd232d73c7c63968c0105170928d0ab28d689342ad875a81fbc30ac56d508179a1866b476b79125fe43a85fc69cc68baef14a45cd220019be5612ce458bea
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,91 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.9.3] - 2026-05-09
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **`ruborg lock` command**: Check for and optionally break stale Borg repository locks
|
|
14
|
+
- `ruborg lock --repository NAME` — exits 0 if no lock, exits 1 if lock detected
|
|
15
|
+
- `ruborg lock --repository NAME --break --yes` — breaks the lock via `borg break-lock`
|
|
16
|
+
- `ruborg lock --repository NAME --force --yes` — force-removes lock files directly without invoking Borg (useful when Borg itself can't run)
|
|
17
|
+
- `--break` and `--force` are mutually exclusive; both require `--yes` as a safety guard
|
|
18
|
+
- `Repository#locked?` — pure filesystem check on `lock.exclusive` / `lock.roster`, no Borg invocation or passphrase required
|
|
19
|
+
- `Repository#break_lock` — delegates to `borg break-lock`; requires Borg >= 1.4.0
|
|
20
|
+
- `Repository#force_break_lock` — direct filesystem removal of lock files/dirs; no Borg needed
|
|
21
|
+
- Status output (lock present/absent) goes to stdout for scriptability; warning messages go to `$stderr`
|
|
22
|
+
- **Pre-flight lock detection during backup**: If a repository is locked when `backup` starts, ruborg waits and retries instead of failing immediately
|
|
23
|
+
- Polls every 5 seconds, prints elapsed time via the spinner
|
|
24
|
+
- Aborts with a clear error message after `lock_wait` seconds (default 300), suggesting `ruborg lock` to inspect or clear
|
|
25
|
+
- **`lock_wait` config key**: Optional integer (seconds). When set, also passed as `--lock-wait` to all Borg commands so Borg itself waits for mid-operation locks. Omitting the key leaves Borg at its own default (1 second)
|
|
26
|
+
- **Minimum Borg version**: Raised to 1.4.0; `break_lock` verifies this before invoking `borg break-lock`
|
|
27
|
+
- **`CLI::DEFAULT_LOCK_WAIT = 300`**: Named constant for the pre-flight wait timeout
|
|
28
|
+
- Fixes [#8](https://github.com/mpantel/ruborg/issues/8)
|
|
29
|
+
|
|
30
|
+
## [0.9.2] - 2026-05-09
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
- **CLI progress display**: Real-time feedback during backup operations
|
|
34
|
+
- Named stages printed to `$stderr`: `[1/3] Verifying repository`, `[2/3] Backing up files`, `[3/3] Pruning`
|
|
35
|
+
- Animated spinner for indeterminate operations (cache loading, `borg create`, pruning)
|
|
36
|
+
- Inline progress bar for per-file backup mode: `[=========> ] 42/120 filename.jpg`
|
|
37
|
+
- Stage count adapts to the operation: 2 stages without pruning, 3 with
|
|
38
|
+
- Degrades gracefully to plain text lines when output is piped or redirected (non-TTY)
|
|
39
|
+
- All progress output goes to `$stderr` — `--json` stdout and piped output remain clean
|
|
40
|
+
- No external gem dependencies — pure Ruby with ANSI `\r` rewrite
|
|
41
|
+
- Fixes [#6](https://github.com/mpantel/ruborg/issues/6)
|
|
42
|
+
|
|
43
|
+
## [0.9.1] - 2026-05-09
|
|
44
|
+
|
|
45
|
+
### Added
|
|
46
|
+
- **Archive metadata cache**: New `ArchiveCache` class eliminates N+1 `borg info` calls during per-file backup runs
|
|
47
|
+
- Metadata cached in `<repo_path>.ruborg_cache.json` — sibling file to the repository
|
|
48
|
+
- Cache is shared across machines: any host with access to the repo path shares the same cache
|
|
49
|
+
- Local repos use `File::LOCK_EX` for safe concurrent reads/writes with merge-on-conflict
|
|
50
|
+
- SSH repos (`user@host:/path`, `ssh://user@host/path`) fetch/push the cache via `scp` with optimistic locking (fetch-fresh → merge → push), avoiding deadlocks on process crashes
|
|
51
|
+
- Only archives not yet in cache trigger a `borg info` call; warm runs reduce subprocess overhead from O(n) to O(new archives)
|
|
52
|
+
- Fixes [#4](https://github.com/mpantel/ruborg/issues/4)
|
|
53
|
+
- **Catalog command**: New `ruborg catalog` command for fast, offline browsing of backed-up files
|
|
54
|
+
- Reads the local cache file — no `borg` subprocess calls needed
|
|
55
|
+
- `--search PATTERN` — filter entries by file path using a regex
|
|
56
|
+
- `--stats` — show aggregate statistics (total archives, unique files, total size, source dirs)
|
|
57
|
+
- `--json` — machine-readable JSON output; default is a human-friendly text table
|
|
58
|
+
- Works per-repository like all other commands (`--repository`)
|
|
59
|
+
- Supports SSH repos transparently via the same `scp`-based cache fetch
|
|
60
|
+
- **Bug fix**: `ArchiveCache` now normalises all loaded metadata to symbol keys, ensuring cache hits and cache misses return identical key types
|
|
61
|
+
|
|
62
|
+
## [0.9.0] - 2025-10-14
|
|
63
|
+
|
|
64
|
+
### Changed
|
|
65
|
+
- **Command Consolidation**: Unified validation commands for consistency
|
|
66
|
+
- `ruborg validate` renamed to `ruborg validate config` (validates YAML configuration)
|
|
67
|
+
- `ruborg check` renamed to `ruborg validate repo` (validates repository compatibility and integrity)
|
|
68
|
+
- Both commands now use consistent `validate` terminology
|
|
69
|
+
- `--verify-data` option remains available for `validate repo` to run full integrity checks
|
|
70
|
+
- Eliminates confusion between `validate` (config) and `check` (repository)
|
|
71
|
+
- Updated README with new command syntax and examples
|
|
72
|
+
- Updated all tests to use new command format
|
|
73
|
+
|
|
74
|
+
### Added
|
|
75
|
+
- **Skip Hash Check Option**: New `skip_hash_check` configuration option for faster per-file backups
|
|
76
|
+
- Skips expensive SHA256 content hash calculation when file already exists
|
|
77
|
+
- Trusts file path, size, and modification time matching for duplicate detection
|
|
78
|
+
- Significantly speeds up backups with many unchanged files
|
|
79
|
+
- Configurable globally or per-repository
|
|
80
|
+
- Default: `false` (paranoid mode - always verify content hash)
|
|
81
|
+
- Use case: Large directories where mtime changes are reliable (most filesystems)
|
|
82
|
+
- Example: `skip_hash_check: true` in YAML configuration
|
|
83
|
+
- **Migration Help**: `ruborg check` now displays a helpful deprecation notice
|
|
84
|
+
- Shows clear message explaining the command has been renamed
|
|
85
|
+
- Provides examples of the new `ruborg validate repo` syntax
|
|
86
|
+
- Exits with error to prevent confusion
|
|
87
|
+
- Logs deprecation warning for audit trail
|
|
88
|
+
- **Enhanced Version Command**: `ruborg version` now shows both Ruborg and Borg versions with path
|
|
89
|
+
- Displays Ruborg version (gem version)
|
|
90
|
+
- Displays installed Borg version and executable path
|
|
91
|
+
- Example output: `borg 1.2.8 (/usr/local/bin/borg)`
|
|
92
|
+
- Gracefully handles missing Borg installation
|
|
93
|
+
- Helps users verify both tool versions and location at a glance
|
|
94
|
+
|
|
10
95
|
## [0.8.1] - 2025-10-09
|
|
11
96
|
|
|
12
97
|
### Added
|
data/README.md
CHANGED
|
@@ -25,13 +25,15 @@ 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
|
-
-
|
|
28
|
+
- 🔒 **Lock Management** - Detect and break stale Borg repository locks with `ruborg lock`
|
|
29
|
+
- ⏳ **Lock-aware Backups** - Pre-flight lock detection with configurable wait timeout before backup
|
|
30
|
+
- ✅ **Well-tested** - Comprehensive test suite with RSpec (412 examples, 0 failures)
|
|
29
31
|
- 🔒 **Security-focused** - Path validation, safe YAML loading, command injection protection
|
|
30
32
|
|
|
31
33
|
## Prerequisites
|
|
32
34
|
|
|
33
35
|
- Ruby >= 3.2.0
|
|
34
|
-
- [Borg Backup](https://www.borgbackup.org/) installed and available in PATH
|
|
36
|
+
- [Borg Backup](https://www.borgbackup.org/) >= 1.4.0 installed and available in PATH
|
|
35
37
|
- [Passbolt CLI](https://github.com/passbolt/go-passbolt-cli) (optional, for password management)
|
|
36
38
|
|
|
37
39
|
### Installing Borg Backup
|
|
@@ -163,13 +165,15 @@ repositories:
|
|
|
163
165
|
|
|
164
166
|
**Configuration Features:**
|
|
165
167
|
- **Automatic Type Validation**: Configuration is validated on startup to catch type errors early
|
|
166
|
-
- **Validation Command**: Run `ruborg validate` to check configuration files for errors
|
|
168
|
+
- **Validation Command**: Run `ruborg validate config` to check configuration files for errors
|
|
167
169
|
- **Descriptions**: Add `description` field to document each repository's purpose
|
|
168
170
|
- **Hostname Validation**: Optional `hostname` field to restrict backups to specific hosts (global or per-repository)
|
|
169
171
|
- **Source Deletion Safety**: `allow_remove_source` flag to explicitly enable `--remove-source` option (default: disabled)
|
|
172
|
+
- **Skip Hash Check**: Optional `skip_hash_check` flag to skip content hash verification for faster backups (per-file mode only)
|
|
170
173
|
- **Type-Safe Booleans**: Strict boolean validation prevents configuration errors (must use `true`/`false`, not strings)
|
|
171
|
-
- **
|
|
172
|
-
- **
|
|
174
|
+
- **Lock Wait Timeout**: Optional `lock_wait` (integer, seconds) — how long ruborg waits for a locked repository before aborting. Also passed as `--lock-wait` to Borg when set. Default: 300 seconds (pre-flight), Borg default: 1 second (when not configured)
|
|
175
|
+
- **Global Settings**: Hostname, compression, encryption, auto_init, allow_remove_source, skip_hash_check, lock_wait, log_file, borg_path, borg_options, and retention apply to all repositories
|
|
176
|
+
- **Per-Repository Overrides**: Any global setting can be overridden at the repository level (including hostname, allow_remove_source, skip_hash_check, lock_wait, and custom borg_path)
|
|
173
177
|
- **Custom Borg Path**: Specify a custom Borg executable path if borg is not in PATH or to use a specific version
|
|
174
178
|
- **Retention Policies**: Define how many backups to keep (hourly, daily, weekly, monthly, yearly)
|
|
175
179
|
- **Multiple Sources**: Each repository can have multiple backup sources with their own exclude patterns
|
|
@@ -184,7 +188,7 @@ Ruborg automatically validates your configuration on startup. All commands check
|
|
|
184
188
|
Check your configuration file for errors:
|
|
185
189
|
|
|
186
190
|
```bash
|
|
187
|
-
ruborg validate --config ruborg.yml
|
|
191
|
+
ruborg validate config --config ruborg.yml
|
|
188
192
|
```
|
|
189
193
|
|
|
190
194
|
**Validation checks:**
|
|
@@ -471,20 +475,52 @@ Group: postgres
|
|
|
471
475
|
Type: regular file
|
|
472
476
|
```
|
|
473
477
|
|
|
474
|
-
###
|
|
478
|
+
### Manage Repository Locks
|
|
479
|
+
|
|
480
|
+
Borg uses lock files to prevent concurrent access. If a backup crashes, stale locks can block all subsequent operations. Use `ruborg lock` to inspect and clear them.
|
|
481
|
+
|
|
482
|
+
```bash
|
|
483
|
+
# Check if a repository is locked (exits 0 = no lock, 1 = locked)
|
|
484
|
+
ruborg lock --repository documents
|
|
485
|
+
|
|
486
|
+
# Break the lock via borg break-lock (requires Borg >= 1.4.0)
|
|
487
|
+
ruborg lock --repository documents --break --yes
|
|
488
|
+
|
|
489
|
+
# Force-remove lock files directly (no Borg required, last resort)
|
|
490
|
+
ruborg lock --repository documents --force --yes
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
**Lock-aware backups:** When `ruborg backup` starts and detects a lock, it waits up to `lock_wait` seconds (default 300) for the lock to clear before aborting:
|
|
494
|
+
|
|
495
|
+
```
|
|
496
|
+
[1/2] Verifying repository: documents
|
|
497
|
+
Repository locked — waiting for lock to clear (5s / 300s)…
|
|
498
|
+
Repository locked — waiting for lock to clear (10s / 300s)…
|
|
499
|
+
✓ Lock cleared
|
|
500
|
+
[2/2] Creating archive
|
|
501
|
+
```
|
|
502
|
+
|
|
503
|
+
Configure the timeout in `ruborg.yml` (also passed as `--lock-wait` to Borg when set):
|
|
504
|
+
|
|
505
|
+
```yaml
|
|
506
|
+
# Wait up to 60s for a lock before aborting; also passes --lock-wait 60 to borg commands
|
|
507
|
+
lock_wait: 60
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
### Validate Repository Compatibility
|
|
475
511
|
|
|
476
512
|
```bash
|
|
477
513
|
# Check specific repository compatibility with installed Borg version
|
|
478
|
-
ruborg
|
|
514
|
+
ruborg validate repo --repository documents
|
|
479
515
|
|
|
480
516
|
# Check all repositories
|
|
481
|
-
ruborg
|
|
517
|
+
ruborg validate repo --all
|
|
482
518
|
|
|
483
519
|
# Check with data integrity verification (slower)
|
|
484
|
-
ruborg
|
|
520
|
+
ruborg validate repo --repository documents --verify-data
|
|
485
521
|
```
|
|
486
522
|
|
|
487
|
-
The `
|
|
523
|
+
The `validate repo` command verifies:
|
|
488
524
|
- Installed Borg version
|
|
489
525
|
- Repository format version
|
|
490
526
|
- Compatibility between Borg and repository versions
|
|
@@ -494,11 +530,11 @@ The `check` command verifies:
|
|
|
494
530
|
```
|
|
495
531
|
Borg version: 1.2.8
|
|
496
532
|
|
|
497
|
-
---
|
|
533
|
+
--- Validating repository: documents ---
|
|
498
534
|
Repository version: 1
|
|
499
535
|
✓ Compatible with Borg 1.2.8
|
|
500
536
|
|
|
501
|
-
---
|
|
537
|
+
--- Validating repository: databases ---
|
|
502
538
|
Repository version: 2
|
|
503
539
|
✗ INCOMPATIBLE with Borg 1.2.8
|
|
504
540
|
Repository version 2 cannot be read by Borg 1.2.8
|
|
@@ -652,26 +688,27 @@ See [SECURITY.md](SECURITY.md) for detailed security information and best practi
|
|
|
652
688
|
| Command | Description | Options |
|
|
653
689
|
|---------|-------------|---------|
|
|
654
690
|
| `init REPOSITORY` | Initialize a new Borg repository | `--passphrase`, `--passbolt-id`, `--log` |
|
|
655
|
-
| `validate` | Validate configuration file for type errors | `--config`, `--log` |
|
|
691
|
+
| `validate config` | Validate configuration file for type errors | `--config`, `--log` |
|
|
692
|
+
| `validate repo` | Validate repository compatibility and integrity | `--config`, `--repository`, `--all`, `--verify-data`, `--log` |
|
|
656
693
|
| `backup` | Create a backup using config file | `--config`, `--repository`, `--all`, `--name`, `--remove-source`, `--log` |
|
|
657
694
|
| `list` | List archives or files in repository | `--config`, `--repository`, `--archive`, `--log` |
|
|
658
695
|
| `restore ARCHIVE` | Restore files from archive | `--config`, `--repository`, `--destination`, `--path`, `--log` |
|
|
659
696
|
| `metadata ARCHIVE` | Get file metadata from archive | `--config`, `--repository`, `--file`, `--log` |
|
|
697
|
+
| `lock` | Check for and optionally break a repository lock | `--config`, `--repository`, `--break`, `--force`, `--yes`, `--log` |
|
|
660
698
|
| `info` | Show repository information | `--config`, `--repository`, `--log` |
|
|
661
|
-
| `check` | Check repository integrity and compatibility | `--config`, `--repository`, `--all`, `--verify-data`, `--log` |
|
|
662
699
|
| `version` | Show ruborg version | None |
|
|
663
700
|
|
|
664
701
|
### Options
|
|
665
702
|
|
|
666
703
|
- `--config`: Path to configuration file (default: `ruborg.yml`)
|
|
667
704
|
- `--log`: Path to log file (overrides config, default: `~/.ruborg/logs/ruborg.log`)
|
|
668
|
-
- `--repository` / `-r`: Repository name (optional for info, required for backup/list/restore/
|
|
669
|
-
- `--all`: Process all repositories (backup and
|
|
705
|
+
- `--repository` / `-r`: Repository name (optional for info, required for backup/list/restore/validate repo unless --all)
|
|
706
|
+
- `--all`: Process all repositories (backup and validate repo commands)
|
|
670
707
|
- `--name`: Custom archive name (backup command only)
|
|
671
708
|
- `--remove-source`: Remove source files after successful backup (backup command only)
|
|
672
709
|
- `--destination`: Destination directory for restore (restore command only)
|
|
673
710
|
- `--path`: Specific file or directory to restore (restore command only)
|
|
674
|
-
- `--verify-data`: Run full data integrity check (
|
|
711
|
+
- `--verify-data`: Run full data integrity check (validate repo command only, slower)
|
|
675
712
|
|
|
676
713
|
## Retention Policies
|
|
677
714
|
|
|
@@ -822,6 +859,60 @@ repositories:
|
|
|
822
859
|
|
|
823
860
|
**Backup vs Retention:** The per-file `retention_mode` only affects how archives are created and pruned. Traditional backup commands still work normally - you can list, restore, and check per-file archives just like standard archives.
|
|
824
861
|
|
|
862
|
+
### Skip Hash Check for Faster Backups
|
|
863
|
+
|
|
864
|
+
**NEW:** In per-file backup mode, you can optionally skip content hash verification for faster duplicate detection:
|
|
865
|
+
|
|
866
|
+
```yaml
|
|
867
|
+
repositories:
|
|
868
|
+
- name: project-files
|
|
869
|
+
path: /mnt/backup/project-files
|
|
870
|
+
retention_mode: per_file
|
|
871
|
+
skip_hash_check: true # Skip SHA256 content hash verification
|
|
872
|
+
sources:
|
|
873
|
+
- name: projects
|
|
874
|
+
paths:
|
|
875
|
+
- /home/user/projects
|
|
876
|
+
```
|
|
877
|
+
|
|
878
|
+
**How it works:**
|
|
879
|
+
- **Default (paranoid mode)**: Ruborg calculates SHA256 hash of file content to verify files haven't changed (even when size and mtime are identical)
|
|
880
|
+
- **With skip_hash_check: true**: Ruborg trusts file path, size, and modification time for duplicate detection (skips hash calculation)
|
|
881
|
+
|
|
882
|
+
**When to use:**
|
|
883
|
+
- ✅ **Large directories** with thousands of files where hash calculation is slow
|
|
884
|
+
- ✅ **Reliable filesystems** where modification time changes are trustworthy
|
|
885
|
+
- ✅ **Regular backups** where files are unlikely to be manually modified with `touch -t`
|
|
886
|
+
|
|
887
|
+
**When NOT to use:**
|
|
888
|
+
- ❌ **Security-critical data** where you want maximum verification
|
|
889
|
+
- ❌ **Untrusted sources** where files might be tampered with
|
|
890
|
+
- ❌ **Systems with unreliable mtime** (rare, but some network filesystems)
|
|
891
|
+
|
|
892
|
+
**Performance impact:**
|
|
893
|
+
```yaml
|
|
894
|
+
# Example: 10,000 unchanged files, average 50KB each
|
|
895
|
+
# With skip_hash_check: false (default) - ~30 seconds (read + hash all files)
|
|
896
|
+
# With skip_hash_check: true - ~3 seconds (read metadata only)
|
|
897
|
+
```
|
|
898
|
+
|
|
899
|
+
**Console output:**
|
|
900
|
+
```
|
|
901
|
+
# With skip_hash_check: true
|
|
902
|
+
[1/10000] Backing up: /home/user/file1.txt - Archive already exists (skipped hash check)
|
|
903
|
+
[2/10000] Backing up: /home/user/file2.txt - Archive already exists (skipped hash check)
|
|
904
|
+
...
|
|
905
|
+
✓ Per-file backup completed: 50 file(s) backed up, 9950 skipped (hash check skipped)
|
|
906
|
+
|
|
907
|
+
# With skip_hash_check: false (default)
|
|
908
|
+
[1/10000] Backing up: /home/user/file1.txt - Archive already exists (file unchanged)
|
|
909
|
+
[2/10000] Backing up: /home/user/file2.txt - Archive already exists (file unchanged)
|
|
910
|
+
...
|
|
911
|
+
✓ Per-file backup completed: 50 file(s) backed up, 9950 skipped (unchanged)
|
|
912
|
+
```
|
|
913
|
+
|
|
914
|
+
**Security note:** Even with `skip_hash_check: true`, files are still verified by path, size, and mtime. The only difference is skipping the SHA256 content hash verification, which catches rare edge cases like manual file tampering with preserved timestamps.
|
|
915
|
+
|
|
825
916
|
### Automatic Pruning
|
|
826
917
|
|
|
827
918
|
Enable **automatic pruning** to remove old backups after each backup operation:
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "open3"
|
|
5
|
+
require "tempfile"
|
|
6
|
+
|
|
7
|
+
module Ruborg
|
|
8
|
+
# Persistent cache of per-archive metadata, stored as a JSON file sibling to the
|
|
9
|
+
# Borg repository. Eliminates repeated `borg info` calls across runs.
|
|
10
|
+
#
|
|
11
|
+
# Supports local paths (File::LOCK_EX) and SSH paths (optimistic merge via scp).
|
|
12
|
+
# All metadata is stored and returned with symbol keys (:path, :size, :hash, :source_dir).
|
|
13
|
+
class ArchiveCache
|
|
14
|
+
SSH_PATTERN = %r{\A(?:ssh://|[^\s/]+@[^\s:]+:)}
|
|
15
|
+
|
|
16
|
+
def initialize(repo_path)
|
|
17
|
+
@repo_path = repo_path
|
|
18
|
+
@data = {}
|
|
19
|
+
@snapshot = {}
|
|
20
|
+
@loaded = false
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def fetch
|
|
24
|
+
return self if @loaded
|
|
25
|
+
|
|
26
|
+
if ssh?
|
|
27
|
+
load_remote
|
|
28
|
+
else
|
|
29
|
+
load_local
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
@snapshot = snapshot(@data)
|
|
33
|
+
@loaded = true
|
|
34
|
+
self
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def [](archive_name)
|
|
38
|
+
@data[archive_name]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def store(archive_name, metadata)
|
|
42
|
+
@data[archive_name] = symbolize(metadata)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Returns all cached entries as an array of hashes, each including :archive_name.
|
|
46
|
+
def entries
|
|
47
|
+
@data.map { |archive_name, metadata| metadata.merge(archive_name: archive_name) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def save_if_changed
|
|
51
|
+
return unless dirty?
|
|
52
|
+
|
|
53
|
+
if ssh?
|
|
54
|
+
save_remote
|
|
55
|
+
else
|
|
56
|
+
save_local
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def dirty?
|
|
63
|
+
@data != @snapshot
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def snapshot(hash)
|
|
67
|
+
hash.transform_values(&:dup)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def ssh?
|
|
71
|
+
SSH_PATTERN.match?(@repo_path)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def cache_path_for(path)
|
|
75
|
+
"#{path}.ruborg_cache.json"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def symbolize(metadata)
|
|
79
|
+
metadata.transform_keys(&:to_sym)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def normalize_archives(raw)
|
|
83
|
+
(raw || {}).transform_values { |v| symbolize(v) }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def load_local
|
|
87
|
+
path = cache_path_for(@repo_path)
|
|
88
|
+
return unless File.exist?(path)
|
|
89
|
+
|
|
90
|
+
File.open(path, "r") do |f|
|
|
91
|
+
f.flock(File::LOCK_SH)
|
|
92
|
+
parsed = JSON.parse(f.read)
|
|
93
|
+
@data = normalize_archives(parsed["archives"])
|
|
94
|
+
end
|
|
95
|
+
rescue JSON::ParserError
|
|
96
|
+
@data = {}
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def save_local
|
|
100
|
+
path = cache_path_for(@repo_path)
|
|
101
|
+
File.open(path, File::RDWR | File::CREAT, 0o600) do |f|
|
|
102
|
+
f.flock(File::LOCK_EX)
|
|
103
|
+
existing = read_existing_local(f)
|
|
104
|
+
merged = existing.merge(@data)
|
|
105
|
+
f.rewind
|
|
106
|
+
f.write(JSON.generate({ "version" => 1, "archives" => stringify_for_storage(merged) }))
|
|
107
|
+
f.truncate(f.pos)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def read_existing_local(file)
|
|
112
|
+
content = file.read
|
|
113
|
+
return {} if content.empty?
|
|
114
|
+
|
|
115
|
+
normalize_archives(JSON.parse(content)["archives"])
|
|
116
|
+
rescue JSON::ParserError
|
|
117
|
+
{}
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# JSON requires string keys; convert symbol keys back before writing.
|
|
121
|
+
def stringify_for_storage(data)
|
|
122
|
+
data.transform_values { |v| v.transform_keys(&:to_s) }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def parse_ssh
|
|
126
|
+
if @repo_path.start_with?("ssh://")
|
|
127
|
+
require "uri"
|
|
128
|
+
uri = URI.parse(@repo_path)
|
|
129
|
+
host = uri.user ? "#{uri.user}@#{uri.host}" : uri.host
|
|
130
|
+
host = "#{host}:#{uri.port}" if uri.port && uri.port != 22
|
|
131
|
+
[host, uri.path]
|
|
132
|
+
else
|
|
133
|
+
match = @repo_path.match(%r{\A([^\s/]+@[^\s:]+):(.+)\z})
|
|
134
|
+
return [nil, nil] unless match
|
|
135
|
+
|
|
136
|
+
[match[1], match[2]]
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def load_remote
|
|
141
|
+
host, path = parse_ssh
|
|
142
|
+
return unless host
|
|
143
|
+
|
|
144
|
+
remote = "#{host}:#{cache_path_for(path)}"
|
|
145
|
+
loaded = nil
|
|
146
|
+
Tempfile.create(["ruborg_cache", ".json"]) do |tmp|
|
|
147
|
+
_, status = Open3.capture2e("scp", "-q", "-B", remote, tmp.path)
|
|
148
|
+
next unless status.success?
|
|
149
|
+
|
|
150
|
+
begin
|
|
151
|
+
loaded = normalize_archives(JSON.parse(File.read(tmp.path))["archives"])
|
|
152
|
+
rescue JSON::ParserError
|
|
153
|
+
loaded = {}
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
@data = loaded if loaded
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def save_remote
|
|
160
|
+
host, path = parse_ssh
|
|
161
|
+
return unless host
|
|
162
|
+
|
|
163
|
+
remote = "#{host}:#{cache_path_for(path)}"
|
|
164
|
+
fresh = fetch_remote_fresh(remote)
|
|
165
|
+
merged = fresh.merge(@data)
|
|
166
|
+
|
|
167
|
+
Tempfile.create(["ruborg_cache_upload", ".json"]) do |tmp|
|
|
168
|
+
tmp.write(JSON.generate({ "version" => 1, "archives" => stringify_for_storage(merged) }))
|
|
169
|
+
tmp.flush
|
|
170
|
+
Open3.capture2e("scp", "-q", "-B", tmp.path, remote)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def fetch_remote_fresh(remote)
|
|
175
|
+
result = {}
|
|
176
|
+
Tempfile.create(["ruborg_cache_fresh", ".json"]) do |tmp|
|
|
177
|
+
_, status = Open3.capture2e("scp", "-q", "-B", remote, tmp.path)
|
|
178
|
+
next unless status.success?
|
|
179
|
+
|
|
180
|
+
begin
|
|
181
|
+
result = normalize_archives(JSON.parse(File.read(tmp.path))["archives"])
|
|
182
|
+
rescue JSON::ParserError
|
|
183
|
+
result = {}
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
result
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|