Last active
March 25, 2024 21:44
-
-
Save akhoury6/cba1c4da13c24ed7c66f69f488049e3f to your computer and use it in GitHub Desktop.
Backup Script to RSync a local workstation to multiple locations. Pre-Configured for MacOS and Linux.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env ruby | |
################################################## | |
############### Full Backup Script ############### | |
################################################## | |
## (C)opyright 2024 Andrew Khoury | |
## Email: akhoury@live.com | |
###### | |
## README: | |
## This is easily configurable script that uses | |
## RSync to back up multiple locations together. | |
## | |
## To configure it for your system, first check | |
## the RSync config at the top, and then scroll | |
## all the way to the end to configure the actual | |
## backup locations and process tree. | |
## | |
## Prerequites: | |
## gem install colorize | |
###### | |
## License: GPLv3 | |
## I don't really care how you use or modify the | |
## script as long as you keep this header to give | |
## me credit for the original, note that you | |
## modified it, don't try to make money from it, | |
## and don't blame me if something goes wrong. | |
################################################### | |
## | |
## RSync Config | |
## | |
# Only do dry runs. Useful for testing backup settings. | |
DRY_RUN = false | |
# Skip the RSync command completely. Useful for script development. | |
SKIP_RSYNC = false | |
# These are the CLI options to run RSYNC with. | |
# The default configuration below is useful for | |
# running it on MacOS and Linux. | |
RSYNC_OPTIONS = <<-EOF | |
-avhz #{DRY_RUN ? '--dry-run' : ''} --delete-before \ | |
--exclude .DocumentRevisions-V100 \ | |
--exclude .Spotlight-V100 \ | |
--exclude .TemporaryItems \ | |
--exclude .Trashes \ | |
--exclude .fseventsd \ | |
--exclude .DS_Store \ | |
--exclude lost+found | |
EOF | |
################################################### | |
require 'colorize' | |
$stdout.sync = true | |
class Location | |
def initialize name: nil, folder: nil, local_addr: nil, local_port: nil, remote_addr: nil, remote_port: nil, ssh_user: nil, ssh_key: nil, triggerfile: nil, subfolder_at_target: nil, success_color: nil, mountable: false | |
%w(name folder local_addr local_port remote_addr remote_port ssh_user ssh_key triggerfile subfolder_at_target success_color mountable).each do |var| | |
self.instance_variable_set("@#{var}", binding.local_variable_get(var.to_sym)) | |
self.class.instance_eval{ attr_reader var.to_sym } | |
end | |
@local_or_remote = nil | |
@location = nil | |
end | |
attr_reader :location, :local_or_remote | |
def prep | |
if !@folder.nil? && @local_addr.nil? && @remote_addr.nil? # Local Folder | |
if @mountable | |
@local_or_remote = :local | |
@location = :disk if system("mount | grep '#{@folder}' &> /dev/null") | |
else | |
@local_or_remote = :local | |
@location = :folder if Dir.exist? @folder | |
end | |
elsif !@local_addr.nil? || !@remote_addr.nil? # Server LAN/WAN Autodetect | |
@local_or_remote = :remote | |
if !@local_addr.nil? && system("nc -z -G 1 -w 1 '#{@local_addr}' '#{@local_port}' &>/dev/null") | |
@location = :lan | |
elsif !@remote_addr.nil? && system("nc -z -G 1 -w 1 '#{@remote_addr}' '#{@remote_port}' &>/dev/null") | |
@location = :wan | |
end | |
end | |
end | |
def status_message | |
if @local_or_remote == :local && !@mountable # local folder | |
(@location == :folder) ? | |
"#{@name.colorize(@success_color)} has been " + "found".colorize(@success_color) + " at #{@folder.colorize(@success_color)}" : | |
"#{@name.red} is " + "missing".red | |
elsif @local_or_remote == :local && @mountable # local disk | |
(@location == :disk) ? | |
"#{@name.colorize(@success_color)} is " + "mounted".colorize(@success_color) + " at #{@folder.colorize(@success_color)}" : | |
"#{@name.red} is " + "not mounted".red | |
elsif @local_or_remote == :remote | |
if @location == :lan | |
"#{@name.colorize(@success_color)} has been " + "found locally".colorize(@success_color) + " at #{@local_addr.colorize(@success_color)}" | |
elsif @location == :wan | |
"#{@name.colorize(@success_color)} has been " + "found remotely".colorize(@success_color) + " at #{@remote_addr.colorize(@success_color)}" | |
elsif @location.nil? | |
"#{@name.red} is " + "unreachable".red | |
end | |
else | |
"#{@name} is an invalid location. Please fix the backup script.".colorize(color: :white, mode: :blink, background: :red) | |
end | |
end | |
def sync_to target, force_skip: false | |
sync self, target, force_skip: force_skip | |
end | |
def sync_from source, force_skip: false | |
sync source, self, force_skip: force_skip | |
end | |
private | |
def sync src, dest, force_skip: false | |
if src.local_or_remote == :remote && dest.local_or_remote == :remote | |
puts "Backups between two remote locations are not supported (#{src.name}, #{dest.name}). Please fix the backup script.".colorize(color: :white, mode: :blink, background: :red) | |
return | |
end | |
if force_skip | |
puts "Skipping backup from #{src.name.red} to #{dest.name.red}. " + "Prerequisite backups failed.".red | |
return false | |
elsif src.location.nil? | |
puts "Skipping backup from #{src.name.red} to #{dest.name.colorize(dest.success_color)}. #{src.name.red} not available." | |
return false | |
elsif dest.location.nil? | |
puts "Skipping backup from #{src.name.colorize(src.success_color)} to #{dest.name.red}. #{dest.name.red} not available." | |
return false | |
end | |
msg = "Backing up #{src.name.colorize(src.success_color)} to #{dest.name.colorize(dest.success_color)}" | |
cmd = nil | |
remote = nil | |
r_source, r_dest = [src, dest].map do |loc| | |
if loc.local_or_remote == :local | |
loc.folder.chomp('/') | |
elsif loc.local_or_remote == :remote | |
remote = { | |
location: loc.location, | |
color: loc.success_color, | |
addr: (loc.location == :lan) ? loc.local_addr : loc.remote_addr, | |
port: (loc.location == :lan) ? loc.local_port : loc.remote_port, | |
ssh_key: loc.ssh_key, | |
ssh_user: loc.ssh_user | |
} | |
"#{loc.ssh_user}@#{remote[:addr]}:#{loc.folder.chomp('/')}" | |
end | |
end | |
msg += (remote.nil?) ? ' locally' : " over the #{((remote[:location] == :lan) ? 'local network' : 'internet').colorize(remote[:color])}" | |
msg += '...' | |
r_source += '/' | |
r_dest += "/#{src.subfolder_at_target}" unless src.subfolder_at_target.nil? | |
cmd = "rsync" | |
cmd += " -e 'ssh -p #{remote[:port]} -Tx -o Compression=no -i #{remote[:ssh_key]}'" unless remote.nil? | |
cmd += " #{RSYNC_OPTIONS.chomp} '#{r_source}' '#{r_dest}'" | |
puts msg | |
system(cmd) unless SKIP_RSYNC | |
if !dest.triggerfile.nil? | |
if dest.local_or_remote == :local | |
system("touch '#{dest.triggerfile}'") | |
else | |
system("ssh -i '#{remote[:ssh_key]}' -p '#{remote[:port]}' '#{remote[:ssh_user]}@#{remote[:addr]}' touch '#{dest.triggerfile}'") | |
end | |
end | |
return true | |
end | |
end | |
class BackupNode | |
def initialize source | |
@source = source | |
@targets = [] | |
@source.prep | |
puts @source.status_message | |
end | |
attr_reader :source | |
def add_target target, failovers: [] | |
@targets.push [target, failovers] | |
end | |
def backup skip: false, &block | |
return false if @targets.empty? | |
@targets.each do |target, failovers| | |
block.call("#{@source.name.colorize(@source.success_color)} to #{target.source.name.colorize(target.source.success_color)}") if block_given? | |
success = @source.sync_to(target.source, force_skip: skip) | |
if !success && !failovers.empty? | |
failovers.each do |failover| | |
block.call("#{@source.name.colorize(@source.success_color)} to #{failover.source.name.colorize(failover.source.success_color)}") if block_given? | |
failover_success = @source.sync_to(failover.source, force_skip: skip) | |
failover.backup(skip: !failover_success, &block) if failover_success | |
end | |
end | |
target.backup(skip: !success, &block) if success | |
end | |
end | |
end | |
def divider text = nil | |
divider_color = {color: :cyan, background: :default} | |
if text.nil? | |
text = "————————————————————————".colorize(divider_color) | |
else | |
text = " ".colorize(divider_color).underline + ' ' + text + ' ' + " ".colorize(divider_color).underline | |
end | |
puts "\n#{text}\n\n" | |
end | |
banner=<<-EOF | |
_____________________ | |
< Running Backup!!!!! > | |
--------------------- | |
\\ ^__^ | |
\\ (oo)\\_______ | |
(__)\\ )\/\\ | |
||----w | | |
|| || | |
EOF | |
puts banner.cyan | |
divider('Analyze Backup Targets'.cyan) | |
## | |
## Backup Config | |
## | |
# First declare the properties of the locations for backup | |
icloud = BackupNode.new(Location.new( | |
name: 'iCloud Drive', | |
folder: '/Users/<USER>/Library/Mobile Documents/com~apple~CloudDocs/', | |
subfolder_at_target: 'Documents', | |
success_color: :green | |
)) | |
local_storage = BackupNode.new(Location.new( | |
name: 'Local Storage Drive', | |
folder: '/Volumes/Storage', | |
success_color: :yellow, | |
mountable: true | |
)) | |
primary_backup = BackupNode.new(Location.new( | |
name: 'Primary Backup Server', | |
folder: "/mnt/backup", | |
local_addr: "server1.local", | |
local_port: "22", | |
remote_addr: "server1.your.domain.com", | |
remote_port: "22", | |
ssh_user: "root", | |
ssh_key: File.expand_path("~/.ssh/id_rsa"), | |
triggerfile: "/var/tmp/ready_for_sync", | |
subfolder_at_target: nil, | |
success_color: :blue | |
)) | |
offsite_backup = BackupNode.new(Location.new( | |
name: 'Offsite Backup Server', | |
folder: "/mnt/backup", | |
local_addr: "server2.local", | |
local_port: "22", | |
remote_addr: "server2.your.domain.com", | |
remote_port: "22", | |
ssh_user: "root", | |
ssh_key: File.expand_path("~/.ssh/id_rsa"), | |
triggerfile: "/var/tmp/ready_for_sync", | |
subfolder_at_target: nil, | |
success_color: :magenta | |
)) | |
# Then declare the tree for how the backup should take place. | |
# The script will traverse the tree in order, skipping branches with failures | |
icloud.add_target(local_storage, failovers: [primary_backup, offsite_backup]) | |
local_storage.add_target(primary_backup) | |
local_storage.add_target(offsite_backup) | |
## | |
## RUN the backup | |
## | |
icloud.backup(&method(:divider)) | |
divider('Done'.cyan) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment