backup-buddy 1.0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 551ed4281803d9740f6a86a062db1b2eaa3902501833876c98bc9306000beb36
4
+ data.tar.gz: 47a1432ee75a7af44f8575885f9609981f3dc1963a873f289abbbb33621527bc
5
+ SHA512:
6
+ metadata.gz: decd8a7182028a4e376b300bdae79d10e75fdce40096621d2ff074a923ba4eb21498fcd8af1b76136ec3f131f7964b76e05633515ca1ca3574178d410651aa52
7
+ data.tar.gz: 107c6461c017ae6e72dc6c25714242a619c3a0f54cbfc82a7622b6b3f1834d085ecdc47de81b0eacfbea8ce4a26b16f7a0cf47db6d505a605b7d4b61509fdc56
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Egee
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,72 @@
1
+ # Backup-Buddy
2
+
3
+ Backup (or restore) your things with Rsync and Ruby!
4
+
5
+ Backup-Buddy is a dumb harness around rsync. It parses a YAML manifest, spawns lightweight rsync tasks, and runs them through a thread pool. The Ruby GIL releases during I/O, so all rsync processes genuinely run in parallel!
6
+
7
+ ## What It Does
8
+
9
+ Backup-buddy reads in a YAML manifest and feeds the paths into a worker thread pool. If you got 10 folders and a concurrency of 3, it runs 3 rsyncs at a time until all 10 are done.
10
+
11
+ Since rsync is symmetric, you can also use Backup-buddy as a **restore tool** — just swap the source and destination in your manifest!
12
+
13
+ From this:
14
+
15
+ ```yaml
16
+ ---
17
+ name: "My Backup"
18
+ backupDestination: "your@nas.local:/path/to/backup_destination"
19
+ backupPaths:
20
+ - "/path/to/dir_or_file"
21
+ ```
22
+
23
+ To this:
24
+
25
+ ```yaml
26
+ ---
27
+ name: "My Restore"
28
+ backupDestination: "/path/to/restore_destination"
29
+ backupPaths:
30
+ - "your@nas.local:/path/to/backup_dir"
31
+ ```
32
+
33
+ Canonically, it is a backup tool. Semantically, whether it's a "backup" or a "restore" is up to the eyes of the beholder.
34
+
35
+ ## What It Doesn't
36
+
37
+ Backup-Buddy is designed specifically to be a dumb harness that spawns rsync workers using a yaml manifest and a cli as the entry point.
38
+
39
+ It does **not** manage SSH connections, users, or permissions - it simply invokes rsync and gets out of the way. Configure your remote hosts in `~/.ssh/config` and rsync will pick them up automatically.
40
+
41
+ It also does not compress or otherwise archive files - it simply copies them from one place to another. The previous version had a compression feature but it was complicated and I never used it so I removed it.
42
+
43
+ ## How It Started
44
+
45
+ Every once in a while, I back up all my files. I got tired of doing it manually so I started using rsync. Then, I got tired of typing rsync so I scripted it. Then, I got tired of the script so I wrote a program to do it all for me.
46
+
47
+ The first version was written in NodeJS so we got async for free. However, I quickly learned that without concurrency limits, rsync will happily take your system down. I like Ruby a bit better than JavaScript so I rewrote it to use a thread pool.
48
+
49
+ ## Requirements
50
+
51
+ - **Ruby** >= 3.1
52
+ - **rsync** available to your system
53
+
54
+ ## SSH Configuration
55
+
56
+ Backup-Buddy delegates all connection handling to rsync, which reads your `~/.ssh/config`. Set up your hosts there and then reference the host in your manifest paths.
57
+
58
+ ## Installation
59
+
60
+ ```bash
61
+ gem install backup-buddy
62
+ ```
63
+
64
+ ## Usage
65
+
66
+ ```bash
67
+ backup-buddy manifests/my_backup.yaml
68
+ ```
69
+
70
+ ## License
71
+
72
+ MIT
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 1.0.0
data/bin/backup-buddy ADDED
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'optparse'
5
+ require_relative '../lib/backup_buddy'
6
+
7
+ version = File.read(File.expand_path('../VERSION', __dir__)).strip
8
+ dry_run = false
9
+
10
+ parser = OptionParser.new do |opts|
11
+ opts.banner = 'Usage: backup-buddy [options] <manifest.yml>'
12
+
13
+ opts.on('-n', '--dry-run', 'Show what would be transferred without doing it') do
14
+ dry_run = true
15
+ end
16
+
17
+ opts.on('-d', '--debug', 'Print rsync commands and enable verbose logging') do
18
+ require 'foghorn'
19
+ Foghorn.level = :debug
20
+ end
21
+
22
+ opts.on('-v', '--version', 'Print version') do
23
+ puts "backup-buddy #{version}"
24
+ exit
25
+ end
26
+
27
+ opts.on('-h', '--help', 'Show this help') do
28
+ puts opts
29
+ exit
30
+ end
31
+ end
32
+
33
+ parser.parse!
34
+ manifest_path = ARGV[0]
35
+
36
+ if manifest_path.nil?
37
+ puts parser
38
+ exit 1
39
+ end
40
+
41
+ unless File.exist?(manifest_path)
42
+ warn "Error: '#{manifest_path}' not found."
43
+ exit 1
44
+ end
45
+
46
+ manager = BackupBuddy::BackupManager.new(manifest_path, dry_run: dry_run)
47
+ manager.run
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require 'open3'
5
+ require 'foghorn'
6
+ require_relative 'rsync_factory'
7
+
8
+ module BackupBuddy
9
+ # Reads manifest, uses RsyncFactory to spawn tasks, and runs em
10
+ # through a thread pool with configurable concurrency.
11
+ class BackupManager
12
+ DEFAULT_CONCURRENCY = 3
13
+
14
+ attr_reader :name, :destination, :paths,
15
+ :concurrency, :excludes, :dry_run
16
+
17
+ def initialize(manifest_path, dry_run: false)
18
+ manifest = YAML.load_file(manifest_path)
19
+
20
+ @name = manifest['name']
21
+ @destination = manifest['backupDestination']
22
+ @paths = manifest['backupPaths'] || []
23
+ @concurrency = manifest.fetch('concurrency', DEFAULT_CONCURRENCY)
24
+ @excludes = build_excludes(manifest['ignorePatterns'])
25
+ @dry_run = dry_run
26
+
27
+ rsync_bin = manifest.fetch('rsync_path', '/usr/bin/rsync')
28
+ @factory = RsyncFactory.new(bin: rsync_bin)
29
+ end
30
+
31
+ def run
32
+ valid_paths = validate_paths(@paths)
33
+ return if valid_paths.empty?
34
+ return unless valid_destination?(@destination)
35
+
36
+ print_header(valid_paths)
37
+
38
+ tasks = valid_paths.map { |path| spawn_task(path) } # Spawn tasks from the factory
39
+ results = run_pool(tasks) # Run on thread pool w/concurrency control!
40
+
41
+ print_summary(tasks, results)
42
+ end
43
+
44
+ private
45
+
46
+ def valid_destination?(dest)
47
+ return true unless dest.start_with?('/')
48
+ return true if File.directory?(dest)
49
+
50
+ Foghorn.error("Destination '#{dest}' does not exist or is not a directory. Please create it first.")
51
+ false
52
+ end
53
+
54
+ def build_excludes(patterns)
55
+ return '' unless patterns.is_a?(Array)
56
+
57
+ patterns.map { |p| "--exclude='#{p}'" }.join(' ')
58
+ end
59
+
60
+ def validate_paths(paths)
61
+ valid = []
62
+ paths.each do |path|
63
+ if valid_path?(path)
64
+ valid << path
65
+ else
66
+ Foghorn.warn("Skipping '#{path}' — local paths must start with /, remote paths must be host:/absolute/path")
67
+ end
68
+ end
69
+ valid
70
+ end
71
+
72
+ def valid_path?(path)
73
+ return true if path.start_with?('/')
74
+
75
+ # Remote rsync path: host:/absolute/path
76
+ if path.include?(':')
77
+ _host, remote_path = path.split(':', 2)
78
+ return remote_path.start_with?('/')
79
+ end
80
+
81
+ false
82
+ end
83
+
84
+ def spawn_task(src_path)
85
+ @factory.spawn(
86
+ src: src_path,
87
+ dest: @destination,
88
+ excludes: @excludes,
89
+ dry_run: @dry_run
90
+ )
91
+ end
92
+
93
+ def run_pool(tasks)
94
+ queue = Queue.new
95
+ tasks.each { |t| queue << t }
96
+
97
+ results = Array.new(tasks.length)
98
+ mutex = Mutex.new
99
+
100
+ # Spawn worker threads up to concurrency limit
101
+ workers = @concurrency.times.map do
102
+ Thread.new do
103
+ loop do
104
+ task, index = mutex.synchronize do
105
+ break nil if queue.empty?
106
+
107
+ t = queue.pop
108
+ i = tasks.index(t)
109
+ [t, i]
110
+ end
111
+ break unless task
112
+
113
+ exit_code = execute_task(task)
114
+ mutex.synchronize { results[index] = exit_code }
115
+ end
116
+ end
117
+ end
118
+
119
+ workers.each(&:join)
120
+ results
121
+ end
122
+
123
+ def execute_task(task)
124
+ prefix = "[#{task.label}]"
125
+ Foghorn.debug("#{prefix} #{task.command}")
126
+
127
+ status = nil
128
+ Open3.popen3(task.command) do |_stdin, stdout, stderr, wait_thr|
129
+ stdout_reader = Thread.new do
130
+ stdout.each_line { |line| Foghorn.info("#{prefix} #{line.chomp}") }
131
+ end
132
+
133
+ stderr_reader = Thread.new do
134
+ stderr.each_line { |line| Foghorn.warn("#{prefix} #{line.chomp}") }
135
+ end
136
+
137
+ stdout_reader.join
138
+ stderr_reader.join
139
+ status = wait_thr.value
140
+ end
141
+
142
+ code = status&.exitstatus || 1
143
+ if code.zero?
144
+ Foghorn.success("#{prefix} completed")
145
+ else
146
+ Foghorn.error("#{prefix} failed (exit #{code})")
147
+ end
148
+ code
149
+ end
150
+
151
+ def print_header(valid_paths)
152
+ Foghorn.warn('DRY RUN — no files will be modified') if @dry_run
153
+ Foghorn.info("Starting Job: #{@name}")
154
+ Foghorn.info("Destination: #{@destination}")
155
+ Foghorn.debug("Paths: #{valid_paths.length} | Concurrency: #{@concurrency}")
156
+ end
157
+
158
+ def print_summary(tasks, exit_codes)
159
+ puts
160
+ Foghorn.info('≈≈ Backup Summary ≈≈')
161
+
162
+ tasks.each_with_index do |task, i|
163
+ code = exit_codes[i] || 1
164
+ if code.zero?
165
+ Foghorn.success(" OK #{task.label}")
166
+ else
167
+ Foghorn.error(" FAILED (exit #{code}) #{task.label}")
168
+ end
169
+ end
170
+
171
+ failures = exit_codes.count { |c| c.nil? || !c.zero? }
172
+ if failures.zero?
173
+ Foghorn.success("All #{tasks.length} paths completed successfully.")
174
+ else
175
+ Foghorn.error("#{failures}/#{tasks.length} paths failed.")
176
+ end
177
+ end
178
+ end
179
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'shellwords'
4
+ require_relative 'rsync_task'
5
+
6
+ module BackupBuddy
7
+ # Factory that validates rsync availability and spawns lightweight
8
+ # RsyncTask instances with the default rsync arguments.
9
+ class RsyncFactory
10
+ DEFAULT_ARGS = '-avzP --no-links'
11
+ attr_reader :bin
12
+
13
+ def initialize(bin: '/usr/bin/rsync')
14
+ @bin = bin
15
+ validate_rsync!
16
+ end
17
+
18
+ def spawn(src:, dest:, excludes: '', dry_run: false)
19
+ args = DEFAULT_ARGS
20
+ args = "--dry-run #{args}" if dry_run
21
+ escaped_src = Shellwords.shellescape(src)
22
+ escaped_dest = Shellwords.shellescape(dest)
23
+ cmd = "#{@bin} #{args} #{excludes} #{escaped_src} #{escaped_dest}".squeeze(' ').strip
24
+
25
+ RsyncTask.new(
26
+ command: cmd,
27
+ label: src
28
+ )
29
+ end
30
+
31
+ private
32
+
33
+ def validate_rsync!
34
+ rsync_path = `which #{@bin} 2>/dev/null`.strip
35
+ return unless rsync_path.empty?
36
+
37
+ # Fall back to checking the exact path
38
+ return if File.executable?(@bin)
39
+
40
+ raise "rsync not found at '#{@bin}'. Install it first."
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BackupBuddy
4
+ # Immutable data object representing a single rsync invocation.
5
+ # The factory spawns these; the manager runs them.
6
+ class RsyncTask
7
+ attr_reader :command, :label
8
+
9
+ def initialize(command:, label:)
10
+ @command = command
11
+ @label = label
12
+ freeze
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'backup_buddy/rsync_task'
4
+ require_relative 'backup_buddy/rsync_factory'
5
+ require_relative 'backup_buddy/backup_manager'
6
+
7
+ module BackupBuddy
8
+ end
metadata ADDED
@@ -0,0 +1,67 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: backup-buddy
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Egee
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-03-08 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: foghorn-logger
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.0'
27
+ description: A Ruby harness around rsync to back up files and folders in parallel
28
+ with threads.
29
+ email:
30
+ executables:
31
+ - backup-buddy
32
+ extensions: []
33
+ extra_rdoc_files: []
34
+ files:
35
+ - LICENSE
36
+ - README.md
37
+ - VERSION
38
+ - bin/backup-buddy
39
+ - lib/backup_buddy.rb
40
+ - lib/backup_buddy/backup_manager.rb
41
+ - lib/backup_buddy/rsync_factory.rb
42
+ - lib/backup_buddy/rsync_task.rb
43
+ homepage: https://github.com/egeexyz/backup-buddy
44
+ licenses:
45
+ - MIT
46
+ metadata:
47
+ rubygems_mfa_required: 'true'
48
+ post_install_message:
49
+ rdoc_options: []
50
+ require_paths:
51
+ - lib
52
+ required_ruby_version: !ruby/object:Gem::Requirement
53
+ requirements:
54
+ - - ">="
55
+ - !ruby/object:Gem::Version
56
+ version: 3.1.0
57
+ required_rubygems_version: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ requirements: []
63
+ rubygems_version: 3.5.22
64
+ signing_key:
65
+ specification_version: 4
66
+ summary: Back up your things with rsync!
67
+ test_files: []