CentOS 5にRedmineを導入する

下準備

rubyとsqlite3をインストール
$ yum install ruby
$ yum install sqlite
RubyGemsをインストールするためにyumを設定

/etc/yum.repos.d/dlutter.repo を作成する。ここでは、dlutterさんの作ったリポジトリを拝借する。内容は以下の通り。

[dlutter]
name=Unsupported RHEL5 packages (lutter)
baseurl=http://people.redhat.com/dlutter/yum/rhel/5/$basearch/
enabled=0
gpgcheck=0
RubyGemsをインストール
$ yum --enablerepo=dlutter -y install rubygems.noarch
Ruby on Railsをインストール
$ gem install rails -v 2.1.2

この処理中、

Bulk updating Gem source index for: http://gems.rubyforge.org

と表示されて、処理が進まなくなることがある。これは、メモリ不足の可能性がある。VMware上で、256MBのマシンだと発生したが、512MBまであげてみたところ、この問題は解決した。

また、

Could not find rails (> 0) in any repository

と表示された場合には、

$ gem install --remote rails --include-dependencies

を試してみる。

注意点として、Rails 2.1.2じゃないと、Redmine 0.8 安定版との組み合わせだといろいろとエラーまみれになったので、ここでは、バージョンを明示的に指定している。ちなみに、すでにインストールされているRailsのバージョンを知るには、次のようにする。

$ gem list rails

*** LOCAL GEMS ***

rails (2.1.2)
    Web-application framework with template engine, control-flow layer,
    and ORM.
sqlite3-rubyをインストール

sqlite3-rubyをインストールするに当たっては、ruby-devel、sqlite-develが必要になるので、あらかじめ、インストールしておく。

$ yum install ruby-devel
$ yum install sqlite-devel

その後にgemをつかってインストールする。

$ gem insall sqlite3-ruby

Redmineのインストール

ここまできてやっとRedmineをインストールできる。ここまででも、情報が分散しているとなかなか大変。

Redmineのチェックアウト

ここでチェックアウトするディレクトリは、ウェブに公開するディレクトリではないところの方がいい。とりあえず、/var/wwwあたりにチェックアウトしておけばいいのではないかな。それについてはまた後ほど。

$ cd /var/www
$ svn co http://redmine.rubyforge.org/svn/branches/0.8-stable redmine

今回は、保守的に、0.8 安定版を使う。というか、最新版だと、全く動かせなかったのが理由。ハマリまくってもいい人以外は安定版をおすすめ。

前のところで、あえて、Rails 2.1.2以外をインストールした場合には、redmine/config/environment.rbにあるRAILS_GEM_VERSIONを修正する。しかしながら、手元では、Redmin 0.8 安定版を2.2.2などで動かすことはできなかった。

データベースの設定

redmine/config/database.ymlを作成する。今回は、sqliteを使用しているので、次の設定は、sqliteのデータベースのパスを、redmine/db/redmine.dbに設定する。

production:
  adapter: sqlite3
  dbfile: db/redmine.db
  timeout: 5000
データベース初期化

redmine/の下で次の作業を行う。

$ rake db:migrate RAILS_ENV=production

次に、デフォルトデータをロードする。ここでは、言語を聞かれるので、必要に応じて、enあるいは、jaを設定する。

$ rake redmine:load_default_data RAILS_ENV=production
(in /home/www/html/redmine)

Select language: bg, ca, cs, da, de, en, es, fi, fr, gl, he, hu, it, ja, ko, lt, nl, no, pl, pt, pt-br, ro, ru, sk, sr, sv, th, tr, uk, vn, zh, zh-tw [en] 
====================================
Default configuration data loaded.

データベース関連のファイルとして、

db/redmine.db
db/schema.rb

が作成されるので、バックアップ時には、これらをバックアップする。

また、実は、今回、僕の場合には、bugzillaからの移行だったのだが、それについては、下で説明する。

メール送信用の設定

