Date: Mon, 21 Sep 2015 12:31:42 -0300
Last-Update: 2022-03-29
Forwarded: no
Author: Antonio Terceiro <terceiro@debian.org>
Description: Add multi-tenancy support
 This is an improved version of the combination of a few patches that
 were carried in the Redmine package for Debian GNU/Linux for a few
 years.
 .
 Documentation is provided as a man page produced by
 `./bin/redmine-instances help`
Signed-off-by: Antonio Terceiro <terceiro@debian.org>
Signed-off-by: Jérémy Lal <kapouer@melix.org>
Signed-off-by: Ondřej Surý <ondrej@sury.org>
---
This patch header follows DEP-3: http://dep.debian.net/deps/dep3/

--- a/.gitignore
+++ b/.gitignore
@@ -17,6 +17,7 @@
 /db/*.sqlite3-*
 /db/schema.rb
 /files/*
+/instances
 /lib/redmine/scm/adapters/mercurial/redminehelper.pyc
 /lib/redmine/scm/adapters/mercurial/redminehelper.pyo
 /log/*.log*
--- a/Gemfile
+++ b/Gemfile
@@ -58,13 +58,17 @@
 # configuration file
 require 'erb'
 require 'yaml'
-database_file = File.join(File.dirname(__FILE__), "config/database.yml")
-if File.exist?(database_file)
+seen_adapters = {}
+Dir['/usr/share/redmine/{config,instances/*/config}/database.yml'].select do |f|
+  File.readable?(f)
+end.each do |database_file|
   yaml_config = ERB.new(IO.read(database_file)).result
   database_config = YAML.respond_to?(:unsafe_load) ? YAML.unsafe_load(yaml_config) : YAML.load(yaml_config)
   adapters = database_config.values.map {|c| c['adapter']}.compact.uniq
   if adapters.any?
     adapters.each do |adapter|
+      next if seen_adapters[adapter]
+      seen_adapters[adapter] = true
       case adapter
       when 'mysql2'
         gem "mysql2", "~> 0.5.0", :platforms => [:mri, :mingw, :x64_mingw]
@@ -82,8 +86,6 @@
   else
     warn("No adapter found in config/database.yml, please configure it first")
   end
-else
-  warn("Please configure your config/database.yml first")
 end
 
 group :test do
--- a/app/models/attachment.rb
+++ b/app/models/attachment.rb
@@ -77,10 +77,10 @@
   )
 
   cattr_accessor :storage_path
-  @@storage_path = Redmine::Configuration['attachments_storage_path'] || File.join(Rails.root, "files")
+  @@storage_path = Redmine::Configuration['attachments_storage_path'] || File.join(Redmine.root, "files")
 
   cattr_accessor :thumbnails_storage_path
-  @@thumbnails_storage_path = File.join(Rails.root, "tmp", "thumbnails")
+  @@thumbnails_storage_path = File.join(Redmine.root, "tmp", "thumbnails")
 
   before_create :files_to_final_location
   after_rollback :delete_from_disk, :on => :create
--- /dev/null
+++ b/bin/redmine-instances
@@ -0,0 +1,289 @@
+#!/bin/sh
+
+# Copyright (C) 2015 Antonio Terceiro
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+
+set -eu
+
+REDMINE_INSTANCES_OWNERSHIP=$(stat -c %U:%G $(dirname $0/..))
+REDMINE_INSTANCES_FOLLOW_FHS=""
+REDMINE_INSTANCES_ROOT="$(readlink -f $(dirname $0)/..)/instances"
+
+if [ -r /etc/default/redmine ]; then
+  . /etc/default/redmine
+fi
+
+usage() {
+  local rc="${1:-0}"
+  local program="$(basename $0)"
+  echo "usage: $program list"
+  echo "usage: $program create INSTANCE"
+  echo "usage: $program remove INSTANCE"
+  echo "usage: $program help"
+  exit $rc
+}
+
+# map directories support for placing the files in the correct locations
+# according to the FHS (http://www.pathname.com/fhs/). Required for official
+# Debian packages.
+fhs_directory_mapping() {
+  if [ -n "$REDMINE_INSTANCES_FOLLOW_FHS" ]; then
+    local instance="$1"
+    local dir="$2"
+    case "$dir" in
+      config)
+        echo "/etc/redmine/$instance"
+        ;;
+      log)
+        echo "/var/log/redmine/$instance"
+        ;;
+      files)
+        echo "/var/lib/redmine/$instance/files"
+        ;;
+      public/plugin_assets)
+        echo "/var/cache/redmine/$instance/plugin_assets"
+        ;;
+      tmp)
+        echo "/var/cache/redmine/$instance/tmp"
+        ;;
+    esac
+  fi
+}
+
+make_instance_directory() {
+  local instance="$1"
+  local dirname="$2"
+  local target="$REDMINE_INSTANCES_ROOT/$instance/$dirname"
+  local dir=$(fhs_directory_mapping "$instance" "$dirname")
+  if [ -z "$dir" ]; then
+    dir="$target"
+  fi
+
+  mkdir -p "$dir"
+  chown -R "$REDMINE_INSTANCES_OWNERSHIP" "$dir"
+
+  if [ "$dir" != $target ]; then
+    mkdir -p $(dirname "$target")
+    ln -sfT "$dir" "$target"
+  fi
+}
+
+call_pager() {
+  if [ -t 1 ]; then
+    man -l -
+  else
+    cat
+  fi
+}
+
+if [ $# -lt 1 ]; then
+  usage 1
+fi
+cmd="$1"
+shift
+
+case "$cmd" in
+  list)
+    if [ -d "$REDMINE_INSTANCES_ROOT" ]; then
+      ls -1 "$REDMINE_INSTANCES_ROOT"
+    fi
+    ;;
+
+  create)
+    instance="${1:-}"
+    [ -n "$instance" ] || usage 1
+
+    make_instance_directory "$instance" config
+    make_instance_directory "$instance" log
+    make_instance_directory "$instance" files
+    make_instance_directory "$instance" public/plugin_assets
+    make_instance_directory "$instance" tmp
+    ;;
+
+  remove)
+    instance="${1:-}"
+    [ -n "$instance" ] || usage 1
+
+    printf "Are you sure you want to remove instance $instance? There is no going back [y/N] "
+    read confirm
+    if [ "$confirm" = 'y' -o "$confirm" = 'Y' ]; then
+      rm -rf "$REDMINE_INSTANCES_ROOT/$instance"
+    else
+      echo "Aborted."
+    fi
+    ;;
+
+  help)
+    sed -e '1,/<<DOCUMENTATION$/d; /^DOCUMENTATION/d' "$0" \
+      | pod2man --name='redmine-instances' --center "" --release "" - \
+      | call_pager
+    ;;
+
+  *)
+    if [ -n "$cmd" ]; then
+      echo "E: invalid command: $cmd"
+    fi
+    usage 1
+    ;;
+esac
+
+exit
+: <<DOCUMENTATION
+=encoding UTF-8
+
+=head1 redmine-instances
+
+redmine-instances - manage multiple instances in a single Redmine install
+
+=head1 SYNOPSIS
+
+B<redmine-instances> I<COMMAND> [I<INSTANCE>]
+
+See "I<THE REDMINE MULTITENANCY SYSTEM>" below for a description of how it all
+works.
+
+=head1 COMMANDS
+
+=head2 list
+
+Lists the existing instances.
+
+=head2 create INSTANCE
+
+Creates an instance. This command is idempotent, so calling it multiple times
+is not a problem. Not only that, but existing instances will also be repaired
+and gain the expected directory structure.
+
+=head2 remove INSTANCE
+
+Removes an instance. After this command finished, all files in the instance
+directory will be removed. Any MySQL/PostgreSQL databases will I<not> be
+removed, and must be removed manually.
+
+=head2 help
+
+Displays this documentation.
+
+=head1 THE REDMINE MULTITENANCY SYSTEM
+
+The Redmine multitenancy system is implemented in such a way that it is
+unobstrusive: if you don't do anything to configure multiple instances, Redmine
+will just work as it normally does.
+
+To run redmine against a specific instance, you must set the
+I<REDMINE_INSTANCE> environment variable for Redmine processes. Since such
+environment variable is queried at compile times in some parts of the Redmine
+codebase, this means that multiple Redmine instances must be handled as
+I<separate processes>.
+
+When Redmine is started with the I<REDMINE_INSTANCE> environment variable set
+to an instance name, then a few parameters will be adjusted:
+
+I<Logs> will be stored in /path/to/redmine/instances/I<$REDMINE_INSTANCE>/log/
+
+I<Database configuration> will be read from
+/path/to/redmine/instances/I<$REDMINE_INSTANCE>/config/database.yml
+
+I<Temporary files>, such as caches, will be stored in
+/path/to/redmine/instances/I<$REDMINE_INSTANCE>/tmp/
+
+I<File attachments> will be stored in
+/path/to/redmine/instances/I<$REDMINE_INSTANCE>/files/
+
+I<Plugin assets> will be stored in
+/path/to/redmine/instances/I<$REDMINE_INSTANCE>/public/plugin_assets/
+
+=head1 NOTES FOR DEBIAN GNU/LINUX
+
+When using the official redmine Debian package, the directories mentioned above
+will actually be symbolic links to the expected locations according to FHS,
+namely:
+
+I<config> will be a link to /etc/redmine/I<$REDMINE_INSTANCE>/
+
+I<log> will be a link /var/log/redmine/I<$REDMINE_INSTANCE>/
+
+I<tmp> will be a link /var/cache/redmine/I<$REDMINE_INSTANCE>/
+
+I<files> will be stored directly in /var/lib/redmine/I<$REDMINE_INSTANCE>/files/
+
+I<plugin assets> will be stored directly in /var/lib/redmine/I<$REDMINE_INSTANCE>/plugin_assets/
+
+Users of the Debian package can also find B<redmine-instances> in I<$PATH>,
+and can call it directly (as root) from any directory.
+
+=head1 EXAMPLE: CREATING AND USING A NEW INSTANCE
+
+=head2 Creating the directory structure
+
+  $ cd /path/to/redmine
+  $ ./bin/redmine-instances create myinstance
+  $ edit instances/myinstance/config/database.yml
+  [...]
+  $ export REDMINE_INSTANCE=myinstance
+  $ bundle exec rake db:migrate
+  $ bundle exec rake redmine:load_default_data
+  $ bundle exec rake generate_secret_token
+
+=head2 Web server configuration
+
+For Passenger with Apache, you will want something like the example below. Note
+the I<PassengerAppGroupName>: it must be set to a different value for each of
+your Redmine instances, and PassengerAppGroupName will make sure that all your
+instances are isolated from each other.
+
+  <VirtualHost *:80>
+    ServerName my.domain.name
+    RailsEnv production
+    SetEnv REDMINE_INSTANCE "myinstance"
+    PassengerAppGroupName redmine_myinstance
+    PassengerDefaultUser www-data
+    DocumentRoot /path/to/redmine/public
+    <Directory "/path/to/redmine/public">
+      Allow from all
+      Options -MultiViews
+      Require all granted
+    </Directory>
+  </VirtualHost>
+
+=head1 SEE ALSO
+
+Filesystem Hierarchy Standard (FHS), I<http://www.pathname.com/fhs/>.
+
+=head1 COPYRIGHT AND AUTHORS
+
+B<redmine-instances> is Copyright (C) 2015, Antonio Terceiro.
+
+The Multi-Tenancy support for Redmine was originally developed for Debian
+GNU/Linux, and is Copyright (C) 2014-2015 Antonio Terceiro, 2011-2014 Jérémy
+Lal, 2011-2014 Ondrej Surý.
+
+B<Redmine> is Copyright (C) 2006-2015, Jean-Philippe Lang.
+
+This program is free software; you can redistribute it and/or modify it under
+the terms of the GNU General Public License as published by the Free Software
+Foundation; either version 2 of the License, or (at your option) any later
+version.
+
+This program is distributed in the hope that it will be useful, but WITHOUT ANY
+WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
+PARTICULAR PURPOSE.  See the GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License along with
+this program; if not, write to the Free Software Foundation, Inc., 51 Franklin
+Street, Fifth Floor, Boston, MA  02110-1301, USA.
+
+DOCUMENTATION
--- /dev/null
+++ b/config/multitenancy_environment.rb
@@ -0,0 +1,42 @@
+# Copyright (C) 2014-2015 Antonio Terceiro
+# Copyright (C) 2011-2014 Jérémy Lal
+# Copyright (C) 2011-2014 Ondřej Surý
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+
+require 'redmine/multi_tenancy'
+
+if Redmine.tenant?
+
+  # per-instance tmp/local cache directories
+  config.paths['tmp'] = Redmine.root.join('tmp').to_s
+  config.cache_store = :file_store, Redmine.root.join('tmp', 'cache').to_s
+
+  # per-instance logs
+  config.paths['log'] = Redmine.root.join('log', Rails.env + '.log').to_s
+
+  # per-instance database configuration
+  config.paths['config/database'] = Redmine.root.join('config', 'database.yml').to_s
+
+  # per-instance session cookie
+  path = ENV.fetch('RAILS_RELATIVE_URL_ROOT', '/')
+  key = Redmine.name || '_redmine_session'
+  config.session_store :cookie_store, :key => key, :path => path
+  secret_file = Redmine.root.join('config', 'secret_key.txt')
+  if File.exists?(secret_file)
+    config.secret_key_base = File.read(secret_file).strip
+  end
+
+end
--- a/lib/redmine/configuration.rb
+++ b/lib/redmine/configuration.rb
@@ -38,7 +38,7 @@
       # * <tt>:file</tt>: the configuration file to load (default: config/configuration.yml)
       # * <tt>:env</tt>: the environment to load the configuration for (default: Rails.env)
       def load(options={})
