innkeeper 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|