redmine/config/email.ymlを作成する。たぶん、ほとんどの場合、社内に認証なしで利用できるSMTPサーバーがあるだろうから、それを利用する。認証が必要な場合には、authentication/user_name/passwordを設定する必要がある。

production:
  delivery_method: smtp
  smtp_settings:
    address: smtp.example.com
    port: 25
    domain: example.com
    authentication: :login
    user_name:
    password:
実験的な起動

コマンドラインで次のようにして起動すると、3000/tcpで待ち受け状態となる。初期状態では、admin/adminというユーザーがいるので、そのユーザーでログインして、設定を行う。

$ script/server -e production

この実験的な起動は、くれぐれもファイヤーウォールの内側でやるように。
ある意味では、社内で利用するぐらいなら、ここで設定を終了しても良い。あとは、適当にサービス化すればいいだけ。

Apacheでの起動

とはいえ、うちはApacheじゃないととか、HTTPSでセキュリティを確保したいとか、あるいは、特殊な認証を使わないと行けないとかなると、Apacheの方が断然、カスタマイズがしやすかったりするので、Apacheでの設定方法も書いておきます。

Apacheで起動するには、Passengerというモジュールをインストールする必要があります。

途中、fastthreadをインストールするための質問をされるが基本的には、すべて[Y]で良い。

$ gem install passenger

次に、passenger-install-apache2-moduleを実行するが、gcc-c++, httpd-devel, apr-develなどが必要になるので、適宜、インストールする。

$ passenger-install-apache2-module

このコマンドの完了時に、apacheの設定についてのコメントが表示されるので、それに従って、apacheの設定を修正する。通常は、これを、/etc/httpd/conf.d/redmine.confなどに書くことになる。

LoadModule passenger_module /usr/lib/ruby/gems/1.8/gems/passenger-2.0.6/ext/apache2/mod_passenger.so
PassengerRoot /usr/lib/ruby/gems/1.8/gems/passenger-2.0.6
PassengerRuby /usr/bin/ruby

VirtualHostを使ってうんぬんの設定に関しては、結構、世にあふれているが、普通は、Virtualhostを使うなんて贅沢なことはできないので、サブディレクトリ、つまり、

http://www.example.com/redmine/

などの形態で、運用するための設定を行う。このためには、まず、DocumentRootの直下に、さっき、redmineをチェックアウトしたディレクトリ内のpublicディレクトリに対するシンボリックリンクをredmineという名前で張る。

たとえば、さっき、チェックアウトしたディレクトリが、/var/www/redmineとかで、DocumentRootが、/var/www/htmlとかなら、

$ cd /var/www/html
$ ln -s /var/www/redmine/public redmine

のような感じになる。注意としては、/var/www/redmineはApacheのユーザー、つまり、apacheで読み書き可能になっていること。

$ chown -R apache.apache /var/www/redmine

とかやれば完璧。

次に、先ほどの、/etc/httpd/conf.d/redmine.confに次のようなものを追加する。

# VirtualHostを利用しないので、自動認識をOFF
RailsAutoDetect off

# http://www.example.com/redmine/で運用する
RailsBaseURI /redmine

<Location /redmine>
 # redmineに対する設定
 ...
</Location>

これら設定が完了したら、Apacheを再起動する。

/etc/init.d/httpd restart

たぶん、これで完了。

Bugzillaからの移行

これについては、Bugzillaからの移行のスクリプトを作ってくれている人がいるんだけど、古いバージョンのBugzillaに対するスクリプトだったり、ユーザー名に特殊な文字を使っていたりする(どうも、カンマとか丸括弧がダメっぽい)と、移行時にエラーが発生することになる。その場合、ユーザーの名字とか、姓だけを後で修正すればいいので、とりあえず、エラーなしでインポートできるようにがんばらないと行けない。

さらに、元々のスクリプトだと、Issue IDが、SQLのクエリの順番に付与されてしまい、元々のBugzillaのBug IDと一致しなくなってしまう可能性がある。というか、僕の場合、その問題が起きてしまった。

