Skip to content

Instantly share code, notes, and snippets.

@akhoury6
Last active March 25, 2024 21:44
Show Gist options
  • Save akhoury6/cba1c4da13c24ed7c66f69f488049e3f to your computer and use it in GitHub Desktop.
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.
#!/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