-        filename = options[:file] || File.join(Rails.root, 'config', 'configuration.yml')
+        filename = options[:file] || File.join(Redmine.root, 'config', 'configuration.yml')
         env = options[:env] || Rails.env
 
         @config = @defaults.dup
@@ -116,7 +116,7 @@
       end
 
       def load_deprecated_email_configuration(env)
-        deprecated_email_conf = File.join(Rails.root, 'config', 'email.yml')
+        deprecated_email_conf = File.join(Redmine.root, 'config', 'email.yml')
         if File.file?(deprecated_email_conf)
           warn "Storing outgoing emails configuration in config/email.yml is deprecated. You should now store it in config/configuration.yml using the email_delivery setting."
           @config.merge!({'email_delivery' => load_from_yaml(deprecated_email_conf, env)})
--- /dev/null
+++ b/lib/redmine/multi_tenancy.rb
@@ -0,0 +1,47 @@
+# Copyright (C) 2014-2015 Antonio Terceiro
+# Copyright (C) 2011-2014 Jérémy Lal
+# Copyright (C) 2011-2014 Ondřej Surý
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+
+module Redmine
+  # The new autoloader (Zeitwerk) introduced in Rails 6.0 expects a constant
+  # whose name derive from the filename
+  class MultiTenancy
+  end
+
+  class << self
+
+    def tenant
+      # X_DEBIAN_SITEID is kept here for backwards compatibility with existing
+      # Debian installations.
+      ENV['X_DEBIAN_SITEID'] || ENV['REDMINE_INSTANCE']
+    end
+
+    def tenant?
+      !tenant.nil?
+    end
+
+    def root
+      if tenant
+        Pathname.new(File.join(Rails.root, 'instances', tenant))
+      else
+        Rails.root
+      end
+    end
+
+  end
+
+end
--- a/lib/redmine/scm/adapters/abstract_adapter.rb
+++ b/lib/redmine/scm/adapters/abstract_adapter.rb
@@ -215,7 +215,7 @@
           if @stderr_log_file.nil?
             writable = false
             path = Redmine::Configuration['scm_stderr_log_file'].presence