ということで、下記のスクリプトは、その辺に対する場当たり的な修正が入っている。ただし、rubyネイティブではない僕の修正なので注意。

 # redMine - project management software
 # Copyright (C) 2006-2007  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.
 #
 # Bugzilla migration by Arjen Roodselaar, Lindix bv edited by Oliver Sigge
 #
 # Successfully tested with Bugzilla 2.20, Redmine devBuild, Rails 2.1
 #
 # Please note that the users in the Bugzilla database must have a forename and surename
 
 desc 'Bugzilla migration script'
 
 require 'active_record'
 require 'iconv'
 require 'pp'
 
 namespace :redmine do
 task :migrate_from_bugzilla => :environment do
   
     module BugzillaMigrate
    
       DEFAULT_STATUS = IssueStatus.default
       CLOSED_STATUS = IssueStatus.find :first, :conditions => { :is_closed => true }
       assigned_status = IssueStatus.find_by_position(2)
       resolved_status = IssueStatus.find_by_position(3)
       feedback_status = IssueStatus.find_by_position(4)
       
       STATUS_MAPPING = {
         "UNCONFIRMED" => DEFAULT_STATUS,
         "NEW" => DEFAULT_STATUS,
         "VERIFIED" => DEFAULT_STATUS,
         "ASSIGNED" => assigned_status,
         "REOPENED" => assigned_status,
         "RESOLVED" => resolved_status,
         "CLOSED" => CLOSED_STATUS
       }
       # actually close resolved issues
       resolved_status.is_closed = true
       resolved_status.save
                         
       priorities = Enumeration.get_values('IPRI')
       PRIORITY_MAPPING = {
         "P1" => priorities[1], # low
         "P2" => priorities[2], # normal
         "P3" => priorities[3], # high
         "P4" => priorities[4], # urgent
         "P5" => priorities[5]  # immediate
       }
       DEFAULT_PRIORITY = PRIORITY_MAPPING["P2"]
     
       TRACKER_BUG = Tracker.find_by_position(1)
       TRACKER_FEATURE = Tracker.find_by_position(2)
       
       reporter_role = Role.find_by_position(5)
       developer_role = Role.find_by_position(4)
       manager_role = Role.find_by_position(3)
       DEFAULT_ROLE = reporter_role
       
       CUSTOM_FIELD_TYPE_MAPPING = {
         0 => 'string', # String
         1 => 'int',    # Numeric
         2 => 'int',    # Float
         3 => 'list',   # Enumeration
         4 => 'string', # Email
         5 => 'bool',   # Checkbox
         6 => 'list',   # List
         7 => 'list',   # Multiselection list
         8 => 'date',   # Date
       }
                                    
       RELATION_TYPE_MAPPING = {
         0 => IssueRelation::TYPE_DUPLICATES, # duplicate of
         1 => IssueRelation::TYPE_RELATES,    # related to
         2 => IssueRelation::TYPE_RELATES,    # parent of
         3 => IssueRelation::TYPE_RELATES,    # child of
         4 => IssueRelation::TYPE_DUPLICATES  # has duplicate
       }
                                
       class BugzillaProfile < ActiveRecord::Base
         set_table_name :profiles
         set_primary_key :userid
         
         has_and_belongs_to_many :groups,
           :class_name => "BugzillaGroup",
           :join_table => :user_group_map,
           :foreign_key => :user_id,
           :association_foreign_key => :group_id
         
         def login
           login_name[0..29].gsub(/[^a-zA-Z0-9_\-@\.]/, '')
         end
         
         def email
           if login_name.match(/^.*@.*$/i)
             login_name
           else
             "#{login_name}@foo.bar"
           end
         end
         
         def firstname
           read_attribute(:realname).blank? ? login_name : read_attribute(:realname).split.first[0..29]
         end
 
         def lastname
           read_attribute(:realname).blank? ? login_name : read_attribute(:realname).split[1..-1].join(' ')[0..29]
         end
       end
       
       class BugzillaGroup < ActiveRecord::Base
         set_table_name :groups
         
         has_and_belongs_to_many :profiles,
           :class_name => "BugzillaProfile",
           :join_table => :user_group_map,
           :foreign_key => :group_id,
           :association_foreign_key => :user_id
       end
       
       class BugzillaProduct < ActiveRecord::Base
         set_table_name :products
         
         has_many :components, :class_name => "BugzillaComponent", :foreign_key => :product_id
         has_many :versions, :class_name => "BugzillaVersion", :foreign_key => :product_id
         has_many :bugs, :class_name => "BugzillaBug", :foreign_key => :product_id
       end
       
       class BugzillaComponent < ActiveRecord::Base
         set_table_name :components
       end
       
       class BugzillaVersion < ActiveRecord::Base
         set_table_name :versions
       end
       
       class BugzillaBug < ActiveRecord::Base
         set_table_name :bugs
         set_primary_key :bug_id
         
         belongs_to :product, :class_name => "BugzillaProduct", :foreign_key => :product_id
         has_many :descriptions, :class_name => "BugzillaDescription", :foreign_key => :bug_id
       end
       
       class BugzillaDescription < ActiveRecord::Base
         set_table_name :longdescs
         
         belongs_to :bug, :class_name => "BugzillaBug", :foreign_key => :bug_id
         
         def eql(desc)
           self.bug_when == desc.bug_when
         end
         
         def === desc
           self.eql(desc)
         end
         
         def self.inheritance_column
           "inh_type"
         end
         
         def text
           if self.thetext.blank?
             return nil
           else
             self.thetext
           end 
         end
       end
       
       def self.establish_connection(params)
         constants.each do |const|
           klass = const_get(const)
           next unless klass.respond_to? 'establish_connection'
           klass.establish_connection params
         end
       end
       
       def self.migrate
         
         # Profiles
         print "Migrating profiles\n"
         $stdout.flush
         User.delete_all "login <> 'admin'"
         users_map = {}
         users_migrated = 0
         BugzillaProfile.find(:all).each do |profile|
           user = User.new
           user.login = profile.login
           user.password = "bugzilla"
           user.firstname = profile.firstname
           user.lastname = profile.lastname
           user.mail = profile.email
           user.status = User::STATUS_LOCKED if !profile.disabledtext.empty?
           user.admin = true if profile.groups.include?(BugzillaGroup.find_by_name("admin"))
           if (user.login.nil?)
             user.login = user.mail
           end
 
           if user.firstname.nil? || user.firstname.blank?
             user.firstname = "dummy"
           else
             user.firstname = user.firstname.scan(/([\x2d-\x7f])/).join
           end
           if user.firstname.blank?
             user.firstname = "blank"
           end
           if user.lastname.nil? || user.lastname.blank? 
             user.lastname = "dummy"
           else
             user.lastname = user.lastname.scan(/([\x2d-\x7f])/).join
           end
           if user.lastname.blank?
             user.lastname = "blank"
           end
 
          result = user.save
          user.errors.each{|col,err| puts "  - #{col.to_s.humanize} #{err}" }
          print user.mail+","+user.firstname+","+user.lastname+"\n"
 
          next unless result
             users_migrated += 1
             users_map[profile.userid] = user
             print '.'
             $stdout.flush
         end
         
         
         # Products
         puts
         print "Migrating products"
         $stdout.flush
         
         Project.destroy_all
         projects_map = {}
         versions_map = {}
         categories_map = {}
         BugzillaProduct.find(:all).each do |product|
           project = Project.new
           project.name = product.name
           project.description = product.description
           project.identifier = product.name.downcase.gsub(/[0-9_\s\.]*/, '')[0..15]
           project.trackers[0] = TRACKER_BUG
           #puts "Name: #{product.name}; Identifier: #{project.identifier}\n"
           next unless project.save
           projects_map[product.id] = project
             print "."
             $stdout.flush
 
             # Enable issue tracking
             enabled_module = EnabledModule.new(
               :project => project,
               :name => 'issue_tracking'
             )
             enabled_module.save
           # Components
           product.components.each do |component|
             category = IssueCategory.new(:name => component.name[0,30])
             category.project = project
             category.assigned_to = users_map[component.initialowner]
             category.save
             categories_map[component.id] = category
           end
           # Add default user roles
             1.upto(users_map.length) do |i|
             membership = Member.new(
               :user => users_map[i],
               :project => project,
               :role => DEFAULT_ROLE
             )
             membership.save
             end
         end
         
         # Bugs
         puts "\n"
         print "Migrating bugs"
         Issue.destroy_all
         issues_map = {}
         skipped_bugs = []
         BugzillaBug.find(:all).sort_by {|bug| bug.bug_id}.each do |bug|
         #BugzillaBug.find(:all).each do |bug|
           issue = Issue.new(
             :project => projects_map[bug.product_id],
             :tracker => TRACKER_BUG,
             :subject => bug.short_desc,
             :description => bug.descriptions.first.text || bug.short_desc,
             :author => users_map[bug.reporter],
             :priority => PRIORITY_MAPPING[bug.priority] || DEFAULT_PRIORITY,
             :status => STATUS_MAPPING[bug.bug_status] || DEFAULT_STATUS,
             :start_date => bug.creation_ts,
             :created_on => bug.creation_ts,
             :updated_on => bug.delta_ts
           )
 
           issue.category = categories_map[bug.component_id] unless bug.component_id.blank?                  
           issue.assigned_to = users_map[bug.assigned_to] unless bug.assigned_to.blank?          
           if issue.save
             print '.'
             else
               issue.id = bug.bug_id
               skipped_bugs << issue
               print "!"
               next
             end
             $stdout.flush
             
           # notes
           bug.descriptions.each do |description|
             # the first comment is already added to the description field of the bug
             next if description === bug.descriptions.first
             journal = Journal.new(
               :journalized => issue,
               :user => users_map[description.who],
               :notes => description.text,
               :created_on => description.bug_when
             )
             if (journal.user.nil?)
              journal.user = User.find(:first)
             end
             next unless journal.save
           end
         end
         puts
         
         puts
         puts "Profiles:       #{users_migrated}/#{BugzillaProfile.count}"
         puts "Products:       #{Project.count}/#{BugzillaProduct.count}"
         puts "Components:     #{IssueCategory.count}/#{BugzillaComponent.count}"
         puts "Bugs            #{Issue.count}/#{BugzillaBug.count}"
         puts
         
         if !skipped_bugs.empty?
           puts "The following bugs failed to import: "
           skipped_bugs.each do |issue|
             print "#{issue.id}, reason: "
             issue.errors.each{|error| print "#{error}"}
             puts
           end
         end
       end
 
       puts
       puts "WARNING: Your Redmine data will be deleted during this process."
       print "Are you sure you want to continue ? [y/N] "
       break unless STDIN.gets.match(/^y$/i)
       
       # Default Bugzilla database settings
       db_params = {:adapter => 'mysql', 
                    :database => 'bugzilla', 
                    :host => 'localhost',
                    #:port => 3308,
                    :socket => '/var/lib/mysql/mysql.sock',
                    :username => 'root', 
                    :password => 'password',
                    :encoding => 'utf8' }
 
       puts
       puts "Please enter settings for your Bugzilla database"  
       [:adapter, :host, :database, :username, :password].each do |param|
         print "#{param} [#{db_params[param]}]: "
         value = STDIN.gets.chomp!
         db_params[param] = value unless value.blank?
       end
       
       BugzillaMigrate.establish_connection db_params
       BugzillaMigrate.migrate
     end
     
 end
 end

このスクリプトを、redmine/lib/task/migrate_from_bugzilla.rakeとして保存し、

rake redmine:migrate_from_bugzilla RAILS_ENV="production"

とやるとインポート作業を行ってくれる。ただし、移行中にエラーが発生したら、データベースを最初の状態に戻してから、再度実行すること。そうしないと、データベースの一部の値が変な状態になる(Issue IDの初期値とか)。

あと、このスクリプトでは、パスワードを移行できないので、すべてのユーザーのパスワードは、デフォルトで、bugzillaになっている。ご注意。