innkeeper 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +15 -0
- data/.pryrc +3 -0
- data/.travis.yml +15 -0
- data/Appraisals +20 -0
- data/Gemfile +10 -0
- data/Guardfile +24 -0
- data/HISTORY.md +337 -0
- data/README.md +485 -0
- data/Rakefile +92 -0
- data/TODO.md +51 -0
- data/gemfiles/rails_5_1.gemfile +12 -0
- data/innkeeper.gemspec +42 -0
- data/lib/generators/innkeeper/install/USAGE +5 -0
- data/lib/generators/innkeeper/install/install_generator.rb +10 -0
- data/lib/generators/innkeeper/install/templates/innkeeper.rb +76 -0
- data/lib/innkeeper.rb +110 -0
- data/lib/innkeeper/adapters/abstract_adapter.rb +172 -0
- data/lib/innkeeper/adapters/mysql2_adapter.rb +47 -0
- data/lib/innkeeper/adapters/postgresql_adapter.rb +112 -0
- data/lib/innkeeper/console.rb +12 -0
- data/lib/innkeeper/deprecation.rb +10 -0
- data/lib/innkeeper/elevators/domain.rb +20 -0
- data/lib/innkeeper/elevators/generic.rb +32 -0
- data/lib/innkeeper/elevators/host_hash.rb +22 -0
- data/lib/innkeeper/elevators/subdomain.rb +62 -0
- data/lib/innkeeper/migrator.rb +33 -0
- data/lib/innkeeper/railtie.rb +56 -0
- data/lib/innkeeper/resolvers/abstract.rb +15 -0
- data/lib/innkeeper/resolvers/database.rb +11 -0
- data/lib/innkeeper/resolvers/schema.rb +14 -0
- data/lib/innkeeper/tasks/enhancements.rb +36 -0
- data/lib/innkeeper/tenant.rb +47 -0
- data/lib/innkeeper/version.rb +3 -0
- data/lib/tasks/innkeeper.rake +128 -0
- data/notes.md +31 -0
- data/test/config_test.rb +52 -0
- data/test/databases.yml.sample +37 -0
- data/test/decorator_test.rb +21 -0
- data/test/domain_elevator_test.rb +38 -0
- data/test/dummy/Rakefile +7 -0
- data/test/dummy/app/controllers/application_controller.rb +6 -0
- data/test/dummy/app/helpers/application_helper.rb +2 -0
- data/test/dummy/app/models/company.rb +3 -0
- data/test/dummy/app/models/user.rb +3 -0
- data/test/dummy/app/views/application/index.html.erb +1 -0
- data/test/dummy/app/views/layouts/application.html.erb +14 -0
- data/test/dummy/config.ru +4 -0
- data/test/dummy/config/application.rb +49 -0
- data/test/dummy/config/boot.rb +11 -0
- data/test/dummy/config/database.yml.sample +38 -0
- data/test/dummy/config/environment.rb +5 -0
- data/test/dummy/config/environments/development.rb +27 -0
- data/test/dummy/config/environments/production.rb +51 -0
- data/test/dummy/config/environments/test.rb +34 -0
- data/test/dummy/config/initializers/backtrace_silencers.rb +7 -0
- data/test/dummy/config/initializers/inflections.rb +10 -0
- data/test/dummy/config/initializers/innkeeper.rb +4 -0
- data/test/dummy/config/initializers/mime_types.rb +5 -0
- data/test/dummy/config/initializers/secret_token.rb +7 -0
- data/test/dummy/config/initializers/session_store.rb +8 -0
- data/test/dummy/config/locales/en.yml +5 -0
- data/test/dummy/config/routes.rb +3 -0
- data/test/dummy/db/schema.rb +19 -0
- data/test/dummy/db/seeds.rb +5 -0
- data/test/dummy/db/seeds/import.rb +5 -0
- data/test/dummy/public/404.html +26 -0
- data/test/dummy/public/422.html +26 -0
- data/test/dummy/public/500.html +26 -0
- data/test/dummy/public/favicon.ico +0 -0
- data/test/dummy/public/stylesheets/.gitkeep +0 -0
- data/test/dummy/script/rails +6 -0
- data/test/excluded_models_test.rb +32 -0
- data/test/generic_elevator_test.rb +63 -0
- data/test/host_hash_elevator_test.rb +42 -0
- data/test/innkeeper_test.rb +96 -0
- data/test/mocks/adapter_mock.rb +11 -0
- data/test/multithreading_test.rb +37 -0
- data/test/mysql2_adapter_test.rb +17 -0
- data/test/postgresql_adapter_test.rb +39 -0
- data/test/railtie_test.rb +31 -0
- data/test/rake_task_test.rb +57 -0
- data/test/resolver_test.rb +21 -0
- data/test/shared/shared_adapter_tests.rb +95 -0
- data/test/subdomain_elevator_test.rb +75 -0
- data/test/test_helper.rb +24 -0
- metadata +325 -0
@@ -0,0 +1,47 @@
|
|
1
|
+
require 'innkeeper/adapters/abstract_adapter'
|
2
|
+
require 'digest'
|
3
|
+
|
4
|
+
module Innkeeper
|
5
|
+
module Adapters
|
6
|
+
class Mysql2Adapter < AbstractAdapter
|
7
|
+
def switch_tenant(config)
|
8
|
+
difference = current_difference_from(config)
|
9
|
+
|
10
|
+
if difference[:host]
|
11
|
+
connection_switch!(config)
|
12
|
+
else
|
13
|
+
simple_switch(config) if difference[:database]
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def create_tenant!(config)
|
18
|
+
Innkeeper.connection.create_database(config[:database], config)
|
19
|
+
end
|
20
|
+
|
21
|
+
def simple_switch(config)
|
22
|
+
Innkeeper.connection.execute("use `#{config[:database]}`")
|
23
|
+
rescue ActiveRecord::StatementInvalid => exception
|
24
|
+
raise_connect_error!(config[:database], exception)
|
25
|
+
end
|
26
|
+
|
27
|
+
def connection_specification_name(config)
|
28
|
+
if Innkeeper.pool_per_config
|
29
|
+
"_innkeeper_#{config.hash}".to_sym
|
30
|
+
else
|
31
|
+
host_hash = Digest::MD5.hexdigest(config[:host] || config[:url] || "127.0.0.1")
|
32
|
+
"_innkeeper_#{host_hash}_#{config[:adapter]}".to_sym
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
37
|
+
def database_exists?(database)
|
38
|
+
result = Innkeeper.connection.exec_query(<<-SQL).try(:first)
|
39
|
+
SELECT 1 AS `exists`
|
40
|
+
FROM INFORMATION_SCHEMA.SCHEMATA
|
41
|
+
WHERE SCHEMA_NAME = #{Innkeeper.connection.quote(database)}
|
42
|
+
SQL
|
43
|
+
result.present? && result['exists'] == 1
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,112 @@
|
|
1
|
+
require 'innkeeper/adapters/abstract_adapter'
|
2
|
+
require 'digest'
|
3
|
+
|
4
|
+
module Innkeeper
|
5
|
+
module Adapters
|
6
|
+
class PostgresqlAdapter < AbstractAdapter
|
7
|
+
# -- ABSTRACT OVERRIDES --
|
8
|
+
def drop(tenant)
|
9
|
+
raise NotImplementedError,
|
10
|
+
"Please use either drop_database or drop_schema for PG adapter"
|
11
|
+
end
|
12
|
+
# -- END ABSTRACT OVERRIDES --
|
13
|
+
|
14
|
+
def drop_database(tenant)
|
15
|
+
# Innkeeper.connection.select_all "select pg_terminate_backend(pg_stat_activity.pid) from pg_stat_activity where datname='#{tenant}' AND state='idle';"
|
16
|
+
self.class.superclass.instance_method(:drop).bind(self).call(tenant)
|
17
|
+
end
|
18
|
+
|
19
|
+
def drop_schema(tenant)
|
20
|
+
previous_tenant = @current
|
21
|
+
|
22
|
+
config = config_for(tenant)
|
23
|
+
difference = current_difference_from(config)
|
24
|
+
|
25
|
+
if difference[:host] || difference[:database]
|
26
|
+
connection_switch!(config)
|
27
|
+
end
|
28
|
+
|
29
|
+
schema = first_schema(config[:schema_search_path]) if config[:schema_search_path]
|
30
|
+
|
31
|
+
Innkeeper.connection.execute(%{DROP SCHEMA "#{schema}" CASCADE}) if schema
|
32
|
+
|
33
|
+
@current = tenant
|
34
|
+
rescue ActiveRecord::StatementInvalid => exception
|
35
|
+
raise TenantNotFound, "Error while dropping schema #{schema} for tenant #{tenant}: #{exception.message}"
|
36
|
+
ensure
|
37
|
+
switch!(previous_tenant) rescue reset
|
38
|
+
end
|
39
|
+
|
40
|
+
def switch_tenant(config)
|
41
|
+
current_config = config_for(@current)
|
42
|
+
difference = config.select{ |k, v| current_config[k] != v }
|
43
|
+
|
44
|
+
# PG doesn't have the ability to switch DB without reconnecting
|
45
|
+
if difference[:host] || difference[:database]
|
46
|
+
connection_switch!(config)
|
47
|
+
else
|
48
|
+
simple_switch(config) if difference[:schema_search_path]
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def simple_switch(config)
|
53
|
+
return unless config[:schema_search_path]
|
54
|
+
|
55
|
+
tenant = first_schema(config[:schema_search_path])
|
56
|
+
|
57
|
+
unless Innkeeper.connection.schema_exists?(tenant)
|
58
|
+
raise Innkeeper::TenantNotFound, "Could not find schema #{tenant}"
|
59
|
+
end
|
60
|
+
|
61
|
+
Innkeeper.connection.schema_search_path = config[:schema_search_path]
|
62
|
+
end
|
63
|
+
|
64
|
+
def create_tenant!(config)
|
65
|
+
unless database_exists?(config[:database])
|
66
|
+
Innkeeper.connection.create_database(config[:database], config)
|
67
|
+
connection_switch!(config, without_keys: [:schema_search_path])
|
68
|
+
end
|
69
|
+
|
70
|
+
schema = first_schema(config[:schema_search_path]) if config[:schema_search_path]
|
71
|
+
|
72
|
+
if schema && !schema_exists?(schema)
|
73
|
+
Innkeeper.connection.execute(%{CREATE SCHEMA "#{schema}"})
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
def connection_specification_name(config)
|
78
|
+
if Innkeeper.pool_per_config
|
79
|
+
"_innkeeper_#{config.hash}".to_sym
|
80
|
+
else
|
81
|
+
host_hash = Digest::MD5.hexdigest(config[:host] || config[:url] || "127.0.0.1")
|
82
|
+
"_innkeeper_#{host_hash}_#{config[:adapter]}_#{config[:database]}".to_sym
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
private
|
87
|
+
def database_exists?(database)
|
88
|
+
result = Innkeeper.connection.exec_query(<<-SQL).try(:first)
|
89
|
+
SELECT EXISTS(
|
90
|
+
SELECT 1
|
91
|
+
FROM pg_catalog.pg_database
|
92
|
+
WHERE datname = #{Innkeeper.connection.quote(database)}
|
93
|
+
)
|
94
|
+
SQL
|
95
|
+
|
96
|
+
result.present? && result['exists']
|
97
|
+
end
|
98
|
+
|
99
|
+
def schema_exists?(schema)
|
100
|
+
Innkeeper.connection.schema_exists?(schema)
|
101
|
+
end
|
102
|
+
|
103
|
+
def first_schema(search_path)
|
104
|
+
strip_quotes(search_path.split(",").first)
|
105
|
+
end
|
106
|
+
|
107
|
+
def strip_quotes(string)
|
108
|
+
string[0] == '"' ? string[1..-2] : string
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
# A workaraound to get `reload!` to also call Innkeeper::Tenant.init
|
2
|
+
# This is unfortunate, but I haven't figured out how to hook into the reload process *after* files are reloaded
|
3
|
+
|
4
|
+
# reloads the environment
|
5
|
+
def reload!(print=true)
|
6
|
+
puts "Reloading..." if print
|
7
|
+
# This triggers the to_prepare callbacks
|
8
|
+
ActionDispatch::Callbacks.new(Proc.new {}).call({})
|
9
|
+
# Manually init Innkeeper again once classes are reloaded
|
10
|
+
Innkeeper::Tenant.init
|
11
|
+
true
|
12
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
require 'innkeeper/elevators/generic'
|
2
|
+
|
3
|
+
module Innkeeper
|
4
|
+
module Elevators
|
5
|
+
# Provides a rack based tenant switching solution based on domain
|
6
|
+
# Assumes that tenant name should match domain
|
7
|
+
# Parses request host for second level domain
|
8
|
+
# eg. example.com => example
|
9
|
+
# www.example.bc.ca => example
|
10
|
+
#
|
11
|
+
class Domain < Generic
|
12
|
+
|
13
|
+
def parse_tenant_name(request)
|
14
|
+
return nil if request.host.blank?
|
15
|
+
|
16
|
+
request.host.match(/(www\.)?(?<sld>[^.]*)/)["sld"]
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
require 'rack/request'
|
2
|
+
require 'innkeeper/tenant'
|
3
|
+
|
4
|
+
module Innkeeper
|
5
|
+
module Elevators
|
6
|
+
# Provides a rack based tenant switching solution based on request
|
7
|
+
#
|
8
|
+
class Generic
|
9
|
+
|
10
|
+
def initialize(app, processor = nil)
|
11
|
+
@app = app
|
12
|
+
@processor = processor || method(:parse_tenant_name)
|
13
|
+
end
|
14
|
+
|
15
|
+
def call(env)
|
16
|
+
request = Rack::Request.new(env)
|
17
|
+
|
18
|
+
database = @processor.call(request)
|
19
|
+
|
20
|
+
if database
|
21
|
+
Innkeeper::Tenant.switch(database) { @app.call(env) }
|
22
|
+
else
|
23
|
+
@app.call(env)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def parse_tenant_name(request)
|
28
|
+
raise "Override"
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
require 'innkeeper/elevators/generic'
|
2
|
+
|
3
|
+
module Innkeeper
|
4
|
+
module Elevators
|
5
|
+
# Provides a rack based tenant switching solution based on hosts
|
6
|
+
# Uses a hash to find the corresponding tenant name for the host
|
7
|
+
#
|
8
|
+
class HostHash < Generic
|
9
|
+
def initialize(app, hash = {}, processor = nil)
|
10
|
+
super app, processor
|
11
|
+
@hash = hash
|
12
|
+
end
|
13
|
+
|
14
|
+
def parse_tenant_name(request)
|
15
|
+
raise TenantNotFound,
|
16
|
+
"Cannot find tenant for host #{request.host}" unless @hash.has_key?(request.host)
|
17
|
+
|
18
|
+
@hash[request.host]
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
require 'innkeeper/elevators/generic'
|
2
|
+
require 'public_suffix'
|
3
|
+
|
4
|
+
module Innkeeper
|
5
|
+
module Elevators
|
6
|
+
# Provides a rack based tenant switching solution based on subdomains
|
7
|
+
# Assumes that tenant name should match subdomain
|
8
|
+
#
|
9
|
+
class Subdomain < Generic
|
10
|
+
def self.excluded_subdomains
|
11
|
+
@excluded_subdomains ||= []
|
12
|
+
end
|
13
|
+
|
14
|
+
def self.excluded_subdomains=(arg)
|
15
|
+
@excluded_subdomains = arg
|
16
|
+
end
|
17
|
+
|
18
|
+
def parse_tenant_name(request)
|
19
|
+
request_subdomain = subdomain(request.host)
|
20
|
+
|
21
|
+
# If the domain acquired is set to be excluded, set the tenant to whatever is currently
|
22
|
+
# next in line in the schema search path.
|
23
|
+
tenant = if self.class.excluded_subdomains.include?(request_subdomain)
|
24
|
+
nil
|
25
|
+
else
|
26
|
+
request_subdomain
|
27
|
+
end
|
28
|
+
|
29
|
+
tenant.presence
|
30
|
+
end
|
31
|
+
|
32
|
+
protected
|
33
|
+
|
34
|
+
# *Almost* a direct ripoff of ActionDispatch::Request subdomain methods
|
35
|
+
|
36
|
+
# Only care about the first subdomain for the database name
|
37
|
+
def subdomain(host)
|
38
|
+
subdomains(host).first
|
39
|
+
end
|
40
|
+
|
41
|
+
def subdomains(host)
|
42
|
+
host_valid?(host) ? parse_host(host) : []
|
43
|
+
end
|
44
|
+
|
45
|
+
def host_valid?(host)
|
46
|
+
!ip_host?(host) && domain_valid?(host)
|
47
|
+
end
|
48
|
+
|
49
|
+
def ip_host?(host)
|
50
|
+
!/^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$/.match(host).nil?
|
51
|
+
end
|
52
|
+
|
53
|
+
def domain_valid?(host)
|
54
|
+
PublicSuffix.valid?(host, ignore_private: true)
|
55
|
+
end
|
56
|
+
|
57
|
+
def parse_host(host)
|
58
|
+
(PublicSuffix.parse(host).trd || '').split('.')
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
require 'innkeeper/tenant'
|
2
|
+
|
3
|
+
module Innkeeper
|
4
|
+
module Migrator
|
5
|
+
|
6
|
+
extend self
|
7
|
+
|
8
|
+
# Migrate to latest
|
9
|
+
def migrate(database)
|
10
|
+
Tenant.switch(database) do
|
11
|
+
version = ENV["VERSION"] ? ENV["VERSION"].to_i : nil
|
12
|
+
|
13
|
+
ActiveRecord::Migrator.migrate(ActiveRecord::Migrator.migrations_paths, version) do |migration|
|
14
|
+
ENV["SCOPE"].blank? || (ENV["SCOPE"] == migration.scope)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
# Migrate up/down to a specific version
|
20
|
+
def run(direction, database, version)
|
21
|
+
Tenant.switch(database) do
|
22
|
+
ActiveRecord::Migrator.run(direction, ActiveRecord::Migrator.migrations_paths, version)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
# rollback latest migration `step` number of times
|
27
|
+
def rollback(database, step = 1)
|
28
|
+
Tenant.switch(database) do
|
29
|
+
ActiveRecord::Migrator.rollback(ActiveRecord::Migrator.migrations_paths, step)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
require 'rails'
|
2
|
+
require 'innkeeper/tenant'
|
3
|
+
require 'innkeeper/resolvers/database'
|
4
|
+
|
5
|
+
module Innkeeper
|
6
|
+
class Railtie < Rails::Railtie
|
7
|
+
|
8
|
+
def self.prep
|
9
|
+
Innkeeper.configure do |config|
|
10
|
+
config.excluded_models = []
|
11
|
+
config.force_reconnect_on_switch = false
|
12
|
+
config.tenant_names = []
|
13
|
+
config.seed_after_create = false
|
14
|
+
config.tenant_resolver = Innkeeper::Resolvers::Database
|
15
|
+
end
|
16
|
+
|
17
|
+
ActiveRecord::Migrator.migrations_paths = Rails.application.paths['db/migrate'].to_a
|
18
|
+
end
|
19
|
+
|
20
|
+
#
|
21
|
+
# Set up our default config options
|
22
|
+
# Do this before the app initializers run so we don't override custom settings
|
23
|
+
#
|
24
|
+
config.before_initialize{ prep }
|
25
|
+
|
26
|
+
# Hook into ActionDispatch::Reloader to ensure Innkeeper is properly initialized
|
27
|
+
# Note that this doens't entirely work as expected in Development, because this is called before classes are reloaded
|
28
|
+
# See the middleware/console declarations below to help with this. Hope to fix that soon.
|
29
|
+
#
|
30
|
+
config.to_prepare do
|
31
|
+
unless ARGV.any? { |arg| arg =~ /\Aassets:(?:precompile|clean)\z/ }
|
32
|
+
Innkeeper::Tenant.init
|
33
|
+
Innkeeper.connection_class.clear_active_connections!
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
#
|
38
|
+
# Ensure rake tasks are loaded
|
39
|
+
#
|
40
|
+
rake_tasks do
|
41
|
+
load 'tasks/innkeeper.rake'
|
42
|
+
require 'innkeeper/tasks/enhancements' if Innkeeper.db_migrate_tenants
|
43
|
+
end
|
44
|
+
|
45
|
+
#
|
46
|
+
# The following initializers are a workaround to the fact that I can't properly hook into the rails reloader
|
47
|
+
# Note this is technically valid for any environment where cache_classes is false, for us, it's just development
|
48
|
+
#
|
49
|
+
if Rails.env.development?
|
50
|
+
# Overrides reload! to also call Innkeeper::Tenant.init as well so that the reloaded classes have the proper table_names
|
51
|
+
console do
|
52
|
+
require 'innkeeper/console'
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|