gorynich

[MIRROR] Multitenancy for Rails including ActiveRecord, ActionCable, ActiveJob and other subsystems

MIT License

Downloads
5.4K
Stars
13

Gorynich

Gorynich - () Ruby on Rails . , ActiveRecord.

   ,          (, S3, Redis  .),         (ActiveJob, ActionCable),    [""](https://ru.wikipedia.org/wiki/_),   ~~  .

Gorynich provides tools for creating Multitenancy Ruby on Rails application. If you need to have strong data segregation and isolated DBMS's with diffrent providers (supported by ActiveRecord) and credentials, Gorynich can help.

Since a multi-tenant application is closely related to the separation of data, which in turn can be located in different sources (DBMS, S3, Redis, etc.), as well as their processing in different subsystems (ActiveJob, ActionCable), we chose the name "Gorynych", which to emphasize the multiheadedness versatility of integrations.

/ Features

  • / / Transparent request based DB/DBMS switching
  • / Integrations:
  • ActiveRecord
  • ActionCable
  • ActiveJob
  • DelayedJob
  • Consul KV / Stoting configuration in Consul KV
  • / Storing configuration in file
    
  • / Secret storing and isolation
  • " " / update configuration "on the fly"
  • database.yml / Static database.yml generation

/ Getting started

gem install gorynich

Gorynich bundler Gemfile:


If you'd rather install Gorynichr using bundler, add a line for it in your Gemfile:

gem 'gorynich'

/ Then run:

bundle install #    / gem installation

rails generate gorynich:install #     / install configuration templates

? / What tenant is?

( ) - , Gorynich::Current, . .


In this case tenant is an active connection to the DBMS, as well as a Gorynich::Current object available anywhere, which contains the parameters of the current tenant. You can refer to it anywhere, for example when sending emails:

Gorynich::Current.tap do |t|
  t.tenant   # tenant_name
  t.uri      # https://app.domain.org
  t.host     # app.domain.org
  t.secrets  # { key1 => value1, key2 => value2}
  t.database # { adapter => postgresql, host => localhost, port => 5432, username => xxx, password => xxx }
end

/ How it works

 [Gorynich::Rack::RackMiddleware](./lib/gorynich/head/rack_middleware.rb)  Active Record    ,    [ActiveSupport::CurrentAttributes](https://api.rubyonrails.org/classes/ActiveSupport/CurrentAttributes.html)            `Gorynich::Current`. ActionCable, ActiveJob   ""    `Gorynich::Current`      .

, ActiveJob :


Before request processing Gorynich::Rack::RackMiddleware ActiveRecord connection switching to apropriate database. Additional tenant properties available in any part of application through ActiveSupport::CurrentAttributes as Gorynich::Current instance. ActionCable, ActiveJob and other "heads" also uses Gorynich::Current to store context and evaluate it later.

For example, when sending emails from within ActiveJob, the usage looks like this:

#app/mailers/application_mailer.rb

class ApplicationMailer < ActionMailer::Base
  helper :application

  def self.email_settings
    (
      Gorynich::Current.secrets[:email_settings] || Rails.application.secrets.email_settings || {}
    ).with_indifferent_access
  end

  default from: email_settings[:from], content_type: 'text/plain'

  def mail(args)
    @host = Gorynich.instance.hosts(Gorynich::Current.tenant).first || Rails.application.secrets.domain

    @settings ||= smtp_settings.merge(application_host: @host)

    super(args).tap |m|
      m.from = @settings[:from]
      m.delivery_method.settings = @settings unless Rails.env.development?
    end
  end

end

/ Usage

/ Configuration source

 `config/application.rb`   .   3 :

Now you need to select configuration source in config/application.rb. Yuo can choose from 3 source types now:

Gorynich::Fetchers::File.new(file_path: [FILE_PATH]) #   / from file

Gorynich::Fetchers::Consul.new(storage: [CONSUL_KEY], **options) #   / from consul (options - from Dimplomat gem https://github.com/WeAreFarmGeek/diplomat)

Gorynich::Fetchers::ConsulSecure.new(storage: [CONSUL_KEY], file_path: [FILE_PATH], **options) #       (      ) / from consul with saving to a file (if unavailable, consul will read from the file) (options - from Dimplomat gem https://github.com/WeAreFarmGeek/diplomat)

/ Example:

#   / from single source
Gorynich.configuration.fetcher = Gorynich::Fetchers::File.new(file_path: Rails.root.join('config', 'gorynich_config.yml'))

#   (     fetcher)
# from multiple sources - first succesful source is used
Gorynich.configuration.fetcher  = [
  Gorynich::Fetchers::Consul.new(storage: 'gorynich_project/config'),
  Gorynich::Fetchers::File.new(file_path: Rails.root.join('config', 'gorynich_config.yml'))
]

("") / Integration "Heads"

Gorynich / Gorynich configured in initializer:

# config/initializers/gorynich.rb

Gorynich.configure do |config|
  # config cache of gorynich
  config.cache = Rails.cache

  # config cache namespace
  config.namespace = ENV.fetch('YOUR_NAMESPACE_ENV', 'your_namespace')

  # config how long your source cache will be alive in seconds
  config.cache_expiration = 'your_value'

  # Custom handler for swithing tenants in gorynich rack middleware
  config.rack_env_handler =
    lambda do |env|
      host = env['SERVER_NAME']
      tenant = Gorynich.instance.tenant_by_host(host)
      uri = Gorynich.instance.uri_by_host(host, tenant)

      Sentry.set_tags(tenant: tenant) if Sentry.get_current_scope.present?

      [tenant, { host: host, uri: uri }]
    end
end

# Add cable head
ActiveSupport.on_load(:action_cable_connection) do
  include Gorynich::Head::ActionCable::Connection
end

ActiveSupport.on_load(:action_cable_channel) do
  prepend Gorynich::Head::ActionCable::Channel
end

# Add active job head
ActiveSupport.on_load(:active_job) do
  include Gorynich::Head::ActiveJob
end

Rake Tasks

Rails console / Run rails console inside tenant:

TENANT=tenant rails gc # default tenant name id  'default'
`database.yml`    (Fetcher) :

For static database.yml generation from configured source (Fetcher) use:

rails gc:db:prepare

/ Database configuration

  1. / static generation

, , , database.yml.


First and most simple using of Gorynich handy for local development is static database.yml generation.

rake- / runing rake task:

rails gc:db:prepare
  1. / Semi-automated mode
  • database.yml Rails - . , , secrets, " " . Rake- db:create, db:migrate .

Second option is dynamic database.yml creation while starting Rails application. Configuration will be readed from selected source. In this case database configuration can change only when application restarts, but other configuration such a domain to tenant binding and application secrets wil be updated "on the fly" while application running. Rake tasks db:create and db:migrate works as expected for all tenant in order.

! db:rollback .

WARNING! db:rollback is not working in multitenancy mode.

database.yml / In database.yml set:

# config/database.yml
<%= Gorynich.instance.database_config %>
  1. / Additional databases
,   ,   ,   `database.yml`    ,    Rails :

If you need additional DB, not for tenants Ex. generic database you can configure it in database.yml like in regular Rails application:

# config/database.yml
<%= Gorynich.instance.database_config('development') %>

your_database:
  <<: *configs_for_your_database

<%= Gorynich.instance.database_config('test') %>
your_database:
  <<: *configs_for_your_database

<%= Gorynich.instance.database_config('production') %>
your_database:
  <<: *configs_for_your_database

/ Inside code

, , / Check in which tenant you are:

Gorynich::Current.tenant

, Rails - , ( ). , :


Switching tenants is automatic and no additional steps need to be taken inside a Rails application - you always connected to database associated with currently procesed request. But if you want take action inside specific tenant context you can use:

  #      / run block inside specific tenant
  Gorynich.with('tenant_name') do
    # your code
  end

  #      / run block inside each tenant
  Gorynich.with_each_tenant do |tenant|
    # your code
  end

/ Additional integration examples

Redis / Rails.cache

#config/environments/production.rb

config.cache_store = :redis_cache_store, {
  url:                ENV.fetch('REDIS_URL', nil),
  expires_in:         90.minutes,
  connect_timeout:    3,
  reconnect_attempts: 3,
  namespace:          -> { "#{Gorynich.configuration.namespace}#{Gorynich::Current.tenant}" }
}

Sentry

#config/initializers/gorynich.rb

Gorynich.configure do |config|
  config.rack_env_handler =
    lambda do |env|
      host = env['SERVER_NAME']
      tenant = Gorynich.instance.tenant_by_host(host)
      uri = Gorynich.instance.uri_by_host(host, tenant)

      Sentry.set_tags(tenant: tenant) if Sentry.get_current_scope.present?

      [tenant, { host: host, uri: uri }]
    end
end

Telegram

#config/environments/production.rb

config.telegram_updates_controller.session_store = :redis_cache_store, {
  url:        ENV.fetch('REDIS_URL', nil),
  expires_in: 90.minutes,
  namespace:  -> { "#{Gorynich.configuration.namespace}#{Gorynich::Current.tenant}" }
}

Shrine

#lib/shrine/plugins/tenant_location.rb

class Shrine
  module Plugins
    module TenantLocation
      module InstanceMethods
        def generate_location(io, **options)
          "#{Gorynich::Current.tenant}/#{super}"
        end
      end
    end

    register_plugin(:tenant_location, TenantLocation)
  end
end

#config/initializers/shrine.rb

Shrine.plugin :tenant_location

ApplicationController

class ApplicationController < ActionController::Base
  around_action :around_action_notification

  def around_action_notification(&block)
    ActiveSupport::Notifications.instrument(
      'around_action.action_controller',
      current_user: current_user,
      request:      request,
      tenant:       Gorynich::Current.tenant, &block
    )
  end
end

DelayedJob

#config/initializers/delayed_job.rb

require 'gorynich/head/delayed_job'

Delayed::Worker.plugins << Gorynich::Head::DelayedJob

/ License

      [ MIT](./LICENSE).

The gem is available as open source under the terms of the MIT License.