vagrant-mirror 0.1.0.alpha

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,108 @@
1
+ # Monitors changes on the host and guest instance, and propogates any new, changed
2
+ # or deleted files between machines. Note that this will block the vagrant
3
+ # execution on the host.
4
+ #
5
+ # @author Andrew Coulton < andrew@ingerator.com >
6
+ module Vagrant
7
+ module Mirror
8
+ module Middleware
9
+ class Mirror < Base
10
+
11
+ # Loads the rest of the middlewares first, then finishes up by running
12
+ # the mirror middleware. This allows the listener to start after the
13
+ # instance has been provisioned.
14
+ #
15
+ # @param [Vagrant::Action::Environment] The environment
16
+ def call(env)
17
+ @app.call(env)
18
+
19
+ mirrors = env[:vm].config.mirror.folders
20
+ if !mirrors.empty?
21
+ execute(mirrors, env)
22
+ else
23
+ env[:ui].info("No vagrant-mirror mirrored folders configured for this box")
24
+ end
25
+ end
26
+
27
+ protected
28
+
29
+ # Mirrors the folder pairs configured in the vagrantfile
30
+ #
31
+ # @param [Array] The folder pairs to synchronise
32
+ # @param [Vagrant::Action::Environment] The environment
33
+ def execute(mirrors, env)
34
+ ui = env[:ui]
35
+ ui.info("Beginning directory mirroring")
36
+
37
+ begin
38
+ workers = []
39
+
40
+ # Create a thread to work off the queue for each folder
41
+ each_mirror(mirrors) do | host_path, guest_sf_path, mirror_config |
42
+ workers << Thread.new do
43
+ # Set up the listener and the changes queue
44
+ Thread.current["queue"] = Queue.new
45
+ host_listener = Vagrant::Mirror::Listener::Host.new(host_path, Thread.current["queue"])
46
+ rsync = Vagrant::Mirror::Rsync.new(env[:vm], guest_sf_path, host_path, mirror_config)
47
+
48
+ # Start listening and store the thread reference
49
+ Thread.current["listener"] = host_listener.listen
50
+
51
+ # Just poll indefinitely waiting for changes or to be told to quit
52
+ quit = false
53
+ while !quit
54
+ change = Thread.current["queue"].pop
55
+ if (change[:quit])
56
+ quit = true
57
+ else
58
+ # Handle removed files first - guard sometimes flagged as deleted when they aren't
59
+ # So we first check if the file has been deleted on the host. If so, we delete on
60
+ # the guest, otherwise we add to the list to rsync in case there are changes
61
+ changes = []
62
+ change[:removed].each do | relpath |
63
+ if (File.exists?(File.join(host_path, relpath)))
64
+ changes << relpath
65
+ else
66
+ target = "#{mirror_config[:guest_path]}/#{relpath}"
67
+ ui.warn("XX Deleting #{target}")
68
+ env[:vm].channel.sudo("rm #{target}")
69
+ end
70
+ end
71
+
72
+ # Add to the list of deletions with each of the modified and added files
73
+ changes = changes + change[:added] + change[:modified]
74
+ changes.each do | relpath |
75
+ ui.info(">> #{relpath}")
76
+ rsync.run(relpath)
77
+ end
78
+
79
+ # Beep if configured
80
+ if (mirror_config[:beep])
81
+ print "\a"
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
87
+
88
+ # Wait for the listener thread to exit
89
+ workers.each do | thread |
90
+ thread.join
91
+ end
92
+ rescue RuntimeError => e
93
+ # Pass through Vagrant errors
94
+ if e.is_a? Vagrant::Errors::VagrantError
95
+ raise
96
+ end
97
+
98
+ # Convert to a vagrant error descendant so that the box is not cleaned up
99
+ raise Vagrant::Mirror::Errors::Error.new("Vagrant-mirror caught a #{e.class.name} - #{e.message}")
100
+ end
101
+
102
+ ui.success("Completed directory synchronisation")
103
+ end
104
+
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,92 @@
1
+ require 'date'
2
+ require 'fileutils'
3
+
4
+ # Executes a full sync from the host to the guest instance based on the configuration
5
+ # in the vagrantfile, copying new or changed files to the guest as required.
6
+ #
7
+ # @author Andrew Coulton < andrew@ingerator.com >
8
+ module Vagrant
9
+ module Mirror
10
+ module Middleware
11
+ class Sync < Base
12
+
13
+ # Loads the rest of the middlewares first, then finishes up by running
14
+ # the sync middleware. This is required because the core share_folders
15
+ # middleware does not mount the shares until the very end of the process
16
+ # and we need to run after that
17
+ #
18
+ # @param [Vagrant::Action::Environment] The environment
19
+ def call(env)
20
+ @app.call(env)
21
+
22
+ mirrors = env[:vm].config.mirror.folders
23
+ if !mirrors.empty?
24
+ execute(mirrors, env)
25
+ else
26
+ env[:ui].info("No vagrant-mirror mirrored folders configured for this box")
27
+ end
28
+ end
29
+
30
+ protected
31
+
32
+ # Synchronizes the folder pairs configured in the vagrantfile
33
+ #
34
+ # @param [Array] The folder pairs to synchronise
35
+ # @param [Vagrant::Action::Environment] The environment
36
+ def execute(mirrors, env)
37
+ ui = env[:ui]
38
+ ui.info("Beginning directory synchronisation at " + DateTime.now.iso8601)
39
+
40
+ begin
41
+ each_mirror(mirrors) do | host_path, guest_sf_path, mirror_config |
42
+ ui.info("Synchronising for #{host_path}")
43
+
44
+ # Create any required symlinks
45
+ mirror_config[:symlinks].each do | relpath |
46
+ relpath.sub!(/^\//, '')
47
+ source = "#{guest_sf_path}/#{relpath}"
48
+ target = "#{mirror_config[:guest_path]}/#{relpath}"
49
+
50
+ # Find the parent directory - we have to do this with regexp as we don't have the
51
+ # right filesystem to use File.expand
52
+ dirs = /^(.*)(\/.+)$/.match(target)
53
+ if (dirs)
54
+ target_dir = dirs[1]
55
+ else
56
+ target_dir = '/'
57
+ end
58
+
59
+ # Create the host directory if required
60
+ host_dir = "#{host_path}/#{relpath}"
61
+ if ( ! File.exists?(host_dir))
62
+ FileUtils.mkdir_p(host_dir)
63
+ end
64
+
65
+ # Create the parent directory and create the symlink
66
+ ui.info("Creating link from #{target} to #{source}")
67
+ env[:vm].channel.sudo("rm -f #{target} && mkdir -p #{target_dir} && ln -s #{source} #{target}")
68
+ end
69
+
70
+ # Trigger the sync on the remote host
71
+ ui.info("Synchronising from #{guest_sf_path} to #{mirror_config[:guest_path]}")
72
+ rsync = Vagrant::Mirror::Rsync.new(env[:vm], guest_sf_path, host_path, mirror_config)
73
+ rsync.run('/')
74
+ end
75
+
76
+ rescue RuntimeError => e
77
+ # Pass through Vagrant errors
78
+ if e.is_a? Vagrant::Errors::VagrantError
79
+ raise
80
+ end
81
+
82
+ # Convert to a vagrant error descendant so that the box is not cleaned up
83
+ raise Vagrant::Mirror::Errors::Error.new("Vagrant-mirror caught a #{e.class.name} - #{e.message}")
84
+ end
85
+
86
+ ui.success("Completed directory synchronisation at " + DateTime.now.iso8601)
87
+ end
88
+
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,91 @@
1
+ # Executes rsync on the guest to update files between the shared folder and the local virtual disk.
2
+ # Propogates deletes if configured to do so, but not otherwise.
3
+ #
4
+ # @author Andrew Coulton < andrew@ingerator.com >
5
+ module Vagrant
6
+ module Mirror
7
+ class Rsync
8
+ # @return [Vagrant::VM] The VM to mirror on
9
+ attr_reader :vm
10
+
11
+ # @return [String] The path to the virtualbox shared folder on the guest
12
+ attr_reader :guest_sf_path
13
+
14
+ # @return [String] The path to the virtualbox shared folder on the host
15
+ attr_reader :host_path
16
+
17
+ # @return [Bool] Whether Rsync should delete unexpected files
18
+ attr_reader :delete
19
+
20
+ # @return [Array] The array of paths to exclude
21
+ attr_reader :excludes
22
+
23
+ # @return [String] The exclude paths formatted as an array of rsync exclude arguments
24
+ attr_reader :exclude_args
25
+
26
+ # @return [String] The path to the mirror folder on the guest
27
+ attr_reader :guest_path
28
+
29
+ # Creates an instance
30
+ #
31
+ # @param [Vagrant::VM] The VM to mirror on
32
+ # @param [String] The path of the shared folder on the guest to use as the rsync source
33
+ # @param [String] The path of the shared folder on the host - used to check whether a path is a directory
34
+ # @param [Hash] The config.mirror options hash with the guest mirror destination, delete, etc
35
+ def initialize(vm, guest_sf_path, host_path, mirror_config)
36
+ @vm = vm
37
+ @guest_sf_path = guest_sf_path
38
+ @host_path = host_path
39
+ @delete = mirror_config[:delete]
40
+ @excludes = mirror_config[:exclude]
41
+ @guest_path = mirror_config[:guest_path]
42
+
43
+ # Build the exclude argument array
44
+ @exclude_args = []
45
+ if (@excludes.count > 0)
46
+ @excludes.each do | exclude |
47
+ exclude_args << "--exclude '#{exclude}'"
48
+ end
49
+ end
50
+ end
51
+
52
+ # Run rsync on the guest to update a path - either the whole mirror directory or individual
53
+ # files and folders within it.
54
+ #
55
+ # @param [String] The path to run in
56
+ def run(path)
57
+ # Strip a leading / off the path to avoid any problems
58
+ path.sub!(/^\//, '')
59
+
60
+ # Build the source and destination paths
61
+ source = "#{guest_sf_path}/#{path}"
62
+ dest = "#{guest_path}/#{path}"
63
+
64
+ # Check if the source is a directory on the host - if so, add a / for rsync
65
+ if ((path != '') && (File.directory?(File.join(host_path, path))))
66
+ source << '/'
67
+ dest << '/'
68
+ end
69
+
70
+
71
+ # Build the rsync command
72
+ args = ['rsync -av']
73
+
74
+ if (delete)
75
+ args << '--del'
76
+ end
77
+
78
+ args = args + exclude_args
79
+
80
+ args << source
81
+ args << dest
82
+
83
+ cmd = args.join(' ')
84
+
85
+ # Run rsync
86
+ vm.channel.sudo(cmd)
87
+ end
88
+
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,60 @@
1
+ # Compares the contents of the host and guest paths and transfers any
2
+ # missing or modified paths to the other side of the mirror. If a whole
3
+ # directory is missing, uses recursive upload/download from the SFTP class,
4
+ # otherwise it iterates over and compares the directory contents.
5
+ #
6
+ # This class does not detect deletions - if a file is missing on one side
7
+ # of the mirror it will simply be replaced.
8
+ #
9
+ # @author Andrew Coulton < andrew@ingenerator.com >
10
+ module Vagrant
11
+ module Mirror
12
+ module Sync
13
+ class All < Base
14
+
15
+ # Compares a folder between guest and host, transferring any new or
16
+ # modified files in the right direction.
17
+ #
18
+ # @param [string] The base path to compare
19
+ def execute(path)
20
+ path = path.chomp('/')
21
+ host_dir = host_path(path).chomp('/')
22
+ guest_dir = guest_path(path).chomp('/')
23
+
24
+ if !@connection.exists?(guest_dir)
25
+ # Create the guest directory
26
+ @connection.mkdir(guest_dir)
27
+ end
28
+
29
+ if !File.exists?(host_dir)
30
+ # Create the host directory
31
+ FileUtils.mkdir_p(host_dir)
32
+ end
33
+
34
+ # If the guest path already exists, have to sync manually
35
+ # First get a combined listing of the two paths
36
+ all_files = @connection.dir_entries(guest_dir) | Dir.entries(host_dir)
37
+ all_files.each do | file |
38
+ # Ignore . and ..
39
+ if (file == '.') or (file == '..')
40
+ next
41
+ end
42
+
43
+ # Get local paths
44
+ host_file = File.join(host_dir, file)
45
+ guest_file = File.join(guest_dir, file)
46
+ # Recurse for directories
47
+ if File.directory?(host_file) \
48
+ or ( !File.exists?(host_file) and @connection.directory?(guest_file))
49
+ execute("#{path}/#{file}")
50
+ else
51
+ # Transfer new/modified files between host and guest
52
+ compare_and_transfer(host_file, guest_file)
53
+ end
54
+ end
55
+
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,98 @@
1
+ # Base class for file sync actions, providing common required functionality
2
+ #
3
+ # @author Andrew Coulton < andrew@ingerator.com >
4
+ module Vagrant
5
+ module Mirror
6
+ module Sync
7
+ class Base
8
+
9
+ # Initialises the synchroniser
10
+ #
11
+ # @param [Vagrant::Mirror::Connection::SFTP] The sftp connection instance
12
+ # @param [String] The root path on the host to mirror
13
+ # @param [String] The root path on the guest to mirror
14
+ # @param [Vagrant::UI::Interface] The Vagrant UI class
15
+ def initialize(connection, host_path, guest_path, ui)
16
+ @connection = connection
17
+ @host_root = host_path
18
+ @guest_root = guest_path
19
+ @ui = ui
20
+ end
21
+
22
+ # ==================================================================
23
+ # Begin protected internal methods
24
+ # ==================================================================
25
+ protected
26
+
27
+ # Gets the absolute host path for a file
28
+ #
29
+ # @param [String] Relative path to the file
30
+ # @return [String] Absolute path to the file on the host
31
+ def host_path(relative_path)
32
+ File.join(@host_root, relative_path)
33
+ end
34
+
35
+ # Gets the absolute guest path for a file
36
+ #
37
+ # @param [String] Relative path to the file
38
+ # @return [String] Absolute path to the file on the guest
39
+ def guest_path(relative_path)
40
+ File.join(@guest_root, relative_path)
41
+ end
42
+
43
+ # Gets the mtime of a file on the host from an absolute path
44
+ #
45
+ # @param [String] Absolute host path
46
+ # @return [Time] mtime, or nil if the file does not exist
47
+ def host_mtime(path)
48
+ if !File.exists?(path)
49
+ return nil
50
+ end
51
+ return File.mtime(path)
52
+ end
53
+
54
+ # Gets the mtime of a file on the guest from an absolute path
55
+ #
56
+ # @param [String] Absolute guest path
57
+ # @return [Time] mtime, or nil if the file does not exist
58
+ def guest_mtime(path)
59
+ @connection.mtime(path)
60
+ end
61
+
62
+ # Compares files on the host and the guest and transfers the newest
63
+ # to the other side.
64
+ #
65
+ # @param [String] Absolute host path
66
+ # @param [String] Absolute guest path
67
+ def compare_and_transfer(host_file, guest_file)
68
+
69
+ # Get the mtimes
70
+ host_time = host_mtime(host_file)
71
+ guest_time = guest_mtime(guest_file)
72
+
73
+ # Check what to do
74
+ if (host_time.nil? and guest_time.nil?) then
75
+ # Report an error
76
+ @ui.error("#{host_file} was not found on either the host or guest filesystem - cannot sync")
77
+ elsif (host_time == guest_time)
78
+ # Do nothing
79
+ return
80
+ elsif (guest_time.nil?)
81
+ # Transfer to guest
82
+ @connection.upload(host_file, guest_file, host_time)
83
+ elsif (host_time.nil?)
84
+ # Transfer to host
85
+ @connection.download(guest_file, host_file, guest_time)
86
+ elsif (host_time > guest_time)
87
+ # Transfer to guest
88
+ @connection.upload(host_file, guest_file, host_time)
89
+ elsif (host_time < guest_time)
90
+ # Transfer to guest
91
+ @connection.download(guest_file, host_file, guest_time)
92
+ end
93
+ end
94
+
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,61 @@
1
+ # Processes notified file additions, modifications and deletions notified by
2
+ # guard/listen, and replays them on the other side of the mirror.
3
+ # The execute method will be called for each changeset notified whether
4
+ # by guard or over the TCP socket (from guard on the guest). This could
5
+ # cause a race condition as changes on the guest trigger changes on the host
6
+ # and vice versa.
7
+ #
8
+ # Therefore, on notification of added or modified file, the class just
9
+ # syncs whichever is the newest file between the two machines. On deletion
10
+ # it will quietly fail if the file it is notified of has already been
11
+ # deleted.
12
+ #
13
+ # @author Andrew Coulton < andrew@ingenerator.com >
14
+ module Vagrant
15
+ module Mirror
16
+ module Sync
17
+ class Changes < Base
18
+
19
+ # Compares a single notified changeset and transfers any changed,
20
+ # modified or removed files in the right direction.
21
+ #
22
+ # @param [Symbol] Which side of the mirror the change was detected
23
+ # @param [Array] Array of added paths
24
+ # @param [Array] Array of changed paths
25
+ # @param [Array] Array of removed paths
26
+ def execute(source, added, modified, removed)
27
+
28
+ # Combine added and modified, they're the same for our purposes
29
+ changed = added + modified
30
+ changed.each do |file|
31
+ # Transfer the newest file to the other side, or do nothing
32
+ compare_and_transfer(host_path(file), guest_path(file))
33
+ end
34
+
35
+ # Process deleted files
36
+ removed.each do |file|
37
+ # Expect a cascade - only delete on opposite side if exists
38
+ if source == :host
39
+ guest_file = guest_path(file)
40
+ if !guest_mtime(guest_file).nil?
41
+ @connection.delete(guest_file)
42
+ else
43
+ @ui.info("#{file} was not found on guest - nothing to delete")
44
+ end
45
+ elsif source == :guest
46
+ host_file = host_path(file)
47
+ if File.exists?(host_file)
48
+ File.delete(host_file)
49
+ else
50
+ @ui.info("#{file} was not found on host - nothing to delete")
51
+ end
52
+ end
53
+ end
54
+
55
+ # Complete all transfers
56
+ @connection.finish_transfers
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,5 @@
1
+ module Vagrant
2
+ module Mirror
3
+ VERSION = "0.1.0.alpha"
4
+ end
5
+ end
@@ -0,0 +1,34 @@
1
+ require 'vagrant'
2
+
3
+ require 'vagrant-mirror/errors'
4
+ require 'vagrant-mirror/version'
5
+ require 'vagrant-mirror/config'
6
+
7
+ # Require the host listener
8
+ require 'vagrant-mirror/listener/host'
9
+
10
+ # Require the rsync wrapper
11
+ require 'vagrant-mirror/rsync'
12
+
13
+ # Require the middlewares
14
+ require 'vagrant-mirror/middleware/base'
15
+ require 'vagrant-mirror/middleware/sync'
16
+ require 'vagrant-mirror/middleware/mirror'
17
+
18
+ # Require the command
19
+ require 'vagrant-mirror/command'
20
+
21
+ # Register the config
22
+ Vagrant.config_keys.register(:mirror) { Vagrant::Mirror::Config }
23
+
24
+ # Register the command
25
+ Vagrant.commands.register(:mirror) { Vagrant::Mirror::Command }
26
+
27
+ # Add the sync middleware to the start stack
28
+ Vagrant.actions[:start].insert Vagrant::Action::VM::ShareFolders, Vagrant::Mirror::Middleware::Sync
29
+
30
+ # Add the mirror middleware to the standard stacks
31
+ Vagrant.actions[:start].insert Vagrant::Action::VM::Provision, Vagrant::Mirror::Middleware::Mirror
32
+
33
+ # Abort on unhandled exceptions in any thread
34
+ Thread.abort_on_exception = true
@@ -0,0 +1,4 @@
1
+ # This file is automatically loaded by Vagrant to load any
2
+ # plugins. This file kicks off this plugin.
3
+
4
+ require 'vagrant-mirror'
@@ -0,0 +1,15 @@
1
+ require 'vagrant-mirror'
2
+
3
+ RSpec.configure do |config|
4
+ config.treat_symbols_as_metadata_keys_with_true_values = true
5
+ config.run_all_when_everything_filtered = true
6
+ config.filter_run :focus
7
+
8
+ # Run specs in random order to surface order dependencies. If you find an
9
+ # order dependency and want to debug it, you can fix the order by providing
10
+ # the seed, which is printed after each run.
11
+ # --seed 1234
12
+ config.order = 'random'
13
+
14
+ config.include Vagrant::TestHelpers
15
+ end
@@ -0,0 +1,91 @@
1
+ describe Vagrant::Mirror::Command do
2
+ let (:argv) { [] }
3
+ let (:env) { double("Vagrant::Environment").as_null_object }
4
+ let (:vm) { double("Vagrant::VM").as_null_object }
5
+ let (:ui) { double("Vagrant::UI::Interface").as_null_object }
6
+
7
+ before (:each) do
8
+ env.stub(:primary_vm).and_return vm
9
+ env.stub(:multivm?).and_return false
10
+ env.stub(:ui).and_return ui
11
+ end
12
+
13
+ subject { Vagrant::Mirror::Command.new(argv, env) }
14
+
15
+ describe "#execute" do
16
+ shared_examples "cannot run in multivm" do
17
+ before (:each) do
18
+ env.stub(:multivm?).and_return(true)
19
+ end
20
+
21
+ it "throws a SingleVMEnvironmentRequired error" do
22
+ expect { subject.execute }.to raise_error(Vagrant::Mirror::Errors::SingleVMEnvironmentRequired)
23
+ end
24
+ end
25
+
26
+ context "with vagrant mirror sync" do
27
+ let (:argv) { ['sync' ] }
28
+
29
+ it_behaves_like "cannot run in multivm"
30
+
31
+ it "runs the sync middleware with primary vm" do
32
+ vm.should_receive(:run_action).with(Vagrant::Mirror::Middleware::Sync)
33
+
34
+ subject.execute
35
+ end
36
+
37
+ end
38
+
39
+ shared_examples "the monitor command" do
40
+
41
+ it "runs the monitor middleware with primary vm" do
42
+ vm.should_receive(:run_action).with(Vagrant::Mirror::Middleware::Mirror)
43
+
44
+ subject.execute
45
+ end
46
+ end
47
+
48
+ context "with vagrant mirror monitor" do
49
+ let (:argv) { ['monitor' ] }
50
+
51
+ it_behaves_like "cannot run in multivm"
52
+ it_behaves_like "the monitor command"
53
+ end
54
+
55
+ context "with an empty command" do
56
+ let (:argv) { [] }
57
+
58
+ it_behaves_like "cannot run in multivm"
59
+ it_behaves_like "the monitor command"
60
+ end
61
+
62
+ shared_examples "the help command" do
63
+ it "prints valid usage" do
64
+ ui.should_receive(:info).with(/monitor/, anything())
65
+
66
+ subject.execute
67
+ end
68
+
69
+ it "does not run anything" do
70
+ ui.stub(:info)
71
+ vm.should_not_receive(:run_action)
72
+
73
+ subject.execute
74
+ end
75
+ end
76
+
77
+ context "with unknown command" do
78
+ let (:argv) { ['nothing'] }
79
+
80
+ it_behaves_like "cannot run in multivm"
81
+ it_behaves_like "the help command"
82
+ end
83
+
84
+ context "with help command" do
85
+ let (:argv) { ['-h', 'monitor'] }
86
+
87
+ it_behaves_like "the help command"
88
+ end
89
+
90
+ end
91
+ end