offshore 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore ADDED
@@ -0,0 +1,19 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
18
+ .DS_Store
19
+
data/.rvmrc ADDED
@@ -0,0 +1,2 @@
1
+ rvm use ruby-1.9.3-p194@offshore --install --create
2
+ export PATH=./bin:$PATH
data/Gemfile ADDED
@@ -0,0 +1,6 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gem 'json_pure'
4
+
5
+ # Specify your gem's dependencies in offshore.gemspec
6
+ gemspec
data/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2012 Brian Leonard
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,111 @@
1
+ # Offshore
2
+
3
+ For when you need a remote factory.
4
+ Let's say you love testing, but your app heavily depends on another app and it's data in a certain format. You have API and/or database access to this data.
5
+
6
+ Offshore allows you use the factories of that app within your test suite as well as adds transactional support to the database of that app so that each test starts with the same fixtures.
7
+
8
+ #### Notes
9
+
10
+ * For now, this only works on MySQL databases
11
+ * Redis is needed to run as well
12
+
13
+ ## Server
14
+
15
+ The server app is the one with the factories and the database that your app needs to work.
16
+ For Rails, add this to your Gemfile:
17
+
18
+ group :test do
19
+ gem 'offshore'
20
+ end
21
+
22
+ You might need something like this to your test.rb application config:
23
+
24
+ Offshore.redis = "localhost:6379"
25
+
26
+ Then run something like this on the command line
27
+
28
+ rails s thin -e test -p 4111
29
+
30
+ In you want it anything but blank, you must create a rake task called offshore:seed that creates the test database referenced in the database.yml file in the test environment.
31
+ Something like this would work.
32
+
33
+ namespace :offshore do
34
+ task :preload do
35
+ ENV['RAILS_ENV'] = "test"
36
+ end
37
+ task :setup => :environment
38
+
39
+ desc "seeds the db for offshore gem"
40
+ task :seed do
41
+ Rake::Task['db:migrate'].invoke
42
+ Rake::Task['db:test:prepare'].invoke
43
+ Rake::Task['db:seed'].invoke
44
+ end
45
+ end
46
+
47
+ The :preload and :setup steps will be invoked in that order before your :seed call. They are actually unnecessary here, but shown in case you have something more complex to do.
48
+
49
+ #### Notes
50
+
51
+ * For other Rack apps, you should be able to use Offshore::Middleware directly.
52
+ * This middleware is included automatically in Rails via Railties.
53
+ * The server is meant to be a singular resource accessed by many test threads (parallelization of tests). It accomplishes this through a mutex and polling its availability.
54
+
55
+
56
+
57
+ ## Client
58
+
59
+ The client app is the one running the tests.
60
+
61
+ The same thing in the Gemfile:
62
+
63
+ group :test do
64
+ gem 'offshore'
65
+ end
66
+
67
+ The Rspec config looks likes this:
68
+
69
+ config.before(:suite) do
70
+ Offshore.suite.start(:host => "localhost", :port => 4111)
71
+ end
72
+
73
+ config.before(:each) do
74
+ Offshore.test.start(example)
75
+ end
76
+
77
+ config.after(:each) do
78
+ Offshore.test.stop(example)
79
+ end
80
+
81
+ config.after(:suite) do
82
+ Offshore.suite.stop
83
+ end
84
+
85
+ Then in your test you can do:
86
+
87
+ user = FactoryOffshore.create(:user, :first_name => "Johnny")
88
+ user.first_name.should == "Johnny"
89
+ user.class.should == ::User
90
+
91
+ This assumes that you have a class by the same name the the :user factory made from the server that responds to find() with the id created on the server.
92
+
93
+ You can send :snapshot => false to Offshore.suite.start to prevent rolling back before the test.
94
+ Note, this will leave your suite in a somewhat unpredictable state especially when you consider there are other suites that might be rolling that database back.
95
+ However, this may be preferable if your database is very large. On small (50 tables / 1000 rows) databases, the difference in time seems to be noise. Some efforts are taken (checksum) to not rollback if the test did not change the database.
96
+
97
+
98
+ #### Notes
99
+
100
+ * You can also make API requests.
101
+ * You get a fresh database each time by default.
102
+
103
+ ## TODO
104
+
105
+ * Use binlogs if enabled for even faster MySQL rollback
106
+ * Configure custom lookups for the hash returned with the created data
107
+ * Configure custom paths (defaults to /offshore_tests now)
108
+ * Anything else need to be cleared out each test? i.e. redis, memcache, etc
109
+ * Other DB support
110
+ * Other MySQL adapter support
111
+
data/Rakefile ADDED
@@ -0,0 +1,2 @@
1
+ $LOAD_PATH.unshift 'lib'
2
+ require 'offshore/tasks'
@@ -0,0 +1,8 @@
1
+ class FactoryOffshore
2
+ def self.create(*args)
3
+ host = Offshore.suite.default
4
+ host.factory_create(*args)
5
+ end
6
+
7
+ # TODO: multiple hosts
8
+ end
@@ -0,0 +1,110 @@
1
+ require "faraday"
2
+
3
+ module Offshore
4
+ class Host
5
+ # def client
6
+ # return @http if @http
7
+ # @http = Net::HTTP.new(host, port)
8
+ # # @http.use_ssl = true
9
+ # # @http.verify_mode = OpenSSL::SSL::VERIFY_NONE
10
+ # @http
11
+ # end
12
+ #
13
+ # def post(append_path, attributes)
14
+ # request = Net::HTTP::Post.new()
15
+ # request.set_form_data(attributes)
16
+ # response = client.request(request)
17
+ # response.value
18
+ # JSON.parse(response.body)
19
+ # end
20
+
21
+ def base_uri
22
+ with_http = host
23
+ with_http = "http://#{host}" unless (host =~ /^https?:/) == 0
24
+ need_port = port || 80
25
+ "#{with_http}:#{need_port}"
26
+ end
27
+
28
+ def connection
29
+ return @connection if @connection
30
+ connection_class = Faraday.respond_to?(:new) ? ::Faraday : ::Faraday::Connection
31
+
32
+ timeout_seconds = 5*60 # 5 minutes
33
+ @connection = connection_class.new(base_uri, :timeout => timeout_seconds) do |builder|
34
+ builder.use Faraday::Request::UrlEncoded if defined?(Faraday::Request::UrlEncoded)
35
+ builder.adapter Faraday.default_adapter
36
+ end
37
+ @connection
38
+ end
39
+
40
+ def post(append_path, attributes={})
41
+ attributes[:hostname] = `hostname` # always send hostname
42
+
43
+ while true do
44
+ http_response = connection.post("#{self.path}/#{append_path}", attributes)
45
+ if http_response.success?
46
+ return MultiJson.decode(http_response.body)
47
+ elsif http_response.status.to_s == Offshore::WAIT_CODE.to_s
48
+ sleep 2 # two seconds and try again
49
+ else
50
+ begin
51
+ hash = MultiJson.decode(http_response.body)
52
+ message = "Error in offshore connection (#{append_path}): #{hash}"
53
+ rescue
54
+ message = "Error in offshore connection (#{append_path}): #{http_response.status}"
55
+ end
56
+ raise message
57
+ end
58
+ end
59
+ end
60
+
61
+ attr_reader :host, :port
62
+ def initialize(options)
63
+ @host = options[:host]
64
+ @port = options[:port]
65
+ @path = options[:path]
66
+ @snapshot = options[:snapshot]
67
+ end
68
+
69
+ def snapshot_name
70
+ return nil if @snapshot == true
71
+ return "none" if @snapshot == false
72
+ @snapshot
73
+ end
74
+
75
+ def path
76
+ @path ||= "/offshore_tests"
77
+ end
78
+
79
+ def suite_start
80
+ attributes = {}
81
+ attributes[:snapshot] = snapshot_name
82
+ hash = post(:suite_start, attributes)
83
+ end
84
+
85
+ def suite_stop
86
+ hash = post(:suite_stop)
87
+ end
88
+
89
+ def test_start(name)
90
+ hash = post(:test_start, { :name => name })
91
+ end
92
+
93
+ def test_stop(name)
94
+ hash = post(:test_stop, { :name => name })
95
+ end
96
+
97
+ def factory_create(name, attributes={})
98
+ data = { :name => name }
99
+ data[:attributes] = attributes
100
+ hash = post(:factory_create, data)
101
+ factory_object(hash)
102
+ end
103
+
104
+ # TODO: move this to a config block
105
+ def factory_object(hash)
106
+ hash["class_name"].constantize.find(hash["id"])
107
+ end
108
+
109
+ end
110
+ end
@@ -0,0 +1,33 @@
1
+ module Offshore
2
+ class Suite
3
+ def hosts
4
+ @hosts ||= []
5
+ end
6
+
7
+ def default
8
+ hosts.first
9
+ end
10
+
11
+ def all_hosts!(method, *args)
12
+ hosts.each do |host|
13
+ host.send(method, *args)
14
+ end
15
+ end
16
+
17
+ def start(server_array_or_hash)
18
+ server_array = server_array_or_hash.is_a?(Array) ? server_array_or_hash : [server_array_or_hash]
19
+ raise "Need one server" if server_array.size != 1
20
+
21
+ server_array.each do |hash|
22
+ hosts << Host.new(hash)
23
+ end
24
+
25
+ all_hosts!(:suite_start)
26
+ end
27
+
28
+ def stop
29
+ Offshore.test # if the last one failed this will stop it
30
+ all_hosts!(:suite_stop)
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,39 @@
1
+ module Offshore
2
+ class Test
3
+ def uuid
4
+ @uid ||= rand(99999999).to_s
5
+ end
6
+
7
+ def get_name(example)
8
+ name = nil
9
+ name = example.full_description if example.respond_to?(:full_description)
10
+ name ||= "unknown"
11
+ "#{uuid} #{name}"
12
+ end
13
+
14
+ def run!(example)
15
+ raise "already run test: #{get_name(@run_example)}" if @run_example
16
+ @run_example = example
17
+ end
18
+
19
+ def run?
20
+ !!@run_example
21
+ end
22
+
23
+ def start(example=nil)
24
+ run!(example)
25
+ Offshore.suite.all_hosts!(:test_start, get_name(example))
26
+ end
27
+
28
+ def stop(example=nil)
29
+ Offshore.suite.all_hosts!(:test_stop, get_name(example))
30
+ Offshore.send(:internal_test_ended)
31
+ end
32
+
33
+ def failed
34
+ raise "have not run!" unless @run_example
35
+ stop(@run_example)
36
+ end
37
+
38
+ end
39
+ end
@@ -0,0 +1,4 @@
1
+ require "offshore/client/host"
2
+ require "offshore/client/suite"
3
+ require "offshore/client/test"
4
+ require "offshore/client/factory_offshore"
@@ -0,0 +1,81 @@
1
+ module Offshore
2
+ class Controller
3
+ attr_reader :params
4
+ def initialize(hash)
5
+ @params = hash
6
+ end
7
+
8
+ def suite_start
9
+ suite_key_set(params)
10
+ [200, {"todo" => "log that the suite is running"}]
11
+ end
12
+
13
+ def suite_stop
14
+ suite_key_clear
15
+ [200, {"action" => "cleared"}]
16
+ end
17
+
18
+ def test_start
19
+ Logger.info(" Start: #{params["name"]}")
20
+ Offshore::Database.lock
21
+ Offshore::Database.rollback if suite_key_options["snapshot"] != "none"
22
+ [200, {"action" => "reset / lock"}]
23
+ end
24
+
25
+ def test_stop
26
+ Logger.info(" Stop: #{params["name"]}")
27
+ Offshore::Database.unlock
28
+ [200, {"action" => "unlocked"}]
29
+ end
30
+
31
+ def factory_create
32
+ name = params["name"]
33
+ raise "Need a factory name" unless name
34
+ attributes = params["attributes"] || {}
35
+
36
+ clazz = factory_girl
37
+ require "factory_girl" unless clazz
38
+ clazz = factory_girl
39
+
40
+ begin
41
+ val = clazz.create(name, attributes)
42
+ out = {:class_name => val.class.name, :id => val.id, :attributes => val.attributes}
43
+ status = 200
44
+ rescue ActiveRecord::RecordInvalid => invalid
45
+ out = {:error => invalid.record.errors.full_messages.join(",") }
46
+ status = 422
47
+ end
48
+ [status, out]
49
+ end
50
+
51
+ private
52
+
53
+ def factory_girl
54
+ if defined? FactoryGirl
55
+ return FactoryGirl
56
+ elsif defined? Factory
57
+ return Factory
58
+ end
59
+ return nil
60
+ end
61
+
62
+ def suite_key_set(val)
63
+ Offshore.redis.set(suite_key, MultiJson.encode(val))
64
+ @suite_key_options = nil
65
+ end
66
+
67
+ def suite_key_clear
68
+ Offshore.redis.del(suite_key)
69
+ end
70
+
71
+ def suite_key_options
72
+ @suite_key_options ||= MultiJson.decode(Offshore.redis.get(suite_key) || "{}")
73
+ end
74
+
75
+ def suite_key
76
+ raise "need a hostname" unless params["hostname"]
77
+ "suite:#{params["hostname"]}:options"
78
+ end
79
+
80
+ end
81
+ end
@@ -0,0 +1,111 @@
1
+ module Offshore
2
+ module Database
3
+ extend self
4
+
5
+ GENERATE_KEY = "database:generate"
6
+ LOCK_KEY = "database:lock"
7
+ SHUTDOWN_KEY = "database:shutdown"
8
+ SUITES_LIST_KEY = "suites:list"
9
+
10
+ def redis
11
+ Offshore.redis
12
+ end
13
+
14
+ def lock
15
+ Logger.info(" Database.lock")
16
+ if Offshore::Mutex.lock(LOCK_KEY)
17
+ Logger.info(" .... lock acquired")
18
+ else
19
+ Logger.info(" .... locked")
20
+ raise Offshore::CheckBackLater.new("Database in use")
21
+ end
22
+ end
23
+
24
+ def unlock
25
+ Logger.info(" Database.unlock")
26
+ Offshore::Mutex.unlock!(LOCK_KEY)
27
+ end
28
+
29
+ def reset
30
+ Logger.info(" Database.reset")
31
+ redis.del(LOCK_KEY)
32
+ redis.del(GENERATE_KEY)
33
+ redis.del(SHUTDOWN_KEY)
34
+ redis.del(SUITES_LIST_KEY)
35
+ end
36
+
37
+ def startup(background=true)
38
+ Logger.info(" Database.startup")
39
+ reset
40
+ create_schema(background)
41
+ end
42
+
43
+ def shutdown
44
+ Logger.info(" Database.shutdown")
45
+ redis.incr(SHUTDOWN_KEY, 1)
46
+ end
47
+
48
+ def init
49
+ # TODO: how to let these finish and also stall startup.
50
+ # should it be per test
51
+ # what about db reconnections?
52
+ Logger.info(" Database.init")
53
+ if redis.get(SHUTDOWN_KEY)
54
+ Logger.info(" .... databse shutting down. Exiting.")
55
+ raise "Database shutting down. No new connections, please."
56
+ else
57
+ create_schema(true)
58
+ end
59
+ end
60
+
61
+ def schema_snapshot
62
+ Logger.info(" Database.schema_snapshot")
63
+ snapshot.create
64
+ end
65
+
66
+ def rollback
67
+ Logger.info(" Database.rollback")
68
+ snapshot.rollback
69
+ end
70
+
71
+ private
72
+
73
+ def create_schema(background)
74
+ times = redis.incr(GENERATE_KEY) # get the incremented counter value
75
+ unless times == 1
76
+ Logger.info(" Database.create_schema: already created")
77
+ return
78
+ end
79
+
80
+ Logger.info(" Database.create_schema: creating....")
81
+ lock
82
+ if background
83
+ build_in_fork
84
+ else
85
+ build_in_process
86
+ end
87
+ end
88
+
89
+
90
+ def build_in_fork
91
+ Logger.info(" ..... building in fork")
92
+ otherProcess = fork { `bundle exec rake offshore:seed_schema --trace`; `bundle exec rake offshore:unlock --trace`; }
93
+ Process.detach(otherProcess)
94
+ end
95
+
96
+ def build_in_process
97
+ Logger.info(" ..... building in process")
98
+ #TODO: configurable, what rake job if not exist
99
+ %x(bundle exec rake offshore:seed_schema --trace)
100
+ unless $? == 0
101
+ reset
102
+ raise "rake offshore:seed_schema failed!"
103
+ end
104
+ unlock
105
+ end
106
+
107
+ def snapshot
108
+ Offshore::Snapshot::Template
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,5 @@
1
+ module Offshore
2
+ class CheckBackLater < StandardError
3
+
4
+ end
5
+ end
@@ -0,0 +1,22 @@
1
+ module Offshore
2
+ module Logger
3
+ extend self
4
+
5
+ def say(type, message)
6
+ message = "[Offshore][#{type}][#{Time.now.to_i}][#{Time.now}] #{message}"
7
+ if defined?(Rails)
8
+ Rails.logger.send(type, message)
9
+ else
10
+ puts message
11
+ end
12
+ end
13
+
14
+ def info(message)
15
+ say(:info, message)
16
+ end
17
+
18
+ def error(message)
19
+ say(:error, message)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,70 @@
1
+ module Offshore
2
+ class Middleware
3
+ def initialize(app)
4
+ @app = app
5
+ end
6
+
7
+ def init_server
8
+ Offshore::Database.init # has it's own singleton code
9
+ end
10
+
11
+ def init_thread
12
+ return if @init_thread
13
+ @init_thread = true
14
+
15
+ # TODO: move this to a config block
16
+ if defined?(Rails)
17
+ begin
18
+ require Rails.root.join("spec","spec_helper")
19
+ rescue
20
+ raise
21
+ end
22
+ end
23
+ end
24
+
25
+ def init
26
+ init_thread
27
+ init_server
28
+ end
29
+
30
+
31
+ def call(env)
32
+ if (env["PATH_INFO"] =~ /^\/offshore_tests\/(.*)/) == 0
33
+ offshore_call($1, env)
34
+ else
35
+ @app.call(env)
36
+ end
37
+ end
38
+
39
+ def offshore_call(method, env)
40
+ status = 500
41
+ headers = {}
42
+ hash = {"error" => "Unknown method: #{method}"}
43
+
44
+ Logger.info("Offshore Tests Action: #{method}")
45
+
46
+ begin
47
+ case method
48
+ when "factory_create", "suite_start", "suite_stop", "test_start", "test_stop"
49
+ init if method == "suite_start"
50
+ controller = Controller.new(Rack::Request.new(env).params)
51
+ status, hash = controller.send(method)
52
+ end
53
+ rescue CheckBackLater => e
54
+ hash = {"error" => e.message}
55
+ status = Offshore::WAIT_CODE
56
+ rescue StandardError => e
57
+ hash = {"error" => e.message}
58
+ status = 500
59
+ raise # for now
60
+ end
61
+
62
+ content = hash.to_json
63
+ headers['Content-Type'] = "application/json"
64
+ headers['Content-Length'] = content.length.to_s
65
+ out = [status, headers, [content]]
66
+ Logger.info("Offshore Tests #{method}... returns: #{out.to_s}")
67
+ out
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,154 @@
1
+ # Adapted from...
2
+ # https://github.com/kenn/redis-mutex/blob/master/lib/redis/mutex.rb
3
+ # https://github.com/kenn/redis-classy/blob/master/lib/redis/classy.rb
4
+
5
+ module Offshore
6
+ #
7
+ # Options
8
+ #
9
+ # :block => Specify in seconds how long you want to wait for the lock to be released. Speficy 0
10
+ # if you need non-blocking sematics and return false immediately. (default: 1)
11
+ # :sleep => Specify in seconds how long the polling interval should be when :block is given.
12
+ # It is recommended that you do NOT go below 0.01. (default: 0.1)
13
+ # :expire => Specify in seconds when the lock should forcibly be removed when something went wrong
14
+ # with the one who held the lock. (default: 10)
15
+ #
16
+ class Mutex
17
+
18
+ DEFAULT_EXPIRE = 10*60*1000 # 10 minutes (I think)
19
+ LockError = Class.new(StandardError)
20
+ UnlockError = Class.new(StandardError)
21
+ AssertionError = Class.new(StandardError)
22
+
23
+ attr_reader :key
24
+ def initialize(object, options={})
25
+ @key = (object.is_a?(String) || object.is_a?(Symbol) ? object.to_s : "#{object.class.name}:#{object.id}")
26
+ @block = options[:block] || 0 # defaults to not blocking (returns false)
27
+ @sleep = options[:sleep] || 0.1
28
+ @expire = options[:expire] || DEFAULT_EXPIRE
29
+ end
30
+
31
+ # get, del, etc
32
+ def method_missing(command, *args, &block)
33
+ self.class.send(command, @key, *args, &block)
34
+ end
35
+
36
+ def lock
37
+ self.class.raise_assertion_error if block_given?
38
+ @locking = false
39
+
40
+ if @block > 0
41
+ # Blocking mode
42
+ start_at = Time.now
43
+ while Time.now - start_at < @block
44
+ @locking = true and break if try_lock
45
+ sleep @sleep
46
+ end
47
+ else
48
+ # Non-blocking mode
49
+ @locking = try_lock
50
+ end
51
+ @locking
52
+ end
53
+
54
+ def try_lock
55
+ now = Time.now.to_f
56
+ @expires_at = now + @expire # Extend in each blocking loop
57
+ return true if setnx(@expires_at) # Success, the lock has been acquired
58
+ return false if get.to_f > now # Check if the lock is still effective
59
+
60
+ # The lock has expired but wasn't released... BAD!
61
+ return true if getset(@expires_at).to_f <= now # Success, we acquired the previously expired lock
62
+ return false # Dammit, it seems that someone else was even faster than us to remove the expired lock!
63
+ end
64
+
65
+ def unlock(force = false)
66
+ # Since it's possible that the operations in the critical section took a long time,
67
+ # we can't just simply release the lock. The unlock method checks if expires_at
68
+ # is the one we though, and do not release when the lock timestamp was overwritten.
69
+
70
+ # FIXME TODO: bug here if this wasn't the one to lock it, but I don't have @expires_at in different threads, etc
71
+ force = true
72
+ if force
73
+ # Redis#del with a single key returns '1' or nil
74
+ !!del
75
+ else
76
+ false
77
+ end
78
+ end
79
+
80
+ def with_lock
81
+ if lock!
82
+ begin
83
+ @result = yield
84
+ ensure
85
+ unlock
86
+ end
87
+ end
88
+ @result
89
+ end
90
+
91
+ def lock!
92
+ lock or raise LockError, "failed to acquire lock #{key.inspect}"
93
+ end
94
+
95
+ def unlock!(force = false)
96
+ unlock(force) or raise UnlockError, "failed to release lock #{key.inspect}"
97
+ end
98
+
99
+ class << self
100
+ def db
101
+ Offshore.redis
102
+ end
103
+
104
+ def method_missing(command, *args, &block)
105
+ db.send(command, *args, &block)
106
+ end
107
+
108
+ def sweep
109
+ return 0 if (all_keys = keys).empty?
110
+
111
+ now = Time.now.to_f
112
+ values = mget(*all_keys)
113
+
114
+ expired_keys = all_keys.zip(values).select do |key, time|
115
+ time && time.to_f <= now
116
+ end
117
+
118
+ expired_keys.each do |key, _|
119
+ # Make extra sure that anyone haven't extended the lock
120
+ del(key) if getset(key, now + DEFAULT_EXPIRE).to_f <= now
121
+ end
122
+
123
+ expired_keys.size
124
+ end
125
+
126
+ def lock(object, options = {})
127
+ raise_assertion_error if block_given?
128
+ new(object, options).lock
129
+ end
130
+
131
+ def unlock(object, options = {})
132
+ raise_assertion_error if block_given?
133
+ new(object, options).unlock
134
+ end
135
+
136
+ def lock!(object, options = {})
137
+ new(object, options).lock!
138
+ end
139
+
140
+ def unlock!(object, options = {})
141
+ raise_assertion_error if block_given?
142
+ new(object, options).unlock!
143
+ end
144
+
145
+ def with_lock(object, options = {}, &block)
146
+ new(object, options).with_lock(&block)
147
+ end
148
+
149
+ def raise_assertion_error
150
+ raise AssertionError, 'block syntax has been removed from #lock, use #with_lock instead'
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,5 @@
1
+ module Offshore
2
+ class Railtie < Rails::Railtie
3
+ config.app_middleware.use 'Offshore::Middleware'
4
+ end
5
+ end
@@ -0,0 +1,45 @@
1
+ module Offshore
2
+ extend self
3
+
4
+ # Accepts:
5
+ # 1. A 'hostname:port' String
6
+ # 2. A 'hostname:port:db' String (to select the Redis db)
7
+ # 3. A 'hostname:port/namespace' String (to set the Redis namespace)
8
+ # 4. A Redis URL String 'redis://host:port'
9
+ # 5. An instance of `Redis`, `Redis::Client`, `Redis::DistRedis`,
10
+ # or `Redis::Namespace`.
11
+ def redis=(server)
12
+ case server
13
+ when String
14
+ if server =~ /redis\:\/\//
15
+ redis = Redis.connect(:url => server, :thread_safe => true)
16
+ else
17
+ server, namespace = server.split('/', 2)
18
+ host, port, db = server.split(':')
19
+ redis = Redis.new(:host => host, :port => port,
20
+ :thread_safe => true, :db => db)
21
+ end
22
+ namespace ||= default_namespace
23
+
24
+ @redis = Redis::Namespace.new(namespace, :redis => redis)
25
+ when Redis::Namespace
26
+ @redis = server
27
+ else
28
+ @redis = Redis::Namespace.new(default_namespace, :redis => server)
29
+ end
30
+ end
31
+
32
+ # Returns the current Redis connection. If none has been created, will
33
+ # create a new one from the Reqsue one (with a different namespace)
34
+ def redis
35
+ return @redis if @redis
36
+ self.redis = Redis.respond_to?(:connect) ? Redis.connect : "localhost:6379"
37
+ self.redis
38
+ end
39
+
40
+ private
41
+
42
+ def default_namespace
43
+ :offshore
44
+ end
45
+ end
@@ -0,0 +1,175 @@
1
+ require 'erb'
2
+ require 'digest/md5'
3
+ require 'mysql2'
4
+
5
+ module Offshore
6
+ module Snapshot
7
+ extend self
8
+
9
+ def config
10
+ return @config if @config
11
+
12
+ raise "Only supported in Rails for now" unless defined?(Rails)
13
+
14
+ yml = Rails.root.join("config", "database.yml")
15
+ hash = YAML.load(ERB.new(File.read(yml)).result)["test"]
16
+
17
+ @config = {}
18
+ ['username', 'password', 'host', 'port', 'database', 'collation', 'charset'].each do |key|
19
+ if hash['master'] && hash['master'].has_key?(key)
20
+ @config[key] = hash['master'][key]
21
+ else
22
+ @config[key] = hash[key]
23
+ end
24
+ end
25
+
26
+ @config
27
+ end
28
+ end
29
+ end
30
+
31
+ module Offshore
32
+ module Snapshot
33
+ module Template
34
+ extend self
35
+
36
+ SNAPSHOT_KEY = "snapshot:checksum"
37
+
38
+ def create
39
+ use_db(test_db)
40
+ use_db(template_db)
41
+ clone(test_db, template_db)
42
+ record_checksum(test_db)
43
+ end
44
+
45
+ def rollback
46
+ use_db(test_db)
47
+ stored = Offshore.redis.get(SNAPSHOT_KEY)
48
+ sum = db_checksum(test_db)
49
+
50
+ if stored == sum
51
+ Logger.info(" Snapshot checksum equal - not cloning")
52
+ else
53
+ Logger.info(" Snapshot checksum not equal - cloning")
54
+ use_db(template_db)
55
+ clone(template_db, test_db)
56
+ record_checksum(test_db) unless stored
57
+ end
58
+ end
59
+
60
+
61
+ def rollback2
62
+ use_db(test_db)
63
+ use_db(template_db)
64
+ if equal_checksum?(template_db, test_db)
65
+ Logger.info(" Snapshot checksum equal - not cloning")
66
+ else
67
+ Logger.info(" Snapshot checksum not equal - cloning")
68
+ clone(template_db, test_db)
69
+ end
70
+ end
71
+
72
+ private
73
+
74
+ def record_checksum(db)
75
+ sum = db_checksum(db)
76
+ Logger.info(" Snapshot checksum: #{sum}")
77
+ Offshore.redis.set(SNAPSHOT_KEY, sum)
78
+ end
79
+
80
+ def equal_checksum?(from, to)
81
+ tem = db_checksum(from)
82
+ sum = db_checksum(to)
83
+ Logger.info(" Snapshot checksum (calculated) compare: #{sum} == #{tem}")
84
+ tem == sum
85
+ end
86
+
87
+ def test_db
88
+ @test_db ||= make_conn
89
+ end
90
+
91
+ def template_db
92
+ @template_db ||= make_conn("_offshore_template")
93
+ end
94
+
95
+ def clone(from, to)
96
+ empty_tables(to)
97
+ copy_tables(from, to)
98
+ copy_data(from, to)
99
+ end
100
+
101
+ def db_checksum(db)
102
+ string = ""
103
+ name = current_db(db)
104
+ get_tables(db).each do |table|
105
+ row = db.query("CHECKSUM TABLE `#{name}`.`#{table}`")
106
+ string << ":"
107
+ string << row.first["Checksum"].to_s
108
+ end
109
+
110
+ Digest::MD5.hexdigest(string)
111
+ end
112
+
113
+ def make_conn(append="")
114
+ ar = Offshore::Snapshot.config
115
+ config = {}
116
+ config['reconnect'] = true
117
+ config['flags'] = Mysql2::Client::MULTI_STATEMENTS
118
+
119
+ ['username', 'password', 'host', 'port'].each do |key|
120
+ config[key.to_sym] = ar[key]
121
+ end
122
+
123
+ client = Mysql2::Client.new(config)
124
+ client.instance_variable_set("@offshore_db_name", "#{ar['database']}#{append}")
125
+ client
126
+ end
127
+
128
+ def use_db(db)
129
+ original_db = current_db(db)
130
+ begin
131
+ db.query("create database `#{original_db}` DEFAULT CHARACTER SET `utf8`") # TODO forcing UTF8
132
+ rescue
133
+ end
134
+ db.query("use `#{original_db}`")
135
+ end
136
+
137
+ def empty_tables(db)
138
+ name = current_db(db)
139
+ get_tables(db).each do |table|
140
+ db.query("drop table if exists `#{name}`.`#{table}`")
141
+ end
142
+ end
143
+
144
+ def copy_tables(from, to)
145
+ from_db = current_db(from)
146
+ to_db = current_db(to)
147
+ get_tables(from).each do |table|
148
+ to.query("create table `#{to_db}`.`#{table}` like `#{from_db}`.`#{table}`")
149
+ end
150
+ end
151
+
152
+ def copy_data(from, to)
153
+ from_db = current_db(from)
154
+ to_db = current_db(to)
155
+ get_tables(from).each do |table|
156
+ to.query("insert into `#{to_db}`.`#{table}` select * from `#{from_db}`.`#{table}`")
157
+ end
158
+ end
159
+
160
+ def current_db(db)
161
+ db.instance_variable_get("@offshore_db_name")
162
+ # db.query("select database()").first.values[0]
163
+ end
164
+
165
+ def get_tables(db)
166
+ table_array = []
167
+ db.query("show tables").each do |row|
168
+ table_array << row.values[0]
169
+ end
170
+ table_array
171
+ end
172
+
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,10 @@
1
+ require "redis"
2
+ require "offshore/server/errors"
3
+ require "offshore/server/logger"
4
+ require "offshore/server/redis"
5
+ require "offshore/server/mutex"
6
+ require "offshore/server/snapshot"
7
+ require "offshore/server/database"
8
+ require "offshore/server/controller"
9
+ require "offshore/server/middleware"
10
+ require "offshore/server/railtie" if defined?(Rails)
@@ -0,0 +1,35 @@
1
+ namespace :offshore do
2
+ task :setup
3
+ task :seed
4
+
5
+ task :preload do
6
+ require "offshore"
7
+ end
8
+
9
+ task :schema_snapshot => [:preload, :setup] do
10
+ Offshore::Database.schema_snapshot
11
+ end
12
+
13
+ task :schema_rollback => [:preload, :setup] do
14
+ Offshore::Database.snapshoter.rollback
15
+ end
16
+
17
+ task :unlock => [:preload, :setup] do
18
+ Offshore::Database.unlock
19
+ end
20
+
21
+ task :shutdown => [:preload, :setup] do
22
+ Offshore::Database.shutdown
23
+ end
24
+
25
+ task :startup => [:preload, :setup] do
26
+ Offshore::Database.startup(false)
27
+ end
28
+
29
+ task :reset => [:preload, :setup] do
30
+ Offshore::Database.reset
31
+ end
32
+
33
+ task :seed_schema => [ :preload, :setup, :seed, :schema_snapshot ]
34
+
35
+ end
@@ -0,0 +1,3 @@
1
+ module Offshore
2
+ VERSION = "0.0.1"
3
+ end
data/lib/offshore.rb ADDED
@@ -0,0 +1,29 @@
1
+ require "offshore/version"
2
+
3
+ module Offshore
4
+ extend self
5
+
6
+ WAIT_CODE = 499
7
+
8
+ def suite
9
+ @suite ||= Suite.new
10
+ end
11
+
12
+ def test
13
+ if @test && @test.run?
14
+ # run again because it crashed in execution
15
+ @test.failed
16
+ end
17
+ @test ||= Test.new
18
+ end
19
+
20
+ protected
21
+
22
+ def internal_test_ended
23
+ # called from test.failed and test.stop
24
+ @test = nil
25
+ end
26
+ end
27
+
28
+ require "offshore/client"
29
+ require "offshore/server"
data/offshore.gemspec ADDED
@@ -0,0 +1,23 @@
1
+ # -*- encoding: utf-8 -*-
2
+ require File.expand_path('../lib/offshore/version', __FILE__)
3
+
4
+ Gem::Specification.new do |gem|
5
+ gem.authors = ["Brian Leonard"]
6
+ gem.email = ["brian@bleonard.com"]
7
+ gem.description = %q{For handling remote factories and tests}
8
+ gem.summary = %q{For handling remote factories and tests}
9
+ gem.homepage = ""
10
+
11
+ gem.files = `git ls-files`.split($\)
12
+ gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
13
+ gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
14
+ gem.name = "offshore"
15
+ gem.require_paths = ["lib"]
16
+ gem.version = Offshore::VERSION
17
+
18
+ gem.add_dependency "multi_json"
19
+ gem.add_dependency "faraday"
20
+ gem.add_dependency "redis"
21
+ gem.add_dependency "redis-namespace"
22
+ gem.add_dependency "mysql2"
23
+ end
metadata ADDED
@@ -0,0 +1,150 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: offshore
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Brian Leonard
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2012-12-14 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: multi_json
16
+ requirement: !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ! '>='
20
+ - !ruby/object:Gem::Version
21
+ version: '0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ none: false
26
+ requirements:
27
+ - - ! '>='
28
+ - !ruby/object:Gem::Version
29
+ version: '0'
30
+ - !ruby/object:Gem::Dependency
31
+ name: faraday
32
+ requirement: !ruby/object:Gem::Requirement
33
+ none: false
34
+ requirements:
35
+ - - ! '>='
36
+ - !ruby/object:Gem::Version
37
+ version: '0'
38
+ type: :runtime
39
+ prerelease: false
40
+ version_requirements: !ruby/object:Gem::Requirement
41
+ none: false
42
+ requirements:
43
+ - - ! '>='
44
+ - !ruby/object:Gem::Version
45
+ version: '0'
46
+ - !ruby/object:Gem::Dependency
47
+ name: redis
48
+ requirement: !ruby/object:Gem::Requirement
49
+ none: false
50
+ requirements:
51
+ - - ! '>='
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ type: :runtime
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ none: false
58
+ requirements:
59
+ - - ! '>='
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ - !ruby/object:Gem::Dependency
63
+ name: redis-namespace
64
+ requirement: !ruby/object:Gem::Requirement
65
+ none: false
66
+ requirements:
67
+ - - ! '>='
68
+ - !ruby/object:Gem::Version
69
+ version: '0'
70
+ type: :runtime
71
+ prerelease: false
72
+ version_requirements: !ruby/object:Gem::Requirement
73
+ none: false
74
+ requirements:
75
+ - - ! '>='
76
+ - !ruby/object:Gem::Version
77
+ version: '0'
78
+ - !ruby/object:Gem::Dependency
79
+ name: mysql2
80
+ requirement: !ruby/object:Gem::Requirement
81
+ none: false
82
+ requirements:
83
+ - - ! '>='
84
+ - !ruby/object:Gem::Version
85
+ version: '0'
86
+ type: :runtime
87
+ prerelease: false
88
+ version_requirements: !ruby/object:Gem::Requirement
89
+ none: false
90
+ requirements:
91
+ - - ! '>='
92
+ - !ruby/object:Gem::Version
93
+ version: '0'
94
+ description: For handling remote factories and tests
95
+ email:
96
+ - brian@bleonard.com
97
+ executables: []
98
+ extensions: []
99
+ extra_rdoc_files: []
100
+ files:
101
+ - .gitignore
102
+ - .rvmrc
103
+ - Gemfile
104
+ - LICENSE
105
+ - README.md
106
+ - Rakefile
107
+ - lib/offshore.rb
108
+ - lib/offshore/client.rb
109
+ - lib/offshore/client/factory_offshore.rb
110
+ - lib/offshore/client/host.rb
111
+ - lib/offshore/client/suite.rb
112
+ - lib/offshore/client/test.rb
113
+ - lib/offshore/server.rb
114
+ - lib/offshore/server/controller.rb
115
+ - lib/offshore/server/database.rb
116
+ - lib/offshore/server/errors.rb
117
+ - lib/offshore/server/logger.rb
118
+ - lib/offshore/server/middleware.rb
119
+ - lib/offshore/server/mutex.rb
120
+ - lib/offshore/server/railtie.rb
121
+ - lib/offshore/server/redis.rb
122
+ - lib/offshore/server/snapshot.rb
123
+ - lib/offshore/tasks.rb
124
+ - lib/offshore/version.rb
125
+ - offshore.gemspec
126
+ homepage: ''
127
+ licenses: []
128
+ post_install_message:
129
+ rdoc_options: []
130
+ require_paths:
131
+ - lib
132
+ required_ruby_version: !ruby/object:Gem::Requirement
133
+ none: false
134
+ requirements:
135
+ - - ! '>='
136
+ - !ruby/object:Gem::Version
137
+ version: '0'
138
+ required_rubygems_version: !ruby/object:Gem::Requirement
139
+ none: false
140
+ requirements:
141
+ - - ! '>='
142
+ - !ruby/object:Gem::Version
143
+ version: '0'
144
+ requirements: []
145
+ rubyforge_project:
146
+ rubygems_version: 1.8.24
147
+ signing_key:
148
+ specification_version: 3
149
+ summary: For handling remote factories and tests
150
+ test_files: []