innkeeper 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (87) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +15 -0
  3. data/.pryrc +3 -0
  4. data/.travis.yml +15 -0
  5. data/Appraisals +20 -0
  6. data/Gemfile +10 -0
  7. data/Guardfile +24 -0
  8. data/HISTORY.md +337 -0
  9. data/README.md +485 -0
  10. data/Rakefile +92 -0
  11. data/TODO.md +51 -0
  12. data/gemfiles/rails_5_1.gemfile +12 -0
  13. data/innkeeper.gemspec +42 -0
  14. data/lib/generators/innkeeper/install/USAGE +5 -0
  15. data/lib/generators/innkeeper/install/install_generator.rb +10 -0
  16. data/lib/generators/innkeeper/install/templates/innkeeper.rb +76 -0
  17. data/lib/innkeeper.rb +110 -0
  18. data/lib/innkeeper/adapters/abstract_adapter.rb +172 -0
  19. data/lib/innkeeper/adapters/mysql2_adapter.rb +47 -0
  20. data/lib/innkeeper/adapters/postgresql_adapter.rb +112 -0
  21. data/lib/innkeeper/console.rb +12 -0
  22. data/lib/innkeeper/deprecation.rb +10 -0
  23. data/lib/innkeeper/elevators/domain.rb +20 -0
  24. data/lib/innkeeper/elevators/generic.rb +32 -0
  25. data/lib/innkeeper/elevators/host_hash.rb +22 -0
  26. data/lib/innkeeper/elevators/subdomain.rb +62 -0
  27. data/lib/innkeeper/migrator.rb +33 -0
  28. data/lib/innkeeper/railtie.rb +56 -0
  29. data/lib/innkeeper/resolvers/abstract.rb +15 -0
  30. data/lib/innkeeper/resolvers/database.rb +11 -0
  31. data/lib/innkeeper/resolvers/schema.rb +14 -0
  32. data/lib/innkeeper/tasks/enhancements.rb +36 -0
  33. data/lib/innkeeper/tenant.rb +47 -0
  34. data/lib/innkeeper/version.rb +3 -0
  35. data/lib/tasks/innkeeper.rake +128 -0
  36. data/notes.md +31 -0
  37. data/test/config_test.rb +52 -0
  38. data/test/databases.yml.sample +37 -0
  39. data/test/decorator_test.rb +21 -0
  40. data/test/domain_elevator_test.rb +38 -0
  41. data/test/dummy/Rakefile +7 -0
  42. data/test/dummy/app/controllers/application_controller.rb +6 -0
  43. data/test/dummy/app/helpers/application_helper.rb +2 -0
  44. data/test/dummy/app/models/company.rb +3 -0
  45. data/test/dummy/app/models/user.rb +3 -0
  46. data/test/dummy/app/views/application/index.html.erb +1 -0
  47. data/test/dummy/app/views/layouts/application.html.erb +14 -0
  48. data/test/dummy/config.ru +4 -0
  49. data/test/dummy/config/application.rb +49 -0
  50. data/test/dummy/config/boot.rb +11 -0
  51. data/test/dummy/config/database.yml.sample +38 -0
  52. data/test/dummy/config/environment.rb +5 -0
  53. data/test/dummy/config/environments/development.rb +27 -0
  54. data/test/dummy/config/environments/production.rb +51 -0
  55. data/test/dummy/config/environments/test.rb +34 -0
  56. data/test/dummy/config/initializers/backtrace_silencers.rb +7 -0
  57. data/test/dummy/config/initializers/inflections.rb +10 -0
  58. data/test/dummy/config/initializers/innkeeper.rb +4 -0
  59. data/test/dummy/config/initializers/mime_types.rb +5 -0
  60. data/test/dummy/config/initializers/secret_token.rb +7 -0
  61. data/test/dummy/config/initializers/session_store.rb +8 -0
  62. data/test/dummy/config/locales/en.yml +5 -0
  63. data/test/dummy/config/routes.rb +3 -0
  64. data/test/dummy/db/schema.rb +19 -0
  65. data/test/dummy/db/seeds.rb +5 -0
  66. data/test/dummy/db/seeds/import.rb +5 -0
  67. data/test/dummy/public/404.html +26 -0
  68. data/test/dummy/public/422.html +26 -0
  69. data/test/dummy/public/500.html +26 -0
  70. data/test/dummy/public/favicon.ico +0 -0
  71. data/test/dummy/public/stylesheets/.gitkeep +0 -0
  72. data/test/dummy/script/rails +6 -0
  73. data/test/excluded_models_test.rb +32 -0
  74. data/test/generic_elevator_test.rb +63 -0
  75. data/test/host_hash_elevator_test.rb +42 -0
  76. data/test/innkeeper_test.rb +96 -0
  77. data/test/mocks/adapter_mock.rb +11 -0
  78. data/test/multithreading_test.rb +37 -0
  79. data/test/mysql2_adapter_test.rb +17 -0
  80. data/test/postgresql_adapter_test.rb +39 -0
  81. data/test/railtie_test.rb +31 -0
  82. data/test/rake_task_test.rb +57 -0
  83. data/test/resolver_test.rb +21 -0
  84. data/test/shared/shared_adapter_tests.rb +95 -0
  85. data/test/subdomain_elevator_test.rb +75 -0
  86. data/test/test_helper.rb +24 -0
  87. 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,10 @@
1
+ require 'active_support/deprecation'
2
+
3
+ module Innkeeper
4
+ module Deprecation
5
+
6
+ def self.warn(message)
7
+ ActiveSupport::Deprecation.warn message
8
+ end
9
+ end
10
+ 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
@@ -0,0 +1,15 @@
1
+ module Innkeeper
2
+ module Resolvers
3
+ class Abstract
4
+ attr_accessor :init_config
5
+
6
+ def initialize(init_config)
7
+ @init_config = init_config.freeze
8
+ end
9
+
10
+ def resolve
11
+ raise "Cannot use abstract class directly"
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,11 @@
1
+ require 'innkeeper/resolvers/abstract'
2
+
3
+ module Innkeeper
4
+ module Resolvers
5
+ class Database < Abstract
6
+ def resolve(tenant)
7
+ init_config.dup.tap{ |c| c[:database] = tenant }
8
+ end
9
+ end
10
+ end
11
+ end