-            path ||= Rails.root.join("log/#{Rails.env}.scm.stderr.log").to_s
+            path ||= Redmine.root.join("log/#{Rails.env}.scm.stderr.log").to_s
             if File.exist?(path)
               if File.file?(path) && File.writable?(path)
                 writable = true
--- a/lib/tasks/initializers.rake
+++ b/lib/tasks/initializers.rake
@@ -15,7 +15,7 @@
 # change this key, all old sessions will become invalid! Make sure the
 # secret is at least 30 characters and all random, no regular words or
 # you'll be exposed to dictionary attacks.
-RedmineApp::Application.config.secret_key_base = '#{secret}'
+RedmineApp::Application.config.secret_key_base = [Redmine.tenant, '#{secret}'].compact.join(':')
 EOF
   end
 end
--- a/app/models/import.rb
+++ b/app/models/import.rb
@@ -111,7 +111,7 @@
   # It is stored in tmp/imports with a random hex as filename
   def filepath
     if filename.present? && /\A[0-9a-f]+\z/.match?(filename)
-      File.join(Rails.root, "tmp", "imports", filename)
+      File.join(Redmine.root, "tmp", "imports", filename)
     else
       nil
     end
--- a/lib/redmine/plugin_loader.rb
+++ b/lib/redmine/plugin_loader.rb
@@ -88,7 +88,7 @@
 
     # Absolute path to the plublic directory where plugins assets are copied
     cattr_accessor :public_directory
-    self.public_directory = Rails.root.join('public/plugin_assets')
+    self.public_directory = Redmine.root.join('public/plugin_assets')
 
     def self.create_assets_reloader
       plugin_assets_dirs = {}
--- a/config/application.rb
+++ b/config/application.rb
@@ -93,6 +93,7 @@
       :same_site => :lax
     )
 
+    instance_eval File.read(File.join(File.dirname(__FILE__), 'multitenancy_environment.rb'))
     if File.exist?(File.join(File.dirname(__FILE__), 'additional_environment.rb'))
       instance_eval File.read(File.join(File.dirname(__FILE__), 'additional_environment.rb'))
     end
