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.
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