├── .autotest ├── .bundle └── config ├── .gitignore ├── Gemfile ├── Gemfile.lock ├── README.markdown ├── autotest └── discover.rb ├── init.rb ├── lib ├── strongspace-rsync.rb └── strongspace-rsync │ ├── commands │ └── rsync_command.rb │ ├── config.rb │ ├── help.rb │ ├── helpers.rb │ └── version.rb ├── spec └── rsync_command_spec.rb └── strongspace-rsync.gemspec /.autotest: -------------------------------------------------------------------------------- 1 | require 'autotest/fsevent' 2 | require 'autotest/growl' -------------------------------------------------------------------------------- /.bundle/config: -------------------------------------------------------------------------------- 1 | --- 2 | BUNDLE_DISABLE_SHARED_GEMS: "1" 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **.DS_Store 2 | *.gem 3 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source :rubygems 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | strongspace-rsync (0.2.0) 5 | strongspace 6 | 7 | GEM 8 | remote: http://rubygems.org/ 9 | specs: 10 | ZenTest (4.4.2) 11 | addressable (2.2.3) 12 | autotest-fsevent (0.2.4) 13 | sys-uname 14 | autotest-growl (0.2.9) 15 | crack (0.1.8) 16 | cronedit (0.3.0) 17 | json_pure (1.4.6) 18 | mime-types (1.16) 19 | open4 (1.0.1) 20 | rack (1.2.1) 21 | rake (0.8.7) 22 | rest-client (1.6.1) 23 | mime-types (>= 1.16) 24 | rspec (1.3.1) 25 | ruby-fsevent (0.2.1) 26 | sinatra (1.1.2) 27 | rack (~> 1.1) 28 | tilt (~> 1.2) 29 | strongspace (0.2.0) 30 | cronedit 31 | json_pure (< 1.5.0) 32 | open4 33 | rest-client (< 1.7.0) 34 | sinatra 35 | sys-uname (0.8.5) 36 | tilt (1.2.2) 37 | webmock (1.5.0) 38 | addressable (>= 2.2.2) 39 | crack (>= 0.1.7) 40 | 41 | PLATFORMS 42 | ruby 43 | 44 | DEPENDENCIES 45 | ZenTest 46 | autotest-fsevent 47 | autotest-growl 48 | rake 49 | rspec (~> 1.3.0) 50 | ruby-fsevent 51 | strongspace 52 | strongspace-rsync! 53 | webmock (~> 1.5.0) 54 | -------------------------------------------------------------------------------- /README.markdown: -------------------------------------------------------------------------------- 1 | Strongspace Rsync Backup plugin 2 | =============================== 3 | 4 | This is a plugin for the [Strongspace gem](https://github.com/expandrive/strongspace-ruby) to automatically backup any local folder to Strongspace. Currently it only works on Mac but will support windows soon enough. 5 | 6 | 7 | 8 | Installation 9 | ------------ 10 | 11 | Upgrade/Install the Strongspace gem to v0.3.0 or newer: 12 | `sudo gem install strongspace` 13 | 14 | Install the Strongspace Rsync plugin 15 | `strongspace plugins:install git://github.com/expandrive/strongspace-rsync.git` 16 | 17 | Usage 18 | ----- 19 | 20 | The following commands are added to the Strongspace command-line tool 21 | 22 | === Rsync Backup 23 | rsync:list # List backup profiles 24 | rsync:run # Run a backup profile 25 | rsync:create # Create a backup profile 26 | rsync:delete [remove_data] # Delete a backup profile, [remove_data=>(yes|no)]] 27 | rsync:schedule # Schedules continuous backup 28 | rsync:unschedule # Unschedules continuous backup 29 | rsync:logs # Opens Console.app and shows the Backup log 30 | -------------------------------------------------------------------------------- /autotest/discover.rb: -------------------------------------------------------------------------------- 1 | Autotest.add_discovery { "rspec" } 2 | -------------------------------------------------------------------------------- /init.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + '/lib/strongspace-rsync' -------------------------------------------------------------------------------- /lib/strongspace-rsync.rb: -------------------------------------------------------------------------------- 1 | require 'yaml' 2 | require 'find' 3 | require 'digest' 4 | require 'open4' 5 | require 'cronedit' 6 | require 'date' 7 | 8 | require File.dirname(__FILE__) + '/strongspace-rsync/version' 9 | require File.dirname(__FILE__) + '/strongspace-rsync/helpers' 10 | require File.dirname(__FILE__) + '/strongspace-rsync/config' 11 | require File.dirname(__FILE__) + '/strongspace-rsync/commands/rsync_command' 12 | require File.dirname(__FILE__) + '/strongspace-rsync/help' 13 | -------------------------------------------------------------------------------- /lib/strongspace-rsync/commands/rsync_command.rb: -------------------------------------------------------------------------------- 1 | require 'socket' 2 | require 'POpen4' 3 | 4 | module Strongspace::Command 5 | DEBUG = false 6 | 7 | class Rsync < Base 8 | include StrongspaceRsync::Helpers 9 | include StrongspaceRsync::Config 10 | 11 | # Display the version of the plugin and also return it 12 | def version 13 | display "#{command_name} v#{StrongspaceRsync::VERSION}" 14 | StrongspaceRsync::VERSION 15 | end 16 | 17 | # Displays a list of the available rsync profiles 18 | def list 19 | display "Available rsync backup profiles:" 20 | 21 | profiles = _profiles 22 | 23 | if profiles.blank? 24 | return [] 25 | end 26 | 27 | profiles.each do |profile| 28 | display profile['name'] 29 | end 30 | 31 | return profiles 32 | end 33 | 34 | # create a new profile by prompting the user 35 | def create 36 | if args.blank? 37 | error "Please supply the name for the profile you'd like to create" 38 | end 39 | 40 | Strongspace::Command.run_internal("auth:check", nil) 41 | 42 | if new_profile = ask_for_new_rsync_profile 43 | add_profile(new_profile) 44 | 45 | if args[3] and args[3] == "schedule" 46 | schedule 47 | end 48 | end 49 | 50 | return new_profile 51 | end 52 | 53 | alias :setup :create 54 | 55 | # delete a profile by prompting the user 56 | def delete 57 | profile = profile_by_name(args.first) 58 | 59 | if profile.blank? 60 | display "Please supply the name of the profile you'd like to delete" 61 | self.list 62 | return false 63 | end 64 | 65 | if args[1] == "yes" 66 | puts profile['strongspace_path'][12..-1] 67 | begin 68 | strongspace.rm(profile['strongspace_path'][12..-1]) 69 | rescue 70 | end 71 | end 72 | 73 | delete_profile(profile) 74 | 75 | display "#{args.first} has been deleted" 76 | end 77 | 78 | # run a specific rsync backup profile 79 | def run 80 | profile_name = args.first 81 | 82 | if !profile_exist?(profile_name) 83 | display "Please supply the name of the profile you'd like to run" 84 | self.list 85 | return false 86 | end 87 | 88 | if not (create_pid_file("#{command_name_with_profile_name(profile_name)}", Process.pid)) 89 | display "The backup process for #{profile_name} is already running" 90 | exit(1) 91 | end 92 | 93 | if global_value('paused') or (profile_value(profile_name, 'paused') == true) 94 | display "This backup has been paused" 95 | return true 96 | end 97 | 98 | if profile_value(profile_name, 'last_successful_backup').blank? 99 | validate_destination_space(profile_value(profile_name, 'strongspace_path').split("/")[3], create=true) 100 | begin 101 | strongspace.mkdir("#{profile_value(profile_name, 'strongspace_path')[12..-1]}") 102 | 103 | rescue RestClient::Conflict => e 104 | end 105 | 106 | if profile_value(profile_name, 'last_successful_backup').blank? 107 | if running_on_windows? 108 | total_bytes_command = "#{support_directory}\\bin\\du.exe -ks '#{profile_value(profile_name, 'local_source_path')[9..-1]}'" 109 | total_bytes = `#{total_bytes_command}`.split("\t")[0].to_i * 1024 110 | puts total_bytes_command 111 | else 112 | total_bytes = `du -ks '#{profile_value(profile_name, 'local_source_path')}'`.split("\t")[0].to_i * 1024 113 | end 114 | 115 | set_profile_value(profile_name, total_bytes, 'local_source_size') 116 | end 117 | 118 | elsif not new_digest = source_changed?(profile_name) 119 | if running_on_windows? 120 | launched_by = 'nothing' 121 | else 122 | launched_by = `ps #{Process.ppid}`.split("\n")[1].split(" ").last 123 | end 124 | 125 | if not launched_by.ends_with?("launchd") 126 | display "backup target has not changed since last backup attempt." 127 | end 128 | 129 | set_profile_value(profile_name, DateTime.now.to_s, 'last_successful_backup') 130 | 131 | delete_pid_file("#{command_name_with_profile_name(profile_name)}") 132 | return 133 | end 134 | 135 | restart_wait = 10 136 | num_failures = 0 137 | 138 | puts "checking size to upload #{rsync_command_size(profile_name)}" 139 | sizeCheckOutput = `#{rsync_command_size(profile_name)}` 140 | 141 | 142 | totalToUpload = profile_value(profile_name, "totalToUpload") 143 | 144 | if totalToUpload.blank? or totalToUpload == 0 145 | totalToUpload = sizeCheckOutput.match(/Total transferred file size: [0-9,]+ bytes/).to_s.match(/[0-9,]+/).to_s.gsub(",","").to_i 146 | end 147 | puts "Total to upload #{totalToUpload}" 148 | 149 | set_profile_value(profile_name, totalToUpload.to_f, 'totalToUpload') 150 | 151 | # Set this to avoid divide by zero, for now 152 | if totalToUpload == 0 153 | totalToUpload = 1 154 | end 155 | 156 | bytesUploaded = profile_value(profile_name, "bytesUploaded") 157 | if bytesUploaded.blank? 158 | bytesUploaded = 0 159 | end 160 | 161 | bytesCounter = bytesUploaded 162 | 163 | while true do 164 | status = POpen4::popen4(rsync_command(profile_name)) do 165 | |stdout, stderr, stdin, pid| 166 | 167 | display "\n\nStarting Strongspace Backup: #{Time.now}" 168 | display "rsync command:\n\t#{rsync_command(profile_name)}" 169 | 170 | if not (create_pid_file("#{command_name_with_profile_name(profile_name)}.rsync", pid)) 171 | display "Couldn't start backup sync, already running?" 172 | exit(1) 173 | end 174 | 175 | 176 | 177 | sleep(5) if num_failures 178 | threads = [] 179 | 180 | 181 | threads << Thread.new(stderr) { |f| 182 | while not f.eof? 183 | line = f.gets.strip 184 | if not line.starts_with?("rsync: failed to set permissions on") and not line.starts_with?("rsync error: some files could not be transferred (code 23) ") and not line.starts_with?("rsync error: some files/attrs were not transferred (see previous errors)") 185 | puts "error: #{line}" unless line.blank? 186 | end 187 | end 188 | } 189 | 190 | threads << Thread.new(stdout) { |f| 191 | progressTime = Time.now 192 | 193 | while not f.eof? 194 | lines = f.readpartial(1000).gsub("\r", "\n") 195 | 196 | if lines.include?("\n") 197 | lines.split("\n").each do |line| 198 | if line.include? "100%" and line.include?("(xfr#") 199 | bytesUploaded += line.split(" ")[0].gsub(",","").to_i 200 | bytesCounter = bytesUploaded 201 | elsif line.include? "%" and !line.include? "100%" 202 | bytesCounter = bytesUploaded + line.split(" ")[0].gsub(",","").to_i 203 | end 204 | 205 | if (Time.now - 1) > progressTime 206 | progressTime = Time.now 207 | if bytesCounter > bytesUploaded 208 | percentage = (bytesCounter.to_f/totalToUpload.to_f) 209 | if percentage > 1 210 | percentage = 1 211 | end 212 | set_profile_value(profile_name, bytesUploaded.to_f, 'bytesUploaded') 213 | set_profile_value(profile_name, percentage, 'percent_uploaded') 214 | else 215 | percentage = (bytesUploaded.to_f/totalToUpload.to_f) 216 | if percentage > 1 217 | percentage = 1 218 | end 219 | set_profile_value(profile_name, bytesUploaded.to_f, 'bytesUploaded') 220 | set_profile_value(profile_name, percentage, 'percent_uploaded') 221 | end 222 | end 223 | 224 | end 225 | end 226 | end 227 | 228 | } 229 | 230 | threads.each { |aThread| aThread.join } 231 | end 232 | 233 | delete_pid_file("#{command_name_with_profile_name(profile_name)}.rsync") 234 | 235 | if status.exitstatus == 23 or status.exitstatus == 0 236 | num_failures = 0 237 | display "Successfully backed up at #{Time.now}" 238 | 239 | profile = profile_by_name(profile_name) 240 | 241 | set_profile_value(profile_name, 0, 'totalToUpload') 242 | set_profile_value(profile_name, 0, 'bytesUploaded') 243 | set_profile_value(profile_name, 1, 'percent_uploaded') 244 | set_profile_value(profile_name, new_digest, 'last_successful_backup_hash') 245 | set_profile_value(profile_name, DateTime.now.to_s, 'last_successful_backup') 246 | 247 | delete_pid_file("#{command_name_with_profile_name(profile_name)}") 248 | 249 | return true 250 | else 251 | display "Error backing up - trying #{3-num_failures} more times" 252 | num_failures += 1 253 | if num_failures == 3 254 | puts "Failed out with status #{status.exitstatus}" 255 | return false 256 | else 257 | sleep(1) 258 | end 259 | end 260 | 261 | end 262 | 263 | delete_pid_file("#{command_name_with_profile_name(profile_name)}") 264 | return true 265 | end 266 | alias :backup :run 267 | 268 | def running? 269 | profile_name = args.first 270 | 271 | if process_running?("#{command_name_with_profile_name(profile_name)}") 272 | return true 273 | else 274 | return false 275 | end 276 | 277 | return false 278 | end 279 | 280 | def schedule 281 | profile_name = args.first 282 | profile = profile_by_name(profile_name) 283 | 284 | if profile.blank? 285 | display "Please supply the name of the profile you'd like to schedule" 286 | self.list 287 | return false 288 | end 289 | 290 | if not File.exist?(logs_folder) 291 | FileUtils.mkdir(logs_folder) 292 | end 293 | 294 | if running_on_a_mac? 295 | plist = " 296 | 298 | 299 | 300 | Label 301 | com.strongspace.#{command_name_with_profile_name(profile_name)} 302 | Program 303 | #{support_directory}/gems/bin/strongspace 304 | ProgramArguments 305 | 306 | strongspace 307 | rsync:run 308 | #{profile_name} 309 | 310 | KeepAlive 311 | 312 | StartInterval 313 | 600 314 | RunAtLoad 315 | 316 | StandardOutPath 317 | #{log_file} 318 | StandardErrorPath 319 | #{log_file} 320 | EnvironmentVariables 321 | 322 | STRONGSPACE_DISPLAY 323 | logging 324 | GEM_PATH 325 | #{support_directory}/gems 326 | GEM_HOME 327 | #{support_directory}/gems 328 | RACK_ENV 329 | production 330 | 331 | 332 | " 333 | 334 | file = File.new(scheduled_launch_file(profile_name), "w+") 335 | file.puts plist 336 | file.close 337 | 338 | r = `launchctl load -S aqua '#{scheduled_launch_file(profile_name)}'` 339 | if r.strip.ends_with?("Already loaded") 340 | error "This task is aready scheduled, unload before scheduling again" 341 | return 342 | end 343 | 344 | profile['active'] = true 345 | update_profile(profile_name, profile) 346 | 347 | Strongspace::Command.run_internal("spaces:schedule_snapshots", [profile['strongspace_path'].split("/")[3]]) 348 | 349 | display "Scheduled #{profile_name} to be run continuously" 350 | elsif running_on_windows? 351 | vbs = "Set WshShell = CreateObject(\"WScript.Shell\") 352 | WshShell.Run \"#{support_directory}\\ruby\\bin\\strongspace.bat rsync:run #{profile_name}\", 0 353 | Set WshShell = Nothing" 354 | file = File.new(scheduled_launch_file(profile_name), "w+") 355 | file.puts vbs 356 | file.close 357 | 358 | r = `schtasks.exe /Create /tn "com.strongspace.#{command_name_with_profile_name(profile_name)}" /mo 10 /sc minute /tr "#{scheduled_launch_file(profile_name).gsub('/', '\\')}"` 359 | `schtasks.exe /Run /tn "com.strongspace.#{command_name_with_profile_name(profile_name)}"` 360 | 361 | else # Assume we're running on linux/unix 362 | begin 363 | CronEdit::Crontab.Add "strongspace-#{command_name}-#{profile_name}", "0,5,10,15,20,25,30,35,40,45,52,53,55 * * * * #{$PROGRAM_NAME} rsync:run #{profile_name} >> #{log_file} 2>&1" 364 | rescue Exception => e 365 | error "Error setting up schedule: #{e.message}" 366 | end 367 | display "Scheduled #{profile_name} to be run every five minutes" 368 | end 369 | 370 | end 371 | alias :schedule_backup :schedule 372 | 373 | def unschedule 374 | profile_name = args.first 375 | profile = profile_by_name(profile_name) 376 | 377 | if profile.blank? 378 | display "Please supply the name of the profile you'd like to unschedule" 379 | self.list 380 | return false 381 | end 382 | 383 | if running_on_a_mac? 384 | if File.exist? scheduled_launch_file(profile_name) 385 | `launchctl unload '#{scheduled_launch_file(profile_name)}'` 386 | FileUtils.rm(scheduled_launch_file(profile_name)) 387 | profile['active'] = false 388 | update_profile(profile_name, profile) 389 | end 390 | elsif running_on_windows? 391 | if File.exist? scheduled_launch_file(profile_name) 392 | `schtasks.exe /Delete /f /tn "com.strongspace.#{command_name_with_profile_name(profile_name)}"` 393 | FileUtils.rm(scheduled_launch_file(profile_name)) 394 | profile['active'] = false 395 | update_profile(profile_name, profile) 396 | end 397 | else # Assume we're running on linux/unix 398 | CronEdit::Crontab.Remove "strongspace-#{command_name}-#{profile_name}" 399 | end 400 | 401 | display "Unscheduled continuous backup" 402 | 403 | end 404 | alias :unschedule_backup :unschedule 405 | 406 | def stop_scheduled 407 | profile_name = args.first 408 | profile = profile_by_name(profile_name) 409 | 410 | if profile.blank? 411 | display "Please supply the name of the profile you'd like to stop" 412 | return false 413 | end 414 | 415 | 416 | if running_on_a_mac? 417 | if File.exist? scheduled_launch_file(profile_name) 418 | `launchctl stop 'com.strongspace.#{command_name_with_profile_name(profile_name)}'` 419 | end 420 | elsif running_on_windows? 421 | if File.exist? scheduled_launch_file(profile_name) 422 | `schtasks.exe /End /tn "com.strongspace.#{command_name_with_profile_name(profile_name)}"` 423 | `schtasks.exe /Change /DISABLE /tn "com.strongspace.#{command_name_with_profile_name(profile_name)}"` 424 | end 425 | end 426 | 427 | display "Stopped continuous backup" 428 | end 429 | 430 | def restart_scheduled 431 | profile_name = args.first 432 | profile = profile_by_name(profile_name) 433 | 434 | if profile.blank? 435 | display "Please supply the name of the profile you'd like to stop" 436 | return false 437 | end 438 | 439 | if running_on_a_mac? 440 | if File.exist? scheduled_launch_file(profile_name) 441 | `launchctl start 'com.strongspace.#{command_name_with_profile_name(profile_name)}'` 442 | end 443 | elsif running_on_windows? 444 | if File.exist? scheduled_launch_file(profile_name) 445 | `schtasks.exe /Change /ENABLE /tn "com.strongspace.#{command_name_with_profile_name(profile_name)}"` 446 | `schtasks.exe /Run /tn "com.strongspace.#{command_name_with_profile_name(profile_name)}"` 447 | end 448 | end 449 | 450 | display "Stopped continuous backup" 451 | end 452 | 453 | def scheduled? 454 | profile_name = args.first 455 | profile = profile_by_name(profile_name) 456 | 457 | if profile.blank? 458 | display "Please supply the name of the profile you'd like to query" 459 | self.list 460 | return false 461 | end 462 | 463 | if running_on_a_mac? 464 | r = `launchctl list 'com.strongspace.#{command_name}.#{profile_name}' 2>&1` 465 | if !r.ends_with?("unknown response\n") 466 | return true 467 | end 468 | elsif running_on_windows? 469 | r = `schtasks.exe /Query /nh /tn "com.strongspace.#{command_name_with_profile_name(profile_name)}" 2>&1` 470 | if !r.starts_with?("ERROR:") 471 | puts "scheduled" 472 | puts r 473 | return true 474 | end 475 | end 476 | 477 | return false 478 | end 479 | 480 | def pause_all 481 | set_global_value(true, 'paused') 482 | profiles = _profiles 483 | Strongspace::Command.run_internal("spaces:unschedule_snapshots", [profiles.first['strongspace_path'].split("/")[3]]) 484 | 485 | 486 | profiles.each do |p| 487 | args[0] = p['name'] 488 | if scheduled? 489 | stop_scheduled 490 | end 491 | end 492 | 493 | end 494 | 495 | def unpause_all 496 | profiles = _profiles 497 | Strongspace::Command.run_internal("spaces:schedule_snapshots", [profiles.first['strongspace_path'].split("/")[3]]) 498 | set_global_value(false, 'paused') 499 | 500 | 501 | profiles.each do |p| 502 | args[0] = p['name'] 503 | if scheduled? 504 | restart_scheduled 505 | end 506 | end 507 | end 508 | 509 | def all_paused? 510 | return global_value('paused') 511 | end 512 | 513 | def unschedule_all 514 | profiles = _profiles 515 | Strongspace::Command.run_internal("spaces:unschedule_snapshots", [profiles.first['strongspace_path'].split("/")[3]]) 516 | profiles.each do |p| 517 | args[0] = p['name'] 518 | if scheduled? 519 | unschedule 520 | end 521 | end 522 | end 523 | 524 | def reschedule_all 525 | profiles = _profiles 526 | 527 | # set the computername in the credentials file 528 | 529 | if File.exist? credentials_file 530 | n = File.read(credentials_file).split("\n")[2] 531 | if n.blank? 532 | name = profiles.first['strongspace_path'].split("/")[3] 533 | File.open(credentials_file, 'a') do |f| 534 | f.puts name 535 | end 536 | end 537 | 538 | cache_quota 539 | end 540 | 541 | Strongspace::Command.run_internal("spaces:unschedule_snapshots", [profiles.first['strongspace_path'].split("/")[3]]) 542 | profiles.each do |p| 543 | args[0] = p['name'] 544 | 545 | if scheduled? 546 | unschedule 547 | schedule 548 | end 549 | end 550 | end 551 | 552 | 553 | def logs 554 | if File.exist?(log_file) 555 | if running_on_windows? 556 | error "Scheduling currently isn't supported on Windows" 557 | return 558 | end 559 | if running_on_a_mac? 560 | `open -a Console.app #{log_file}` 561 | else 562 | system("/usr/bin/less less #{log_file}") 563 | end 564 | else 565 | display "No log file has been created yet, run strongspace rsync:setup to get things going" 566 | end 567 | end 568 | 569 | def cache_quota 570 | begin 571 | f = strongspace.filesystem 572 | set_global_value(f['quota_gib'], "quota_gib") 573 | set_global_value(f['used_gib'], "used_gib") 574 | rescue 575 | end 576 | end 577 | 578 | def generate_defaults 579 | if !File.exist? configuration_file 580 | Strongspace::Command.run_internal("rsync:create", ["Desktop", "#{home_directory}/Desktop"]) 581 | Strongspace::Command.run_internal("rsync:create", ["Documents", "#{home_directory}/Documents"]) 582 | Strongspace::Command.run_internal("rsync:create", ["Music", "#{home_directory}/Music"]) 583 | Strongspace::Command.run_internal("rsync:create", ["Pictures", "#{home_directory}/Pictures"]) 584 | Strongspace::Command.run_internal("rsync:create", ["Dropbox", "#{home_directory}/Dropbox"]) if File.exist? "#{home_directory}/Dropbox" 585 | end 586 | end 587 | 588 | private 589 | def ask_for_new_rsync_profile 590 | # Name 591 | name = args.first 592 | 593 | if profile_by_name(name) 594 | raise CommandFailed, "Couldn't Add Folder to Backups|This backup name is already in use" 595 | end 596 | 597 | display "Creating a new strongspace backup profile named #{args.first}" 598 | 599 | if args[1].blank? 600 | # Source 601 | display "Location to backup [#{default_backup_path.normalize_pathslash}]: ", false 602 | location = ask(default_backup_path.normalize_pathslash) 603 | location = Pathname(location).cleanpath.to_s 604 | 605 | if running_on_windows? 606 | mLocation = "/#{location[0..0].upcase}/#{location[3..-1].gsub("\\","/")}" 607 | display "Strongspace destination [/strongspace/#{strongspace.username}/#{computername}#{mLocation}]: ", false 608 | dest = ask("/strongspace/#{strongspace.username}/#{computername}#{mLocation}") 609 | else 610 | display "Strongspace destination [/strongspace/#{strongspace.username}/#{computername}#{location}]: ", false 611 | dest = ask("/strongspace/#{strongspace.username}/#{computername}#{location}") 612 | end 613 | 614 | location = location.to_cygpath if running_on_windows? 615 | else 616 | location = args[1] 617 | location = Pathname(location).cleanpath.to_s 618 | 619 | mLocation = "/#{location[0..0].upcase}/#{location[3..-1].gsub("\\","/")}" 620 | 621 | dest = "/strongspace/#{strongspace.username}/#{computername}#{mLocation}" 622 | location = location.to_cygpath if running_on_windows? 623 | end 624 | 625 | _profiles.each do |profile| 626 | source = Pathname(profile['local_source_path']).cleanpath.to_s 627 | if source.starts_with? location or location.starts_with? source 628 | raise CommandFailed, "Couldn't Add Folder to Backups|Nested backups are not currently permitted" 629 | end 630 | end 631 | 632 | return {'name' => name, 'local_source_path' => location, 'strongspace_path' => dest } 633 | end 634 | 635 | def validate_destination_space(space, create=false) 636 | # TODO: this validation flow could be made more friendly 637 | if !space_exist?(space) 638 | if not create 639 | display "#{strongspace.username}/#{space} does not exist. Would you like to create it? [y]: ", false 640 | if ask('y') != 'y' 641 | puts "Aborting" 642 | exit(-1) 643 | end 644 | end 645 | strongspace.create_space(space, 'backup') 646 | end 647 | 648 | if !backup_space?(space) 649 | puts "#{space} is not a 'backup'-type space. Aborting." 650 | exit(-1) 651 | end 652 | 653 | end 654 | 655 | def rsync_command(profile_name) 656 | 657 | if running_on_windows? #cygwin's ssh.exe is sometimes barfing on name resolution, odd. 658 | remote_ip = Socket.getaddrinfo("#{strongspace.username}.strongspace.com", nil)[0][2] 659 | else 660 | remote_ip = "#{strongspace.username}.strongspace.com" 661 | end 662 | 663 | if File.exist? self.gui_ssh_key 664 | rsync_flags = "-e '#{ssh_binary} -oServerAliveInterval=3 -oServerAliveCountMax=1 -o UserKnownHostsFile=#{credentials_folder.to_cygpath}/known_hosts -o PreferredAuthentications=publickey -i \"#{self.gui_ssh_key.to_cygpath}\"' " 665 | else 666 | rsync_flags = "-e '#{ssh_binary} -oServerAliveInterval=3 -oServerAliveCountMax=1' " 667 | end 668 | rsync_flags << "-avz -P " 669 | rsync_flags << "--delete " unless profile_value(profile_name, 'keep_remote_files') 670 | 671 | local_source_path = profile_value(profile_name, 'local_source_path') 672 | 673 | if not File.file?(local_source_path) 674 | local_source_path = "#{local_source_path}/" 675 | end 676 | 677 | rsync_command_string = "#{rsync_binary} #{rsync_flags} '#{local_source_path}' \"#{strongspace.username}@#{remote_ip}:'#{profile_value(profile_name, 'strongspace_path')}'\"" 678 | 679 | puts "Excludes: #{profile['excludes']}" if DEBUG 680 | 681 | if profile_value(profile_name, 'excludes') 682 | for pattern in profile_value(profile_name, 'excludes').each do 683 | rsync_command_string << " --exclude \"#{pattern}\"" 684 | end 685 | end 686 | 687 | return rsync_command_string 688 | end 689 | 690 | def rsync_command_size(profile_name) 691 | if ENV["RACK_ENV"] == "production" and !File.exist? self.gui_ssh_key 692 | Strongspace::Command.run_internal("keys:generate_for_gui",[]) 693 | end 694 | 695 | if running_on_windows? #cygwin's ssh.exe is sometimes barfing on name resolution, odd. 696 | remote_ip = Socket.getaddrinfo("#{strongspace.username}.strongspace.com", nil)[0][2] 697 | else 698 | remote_ip = "#{strongspace.username}.strongspace.com" 699 | end 700 | 701 | if File.exist? self.gui_ssh_key 702 | rsync_flags = "-e '#{ssh_binary} -oServerAliveInterval=3 -oServerAliveCountMax=1 -o UserKnownHostsFile=#{credentials_folder.to_cygpath}/known_hosts -o PreferredAuthentications=publickey -i \"#{self.gui_ssh_key.to_cygpath}\"' " 703 | else 704 | rsync_flags = "-e '#{ssh_binary} -oServerAliveInterval=3 -oServerAliveCountMax=1' " 705 | end 706 | rsync_flags << "-az --stats --dry-run " 707 | rsync_flags << "--delete " unless profile_value(profile_name, 'keep_remote_files') 708 | 709 | local_source_path = profile_value(profile_name, 'local_source_path') 710 | 711 | if not File.file?(local_source_path) 712 | local_source_path = "#{local_source_path}/" 713 | end 714 | 715 | rsync_command_string = "#{rsync_binary} #{rsync_flags} '#{local_source_path}' \"#{strongspace.username}@#{remote_ip}:'#{profile_value(profile_name, 'strongspace_path')}'\"" 716 | 717 | puts "Excludes: #{profile['excludes']}" if DEBUG 718 | 719 | if profile_value(profile_name, 'excludes') 720 | for pattern in profile_value(profile_name, 'excludes').each do 721 | rsync_command_string << " --exclude \"#{pattern}\"" 722 | end 723 | end 724 | 725 | return rsync_command_string 726 | end 727 | 728 | 729 | def source_changed?(profile_name) 730 | digest = recursive_digest(profile_value(profile_name, 'local_source_path').from_cygpath) 731 | 732 | changed = profile_value(profile_name, 'last_successful_backup_hash') != digest.to_s.strip 733 | if changed 734 | return digest 735 | else 736 | return nil 737 | end 738 | end 739 | 740 | def recursive_digest(path) 741 | # TODO: add excludes to digest computation 742 | digest = Digest::SHA2.new(512) 743 | 744 | Find.find(path) do |entry| 745 | if File.file?(entry) or File.directory?(entry) 746 | stat = File.stat(entry) 747 | digest.update("#{entry} - #{stat.mtime} - #{stat.size}") 748 | end 749 | end 750 | 751 | return digest 752 | end 753 | 754 | 755 | 756 | end 757 | end 758 | -------------------------------------------------------------------------------- /lib/strongspace-rsync/config.rb: -------------------------------------------------------------------------------- 1 | require 'tempfile' 2 | 3 | module StrongspaceRsync 4 | module Config 5 | 6 | def profile_by_name(name=args.first) 7 | profiles = _profiles 8 | profiles.each do |p| 9 | if p['name'] == name 10 | return p 11 | end 12 | end 13 | return nil 14 | end 15 | 16 | def profile_value(profile_name, key_name) 17 | p = profile_by_name(profile_name) 18 | return p[key_name] 19 | end 20 | 21 | def set_profile_value(profile_name, value, key_name) 22 | p = profile_by_name(profile_name) 23 | p[key_name] = value 24 | update_profile(profile_name, p) 25 | end 26 | 27 | def global_value(key_name) 28 | begin 29 | c = _config_dictionary 30 | rescue 31 | c = {} 32 | end 33 | return c[key_name] 34 | end 35 | 36 | def set_global_value(value, key_name) 37 | c = _config_dictionary 38 | c['config_version'] = Strongspace::VERSION 39 | c[key_name] = value 40 | _write_config_dictionary(c) 41 | end 42 | 43 | def set_global_values(hash) 44 | c = _config_dictionary 45 | c['config_version'] = Strongspace::VERSION 46 | c.merge!(hash) 47 | _write_config_dictionary(c) 48 | end 49 | 50 | def profile_exist?(profile_name) 51 | profile_by_name(profile_name) != nil 52 | end 53 | 54 | def add_profile(new_profile) 55 | profiles = _profiles.push(new_profile) 56 | set_global_values('profiles' => profiles) 57 | end 58 | 59 | def delete_profile(profile) 60 | profiles = _profiles 61 | profiles = profiles.reject {|i| i['name'] == profile['name']} 62 | set_global_values('profiles' => profiles) 63 | end 64 | 65 | def update_profile(name, new_profile) 66 | profiles = _profiles 67 | i = profiles.index{|profile| profile['name']== name} 68 | profiles[i] = new_profile 69 | set_global_values('profiles' => profiles) 70 | end 71 | 72 | 73 | def _profiles 74 | begin 75 | r = global_value('profiles') 76 | if r.blank? 77 | return [] 78 | end 79 | rescue 80 | r = [] 81 | end 82 | 83 | return r 84 | end 85 | 86 | def _config_dictionary 87 | contents = nil 88 | begin 89 | File.open(configuration_file, "r", 0644) do |c| 90 | contents = c.readlines.join("\n") 91 | end 92 | 93 | return JSON.load(contents) 94 | rescue 95 | end 96 | return {} 97 | end 98 | 99 | def _write_config_dictionary(dict) 100 | new_config = JSON.pretty_generate(dict) 101 | if new_config.length < 10 102 | display "Strongspace: Error writing config file, please notify developer" 103 | return 104 | end 105 | out = Tempfile.new("rsync-config") 106 | out.write new_config 107 | out.close 108 | FileUtils.mv out.path, configuration_file 109 | out.unlink 110 | end 111 | 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /lib/strongspace-rsync/help.rb: -------------------------------------------------------------------------------- 1 | Strongspace::Command::Help.group('Rsync Backup') do |group| 2 | group.command('rsync:list', 'List backup profiles') 3 | group.command('rsync:run ', 'Run a backup profile') 4 | group.command('rsync:create ', 'Create a backup profile') 5 | group.command('rsync:delete [remove_data]', 'Delete a backup profile, [remove_data=>(yes|no)]]') 6 | group.command('rsync:schedule ', 'Schedules continuous backup') 7 | group.command('rsync:unschedule ', 'Unschedules continuous backup') 8 | group.command('rsync:logs', 'Opens Console.app and shows the Backup log') 9 | end -------------------------------------------------------------------------------- /lib/strongspace-rsync/helpers.rb: -------------------------------------------------------------------------------- 1 | module StrongspaceRsync 2 | module Helpers 3 | def log_file 4 | "#{logs_folder}/#{command_name}.log" 5 | end 6 | 7 | def logs_folder 8 | if running_on_a_mac? 9 | "#{home_directory}/Library/Logs/Strongspace" 10 | else 11 | "#{support_directory}/logs" 12 | end 13 | end 14 | 15 | def scheduled_launch_file(profile_name) 16 | if running_on_a_mac? 17 | "#{launch_files_folder}/com.strongspace.#{command_name_with_profile_name(profile_name)}.plist" 18 | elsif running_on_windows? 19 | "#{launch_files_folder}/com.strongspace.#{command_name_with_profile_name(profile_name)}.vbs" 20 | end 21 | end 22 | 23 | def source_hash_file(profile_name) 24 | "#{support_directory}/#{command_name_with_profile_name(profile_name)}.lastbackup" 25 | end 26 | 27 | def configuration_file 28 | "#{support_directory}/#{command_name}.config" 29 | end 30 | 31 | def rsync_binary 32 | if running_on_windows? 33 | if File.exist? "#{support_directory}\\bin\\rsync.exe" 34 | return "#{support_directory}\\bin\\rsync.exe" 35 | else 36 | return "rsync.exe" 37 | end 38 | end 39 | 40 | if File.exist? "#{support_directory}/bin/rsync" 41 | "#{support_directory}/bin/rsync" 42 | elsif File.exist? "/opt/local/bin/rsync" 43 | "/opt/local/bin/rsync" 44 | elsif File.exist? "/usr/bin/rsync" 45 | "/usr/bin/rsync" 46 | else 47 | "rsync" 48 | end 49 | end 50 | 51 | def default_backup_path 52 | "#{home_directory}/Documents" 53 | end 54 | 55 | def default_space 56 | "backup" 57 | end 58 | 59 | def command_name 60 | "strongspace-rsync" 61 | end 62 | 63 | def command_name_with_profile_name(profile_name) 64 | "#{command_name}.#{profile_name}" 65 | end 66 | 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /lib/strongspace-rsync/version.rb: -------------------------------------------------------------------------------- 1 | module StrongspaceRsync 2 | VERSION = "0.3.7" 3 | end 4 | -------------------------------------------------------------------------------- /spec/rsync_command_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'strongspace' 3 | require 'strongspace/command' 4 | require 'strongspace/commands/base' 5 | require 'strongspace/commands/auth' 6 | 7 | require File.expand_path("../init.rb", File.dirname(__FILE__)) 8 | 9 | 10 | def prepare_command(klass) 11 | command = klass.new(['--app', 'myapp']) 12 | command.stub!(:args).and_return([]) 13 | command.stub!(:display) 14 | command 15 | end 16 | 17 | 18 | 19 | module Strongspace::Command 20 | describe Rsync do 21 | 22 | before do 23 | @rsync_command = prepare_command(Rsync) 24 | end 25 | 26 | it "should print the current version" do 27 | @rsync_command.should_receive(:display).with("RsyncBackup v#{@rsync_command.version}") 28 | @rsync_command.version.should == StrongspaceRsync::VERSION 29 | end 30 | 31 | it "should list no profiles if the configuration file doesn't exist" do 32 | @rsync_command.stub!(:configuration_file).and_return("/tmp/j") 33 | @rsync_command.should_receive(:display).with("Available rsync backup profiles:") 34 | @rsync_command.list.count.should == 0 35 | end 36 | 37 | it "should should fail to load a malformed config file" do 38 | config_mock = "/tmp/RsyncBackup.config.#{Process.pid}" 39 | 40 | File.open(config_mock, 'w') { |f| f.write ''' 41 | { 42 | "profi3les": [ 43 | { 44 | "name": "iPhoto", 45 | "strongspace_path": "/strongspace/hemancuso/Public", 46 | "local_source_path": "/Users/jmancuso/Public", 47 | "last_successful_backup": "2011-01-21T09:35:56-05:00" 48 | }, 49 | { 50 | "name": "iTunes", 51 | "strongspace_path": "/strongspace/hemancuso/anna", 52 | "local_source_path": "/Users/jmancuso/anna" 53 | } 54 | ], 55 | "config_version": "0.1.0" 56 | } 57 | '''} 58 | 59 | @rsync_command.stub!(:configuration_file).and_return(config_mock) 60 | @rsync_command.should_receive(:display).with("Available rsync backup profiles:") 61 | @rsync_command.should_not_receive(:display).with("iPhoto") 62 | @rsync_command.should_not_receive(:display).with("iTunes") 63 | @rsync_command.list 64 | 65 | FileUtils.rm_rf(config_mock) 66 | 67 | end 68 | 69 | describe "with a configuration file" do 70 | 71 | before do 72 | @rsync_command = prepare_command(Rsync) 73 | 74 | @config_mock = "/tmp/RsyncBackup.config.#{Process.pid}" 75 | 76 | File.open(@config_mock, 'w') { |f| f.write ' 77 | { 78 | "profiles": [ 79 | { 80 | "name": "iPhoto", 81 | "strongspace_path": "/strongspace/hemancuso/Public", 82 | "local_source_path": "/Users/jmancuso/Public", 83 | "last_successful_backup": "2011-01-21T09:35:56-05:00" 84 | }, 85 | { 86 | "name": "tmp_test", 87 | "strongspace_path": "/tmp/RsyncBackup.dst", 88 | "local_source_path": "/tmp/RsyncBackup.src" 89 | } 90 | ], 91 | "config_version": "0.1.0" 92 | } 93 | '} 94 | @rsync_command.stub!(:configuration_file).and_return(@config_mock) 95 | 96 | end 97 | 98 | after do 99 | FileUtils.rm_rf(@config_mock) 100 | end 101 | 102 | it "should list the current backup profiles" do 103 | @rsync_command.should_receive(:display).with("Available rsync backup profiles:") 104 | @rsync_command.should_receive(:display).with("iPhoto") 105 | @rsync_command.should_receive(:display).with("tmp_test") 106 | @rsync_command.list.count.should == 2 107 | end 108 | 109 | it "should be able to create a new rsync profile" do 110 | profile_data = {'name' => 'foo', 'local_source_path' => '/tmp/location', 'strongspace_path' => "/strongspace/test" } 111 | 112 | @rsync_command.stub!(:ask_for_new_rsync_profile).and_return(profile_data) 113 | @rsync_command.stub!(:args).and_return(['foo']) 114 | @rsync_command.create 115 | 116 | @rsync_command.should_receive(:display).with("Available rsync backup profiles:") 117 | @rsync_command.should_receive(:display).with("iPhoto") 118 | @rsync_command.should_receive(:display).with("tmp_test") 119 | @rsync_command.should_receive(:display).with("foo") 120 | @rsync_command.list 121 | end 122 | 123 | it "should prevent profile name collisons" do 124 | @rsync_command.should_receive(:display).with("Available rsync backup profiles:") 125 | @rsync_command.should_receive(:display).with("iPhoto") 126 | @rsync_command.should_receive(:display).with("tmp_test") 127 | @rsync_command.list 128 | @rsync_command.stub!(:args).and_return(['iPhoto']) 129 | @rsync_command.should_receive(:display).with("This backup name is already in use") 130 | @rsync_command.create 131 | end 132 | 133 | 134 | it "should be able to delete an rsync profile" do 135 | @rsync_command.should_receive(:display).with("Available rsync backup profiles:") 136 | @rsync_command.should_receive(:display).with("iPhoto") 137 | @rsync_command.should_receive(:display).with("tmp_test") 138 | @rsync_command.list 139 | @rsync_command.stub!(:args).and_return(['iPhoto']) 140 | @rsync_command.should_receive(:display).with("iPhoto has been deleted") 141 | @rsync_command.delete 142 | @rsync_command.should_receive(:display).with("Available rsync backup profiles:") 143 | @rsync_command.should_not_receive(:display).with("iPhoto") 144 | @rsync_command.should_receive(:display).with("tmp_test") 145 | @rsync_command.list 146 | end 147 | 148 | it "should correctly run a backup" do 149 | FileUtils.mkdir_p("/tmp/RsyncBackup.src") 150 | File.open("/tmp/RsyncBackup.src/test_file", 'w') { |f| f.write " 151 | foo bar 152 | "} 153 | 154 | FileUtils.mkdir_p("/tmp/RsyncBackup.dst") 155 | @rsync_command.stub!(:args).and_return(['tmp_test']) 156 | 157 | @rsync_command.stub!(:rsync_command).and_return("rsync -a /tmp/RsyncBackup.src/ /tmp/RsyncBackup.dst/") 158 | 159 | @rsync_command.run.should == true 160 | File.exist?("/tmp/RsyncBackup.dst/test_file").should == true 161 | 162 | FileUtils.rm_rf("/tmp/RsyncBackup.src") 163 | FileUtils.rm_rf("/tmp/RsyncBackup.dst") 164 | end 165 | 166 | end 167 | 168 | end 169 | 170 | end -------------------------------------------------------------------------------- /strongspace-rsync.gemspec: -------------------------------------------------------------------------------- 1 | $:.unshift File.expand_path("../lib", __FILE__) 2 | 3 | Gem::Specification.new do |gem| 4 | gem.name = "strongspace-rsync" 5 | gem.version = "0.3.7" 6 | 7 | gem.author = "Strongspace" 8 | gem.email = "support@strongspace.com" 9 | gem.homepage = "https://www.strongspace.com/" 10 | 11 | gem.summary = "Rsync Backup plugin for Strongspace." 12 | gem.description = "Rsync Backup plugin for Strongspace gem and command=line tool" 13 | gem.homepage = "http://github.com/expandrive/strongspace-rsync" 14 | 15 | gem.files = Dir["**/*"].select { |d| d =~ %r{^(README|bin/|data/|ext/|lib/|spec/|test/)} } 16 | 17 | gem.add_development_dependency "rake" 18 | gem.add_development_dependency "ZenTest" 19 | gem.add_development_dependency "autotest-growl" 20 | #gem.add_development_dependency "autotest-fsevent" 21 | gem.add_development_dependency "rspec", "~> 1.3.0" 22 | gem.add_development_dependency "webmock", "~> 1.5.0" 23 | gem.add_development_dependency "ruby-fsevent" 24 | gem.add_development_dependency "strongspace", "~> 0.3.7" 25 | end 26 | --------------------------------------------------------------------------------