Class: RailsInstaller

Inherits:
Object
  • Object
show all
Includes:
FileUtils
Defined in:
lib/rails-installer.rb,
lib/rails-installer/commands.rb,
lib/rails-installer/databases.rb,
lib/rails-installer/web-servers.rb

Overview

Rails Application Installer

An installer for Rails applications.

The Rails Application Installer is designed to make it easy for end-users to install open-source Rails apps with a minimum amount of effort. When built properly, all the user needs to do is run:

$ sudo gem install my_app
$ my_app install /some/path

Users who need to install your .gem but don’t have the right permissions can do this instead:

$ export GEM_PATH=~/gems
$ gem install -i ~gems my_app
$ ~gems/bin/my_app install /some/path

Adding the installer to your application

(This example assumes that your program is called ‘my_app’. Change this to match your application’s actual name)

First, create a small driver program and put it into bin/my_app. Here’s a minimal example:

#!/usr/bin/env ruby

require 'rubygems'
require 'rails-installer'

class AppInstaller < RailsInstaller
  application_name 'my_app'
  support_location 'our shiny website'
  rails_version '1.1.6'
end

# Installer program
directory = ARGV[1]

app = AppInstaller.new(directory)
app.message_proc = Proc.new do |msg|
  STDERR.puts " #{msg}"
end
app.execute_command(*ARGV)

This is a simple example; you can extend the installer to add new commands and install steps. See the examples/ directory for more complex examples.

Second, you’ll need some schema files in db/schema.*.sql. The schema_generator plugin can generate these automatically from your migrations:

$ sudo gem install schema_generator
$ ./script/generate schemas
$ svn add db/schema.*.sql
$ svn add db/schema_version

Third, build a .gem file. Make sure that it depends on the rails-app-installer GEM. Make sure that the generated schema files and the installer that you put in bin/ are included in your .gem, and the gem knows that bin/my_app is supposed to be executable. Here’s an example from Typo’s .gemspec. This may be more complex then you need.

$:.unshift '../lib'
require 'rubygems'
require 'rake'

spec = Gem::Specification.new do |s|
  s.name = "typo"
  s.version = "4.0.2"
  s.summary = "Modern weblog engine."
  s.has_rdoc = false

  s.files = Dir.glob('**/*', File::FNM_DOTMATCH).reject do |f| 
     [ /\.$/, /config\/database.yml$/, /config\/database.yml-/, 
     /database\.sqlite/,
     /\.log$/, /^pkg/, /\.svn/, /^vendor\/rails/, 
     /^public\/(files|xml|articles|pages|index.html)/, 
     /^public\/(stylesheets|javascripts|images)\/theme/, /\~$/, 
     /\/\._/, /\/#/ ].any? {|regex| f =~ regex }
  end
  s.require_path = '.'
  s.author = "Tobias Luetke"
  s.email = "[email protected]"
  s.homepage = "http://www.typosphere.org"  
  s.rubyforge_project = "typo"
  s.platform = Gem::Platform::RUBY 
  s.executables = ['typo']

  s.add_dependency("rails", "= 1.1.6")
  s.add_dependency("rails-app-installer", ">= 0.1.2")
end

if $0==__FILE__
  Gem::manage_gems
  Gem::Builder.new(spec).build
end

Using your .gem

You can test your new gem by running ‘sudo gem install ./my_app-1.0.0.gem’. Once you’re happy with it, upload it to Rubyforge and it’ll automatically be added to the master gem index.

Users should be able to download and install it using the commands at the top of this document.

Non-standard install options

By default, the installer uses SQLite3 and Mongrel when installing your app. The user can override this at install time with some flags. Examples:

# install using SQLite3 and Mongrel
$ my_app install /some/path

# install using MySQL and Mongrel
$ my_app install /some/path database=mysql db_user=my_app db_name=my_app db_host=localhost db_password=password

# install using PostgreSQL and Mongrel
$ my_app install /some/path database=postgresql db_user=my_app db_name=my_app db_host=localhost db_password=password

# install using SQLite3 and mongrel_cluster
$ my_app install /some/path web-server=mongrel_cluster threads=4

Defined Under Namespace

Classes: Command, Database, InstallFailed, WebServer

Constant Summary collapse

@@rails_version =
nil

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(install_directory) ⇒ RailsInstaller

Returns a new instance of RailsInstaller.



166
167
168
169
170
171
172
173
174
# File 'lib/rails-installer.rb', line 166

def initialize(install_directory)
  # use an absolute path, not a relative path.
  if install_directory
    @install_directory = File.expand_path(install_directory)
  end
      
  @config = read_yml(config_file) rescue nil
  @config ||= Hash.new
end

Instance Attribute Details

#configObject

Returns the value of attribute config.



137
138
139
# File 'lib/rails-installer.rb', line 137

def config
  @config
end

#install_directoryObject

Returns the value of attribute install_directory.



137
138
139
# File 'lib/rails-installer.rb', line 137

def install_directory
  @install_directory
end

#message_procObject

Returns the value of attribute message_proc.



138
139
140
# File 'lib/rails-installer.rb', line 138

def message_proc
  @message_proc
end

#source_directoryObject

Returns the value of attribute source_directory.



137
138
139
# File 'lib/rails-installer.rb', line 137

def source_directory
  @source_directory
end

Class Method Details

.application_name(name) ⇒ Object

The application name. Set this in your derived class.



145
146
147
# File 'lib/rails-installer.rb', line 145

def self.application_name(name)
  @@app_name = name
end

.rails_version(svn_tag) ⇒ Object

Which Rails version this app needs. This version of Rails will be frozen into vendor/rails/



157
158
159
# File 'lib/rails-installer.rb', line 157

def self.rails_version(svn_tag)
  @@rails_version = svn_tag
end

.support_location(location) ⇒ Object

The support location. This is displayed to the user at the end of the install process.



151
152
153
# File 'lib/rails-installer.rb', line 151

def self.support_location(location)
  @@support_location = location
end

Instance Method Details

#app_nameObject

The application name, as set by application_name.



162
163
164
# File 'lib/rails-installer.rb', line 162

def app_name
  @@app_name
end

#backup_config_fileObject

The path to the config file that comes with the GEM



494
495
496
# File 'lib/rails-installer.rb', line 494

def backup_config_file
  File.join(source_directory,'installer','rails_installer_defaults.yml')
end

#backup_databaseObject

Backup the database



259
260
261
262
# File 'lib/rails-installer.rb', line 259

def backup_database
  db_class = RailsInstaller::Database.dbs[config['database']]
  db_class.backup(self)
end

#config_fileObject

The path to the installed config file



489
490
491
# File 'lib/rails-installer.rb', line 489

def config_file
  File.join(install_directory,'installer','rails_installer.yml')
end

#copy_filesObject

Copy files from the source directory to the target directory.



273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
# File 'lib/rails-installer.rb', line 273

def copy_files
  message "Checking for existing #{@@app_name.capitalize} install in #{install_directory}"
  files_yml = File.join(install_directory,'installer','files.yml')
  old_files = read_yml(files_yml) rescue Hash.new
  
  message "Reading files from #{source_directory}"
  new_files = sha1_hash_directory_tree(source_directory)
  new_files.delete('/config/database.yml') # Never copy this.
  
  # Next, we compare the original install hash to the current hash.  For each
  # entry:
  #
  # - in new_file but not in old_files: copy
  # - in old files but not in new_files: delete
  # - in both, but hash different: copy
  # - in both, hash same: don't copy
  #
  # We really should add a third hash (existing_files) and compare against that
  # so we don't overwrite changed files.

  added, changed, deleted, same = hash_diff(old_files, new_files)
  
  if added.size > 0
    message "Copying #{added.size} new files into #{install_directory}"
    added.keys.sort.each do |file|
      message " copying #{file}"
      copy_one_file(file)
    end
  end
  
  if changed.size > 0
    message "Updating #{changed.size} files in #{install_directory}"
    changed.keys.sort.each do |file|
      message " updating #{file}"
      copy_one_file(file)
    end
  end
  
  if deleted.size > 0
    message "Deleting #{deleted.size} files from #{install_directory}"
    
    deleted.keys.sort.each do |file|
      message " deleting #{file}"
      rm(File.join(install_directory,file)) rescue nil
    end
  end
  
  write_yml(files_yml,new_files)
end

#copy_gem(spec, destination) ⇒ Object

Copy a specific gem’s contents.



437
438
439
440
# File 'lib/rails-installer.rb', line 437

def copy_gem(spec, destination)
  message("copying #{spec.name} #{spec.version} to #{destination}")
  cp_r("#{spec.full_gem_path}/.",destination)
end

#copy_one_file(filename) ⇒ Object

Copy one file from source_directory to install_directory, creating directories as needed.



325
326
327
328
329
330
331
332
# File 'lib/rails-installer.rb', line 325

def copy_one_file(filename)
  source_name = File.join(source_directory,filename)
  install_name = File.join(install_directory,filename)
  dir_name = File.dirname(install_name)
  
  mkdir_p(dir_name)
  cp(source_name,install_name,:preserve => true)
end

#create_default_config_filesObject

Create all default config files



443
444
445
# File 'lib/rails-installer.rb', line 443

def create_default_config_files
  create_default_database_yml
end

#create_default_database_ymlObject

Create the default database.yml



448
449
450
451
# File 'lib/rails-installer.rb', line 448

def create_default_database_yml
  db_class = RailsInstaller::Database.dbs[config['database']]
  db_class.database_yml(self)
end

#create_directoriesObject

Create required directories, like tmp



464
465
466
467
468
469
470
471
472
473
# File 'lib/rails-installer.rb', line 464

def create_directories
  mkdir_p(File.join(install_directory,'tmp','cache'))
  chmod(0755, File.join(install_directory,'tmp','cache'))
  mkdir_p(File.join(install_directory,'tmp','session'))
  mkdir_p(File.join(install_directory,'tmp','sockets'))
  mkdir_p(File.join(install_directory,'log'))
  File.open(File.join(install_directory,'log','development.log'),'w')
  File.open(File.join(install_directory,'log','production.log'),'w')
  File.open(File.join(install_directory,'log','testing.log'),'w')
end

#create_initial_databaseObject

Create the initial SQLite database



476
477
478
479
480
481
# File 'lib/rails-installer.rb', line 476

def create_initial_database
  db_class = RailsInstaller::Database.dbs[config['database']]
  in_directory(install_directory) do
    db_class.create(self)
  end
end

#display_help(error = nil) ⇒ Object

Display help.



654
655
656
657
658
659
660
661
662
663
664
665
666
# File 'lib/rails-installer.rb', line 654

def display_help(error=nil)
  STDERR.puts error if error
  
  commands = Command.commands.keys.sort
  commands.each do |cmd|
    cmd_class = Command.commands[cmd]
    flag_help = cmd_class.flag_help_text.gsub(/APPNAME/,app_name)
    help = cmd_class.help_text.gsub(/APPNAME/,app_name)
    
    STDERR.puts "  #{app_name} #{cmd} DIRECTORY #{flag_help}"
    STDERR.puts "    #{help}"
  end
end

#execute_command(*args) ⇒ Object

Execute a command-line command



637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
# File 'lib/rails-installer.rb', line 637

def execute_command(*args)
  if args.size < 2
    display_help
    exit(1)
  end
  
  command_class = Command.commands[args.first]
  
  if command_class
    command_class.command(self,*(args[2..-1]))
  else
    display_help
    exit(1)
  end
end

#expand_template_filesObject

Expand configuration template files.



618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
# File 'lib/rails-installer.rb', line 618

def expand_template_files
  rails_host = config['bind-address'] || `hostname`.chomp
  rails_port = config['port-number'].to_s
  rails_url = "http://#{rails_host}:#{rails_port}"
  Dir[File.join(install_directory,'installer','*.template')].each do |template_file|
    output_file = template_file.gsub(/\.template/,'')
    next if File.exists?(output_file) # don't overwrite files

    message "expanding #{File.basename(output_file)} template"
    
    text = File.read(template_file).gsub(/\$RAILS_URL/,rails_url).gsub(/\$RAILS_HOST/,rails_host).gsub(/\$RAILS_PORT/,rails_port)
    
    File.open(output_file,'w') do |f|
      f.write text
    end
  end
end

#find_source_directory(gem_name, version = nil) ⇒ Object

Locate the source directory for a specific Version



586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
# File 'lib/rails-installer.rb', line 586

def find_source_directory(gem_name, version=nil)
  if version == 'cwd'
    return Dir.pwd
  elsif version
    version_array = ["= #{version}"]
  else
    version_array = ["> 0.0.0"]
  end
  
  specs = Gem.source_index.find_name(gem_name,version_array)
  unless specs.to_a.size > 0
    raise InstallFailed, "Can't locate version #{version}!"
  end
  
  @install_version = specs.last.version
  message "Installing #{app_name} #{@install_version}"
  
  specs.last.full_gem_path
end

#fix_permissionsObject

Clean up file and directory permissions.



454
455
456
457
458
459
460
461
# File 'lib/rails-installer.rb', line 454

def fix_permissions
  unless RUBY_PLATFORM =~ /mswin32/
    message "Making scripts executable"
    chmod 0555, File.join(install_directory,'public','dispatch.fcgi')
    chmod 0555, File.join(install_directory,'public','dispatch.cgi')
    chmod 0555, Dir[File.join(install_directory,'script','*')]
  end
end

#freeze_railsObject

Freeze to a specific version of Rails gems.



387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
# File 'lib/rails-installer.rb', line 387

def freeze_rails
  new_version = rails_version_to_freeze

  version_file = File.join(install_directory,'vendor','rails-version')
  vendor_rails = File.join(install_directory,'vendor','rails')
  
  old_version = File.read(version_file).chomp rescue nil
  
  if new_version == old_version
    return
  elsif old_version != nil
    rm_rf(vendor_rails)
  end

  mkdir_p(vendor_rails)
  
  package_map = {
    'rails' => File.join(vendor_rails,'railties'),
    'actionmailer' => File.join(vendor_rails,'actionmailer'),
    'actionpack' => File.join(vendor_rails,'actionpack'),
    'actionwebservice' => File.join(vendor_rails,'actionwebservice'),
    'activerecord' => File.join(vendor_rails,'activerecord'),
    'activesupport' => File.join(vendor_rails,'activesupport'),
  }
  
  specs = Gem.source_index.find_name('rails',[new_version])
  
  unless specs.to_a.size > 0
    raise InstallFailed, "Can't locate Rails #{new_version}!"
  end
  
  copy_gem(specs.first, package_map[specs.first.name])
  
  specs.first.dependencies.each do |dep|
    next unless package_map[dep.name]
    
    dep_spec = Gem.source_index.find_name(dep.name,[dep.version_requirements.to_s])
    if dep_spec.size == 0
      raise InstallFailed, "Can't locate dependency #{dep.name} #{dep.version_requirements.to_s}"
    end
    
    copy_gem(dep_spec.first, package_map[dep.name])
  end
  
  File.open(version_file,'w') do |f|
    f.puts @@rails_version
  end
end

#get_schema_versionObject

Get the current schema version



484
485
486
# File 'lib/rails-installer.rb', line 484

def get_schema_version
  File.read(File.join(install_directory,'db','schema_version')).to_i rescue 0
end

#hash_diff(a, b) ⇒ Object

Compute the different between two hashes. Returns four hashes, one contains the keys that are in ‘b’ but not in ‘a’ (added entries), the next contains keys that are in ‘a’ and ‘b’, but have different values (changed). The third contains keys that are in ‘b’ but not ‘a’ (added). The final hash contains items that are the same in each.



339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
# File 'lib/rails-installer.rb', line 339

def hash_diff(a, b)
  added = {}
  changed = {}
  deleted = {}
  same = {}
  
  seen = {}
  
  a.each_key do |k|
    seen[k] = true
    
    if b.has_key? k
      if b[k] == a[k]
        same[k] = true
      else
        changed[k] = true
      end
    else
      deleted[k] = true
    end
  end
  
  b.each_key do |k|
    unless seen[k]
      added[k] = true
    end
  end
  
  [added, changed, deleted, same]
end

#install(version = nil) ⇒ Object

Install Application



186
187
188
189
190
191
192
193
194
195
196
197
198
# File 'lib/rails-installer.rb', line 186

def install(version=nil)
  @source_directory = find_source_directory(@@app_name,version)

  # Merge default configuration settings
  @config = read_yml(backup_config_file).merge(config)
  
  install_sequence
  
  message ''
  message "#{@@app_name.capitalize} is now running on http://#{`hostname`.chomp}:#{config['port-number']}"
  message "Use '#{@@app_name} start #{install_directory}' to restart after boot."
  message "Look in installer/*.conf.example to see how to integrate with your web server."
end

#install_post_hookObject

Another install hook; install_post_hook runs after the final migration.



233
234
# File 'lib/rails-installer.rb', line 233

def install_post_hook
end

#install_pre_hookObject

The easy way to add steps to the installation process. install_pre_hook runs right after the DB is backed up and right before the first migration attempt.



229
230
# File 'lib/rails-installer.rb', line 229

def install_pre_hook
end

#install_sequenceObject

The default install sequence. Override this if you need to add extra steps to the installer.



202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
# File 'lib/rails-installer.rb', line 202

def install_sequence
  stop
  
  backup_database
  install_pre_hook
  pre_migrate_database
  copy_files
  freeze_rails
  create_default_config_files
  fix_permissions
  create_directories
  create_initial_database
  set_initial_port_number
  expand_template_files
  
  migrate
  install_post_hook
  save
  
  run_rails_tests
  
  start
end

#is_valid_rails_directory?Boolean

Returns:

  • (Boolean)


668
669
670
671
672
673
674
675
676
677
678
# File 'lib/rails-installer.rb', line 668

def is_valid_rails_directory?
  valid = false
  in_directory install_directory do
    valid = File.exists?('config/database.yml') and
      File.directory?('app') and
      File.directory?('app/models') and
      File.directory?('vendor')
  end

  valid
end

#message(string) ⇒ Object

Display a status message



177
178
179
180
181
182
183
# File 'lib/rails-installer.rb', line 177

def message(string)
  if message_proc
    message_proc.call(string)
  else
    STDERR.puts string
  end
end

#migrateObject

Migrate the database



525
526
527
528
529
530
531
532
533
# File 'lib/rails-installer.rb', line 525

def migrate
  message "Migrating #{@@app_name.capitalize}'s database to newest release"
  
  in_directory install_directory do
    unless system("rake -s migrate")
      raise InstallFailed, "Migration failed"
    end
  end
end

#pre_migrate_databaseObject

Pre-migrate the database. This checks to see if we’re downgrading to an earlier version of our app, and runs ‘rake migrate VERSION=…’ to downgrade the database.



506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
# File 'lib/rails-installer.rb', line 506

def pre_migrate_database
  old_schema_version = get_schema_version
  new_schema_version = File.read(File.join(source_directory,'db','schema_version')).to_i
  
  return unless old_schema_version > 0
   
  # Are we downgrading?
  if old_schema_version > new_schema_version
    message "Downgrading schema from #{old_schema_version} to #{new_schema_version}"
    
    in_directory install_directory do
      unless system("rake -s migrate VERSION=#{new_schema_version}")
        raise InstallFailed, "Downgrade migrating from #{old_schema_version} to #{new_schema_version} failed."
      end
    end
  end
end

#rails_version_to_freezeObject

Decide which Rails version to freeze



371
372
373
374
375
376
377
378
379
380
381
382
383
384
# File 'lib/rails-installer.rb', line 371

def rails_version_to_freeze
  if @@rails_version
    return "= #{@@rails_version}"
  else
    specs = Gem.source_index.find_name(app_name,["= #{@install_version}"])
    unless specs.to_a.size > 0
      raise InstallFailed, "Can't locate version #{@install_version.first}!"
    end
        
    rails_dep = specs.first.dependencies.detect {|a| a.name=='rails'}
    version = rails_dep.version_requirements.to_s
    return version
  end
end

#read_yml(filename) ⇒ Object

Load a yaml file



574
575
576
# File 'lib/rails-installer.rb', line 574

def read_yml(filename)
  YAML.load(File.read(filename))
end

#restore_database(filename) ⇒ Object

Restore the database



265
266
267
268
269
270
# File 'lib/rails-installer.rb', line 265

def restore_database(filename)
  db_class = RailsInstaller::Database.dbs[config['database']]
  in_directory install_directory do
    db_class.restore(self, filename)
  end
end

#run_rails_testsObject

Run Rails tests. This helps verify that we have a clean install with all dependencies. This cuts down on a lot of bug reports.



537
538
539
540
541
542
543
544
545
546
547
548
549
550
# File 'lib/rails-installer.rb', line 537

def run_rails_tests
  message "Running tests.  This may take a minute or two"
  
  in_directory install_directory do
    if system_silently("rake -s test")
      message "All tests pass.  Congratulations."
    else
      message "***** Tests failed *****"
      message "** Please run 'rake test' by hand in your install directory."
      message "** Report problems to #{@@support_location}."
      message "***** Tests failed *****"
    end
  end
end

#saveObject

Save config settings



569
570
571
# File 'lib/rails-installer.rb', line 569

def save
  write_yml(config_file,@config)
end

#set_initial_port_numberObject

Pick a default port number



499
500
501
# File 'lib/rails-installer.rb', line 499

def set_initial_port_number
  config['port-number'] ||= (rand(1000)+4000)
end

#sha1_hash_directory_tree(directory, prefix = '', hash = {}) ⇒ Object

Find all files in a directory tree and return a Hash containing sha1 hashes of all files.



554
555
556
557
558
559
560
561
562
563
564
565
566
# File 'lib/rails-installer.rb', line 554

def sha1_hash_directory_tree(directory, prefix='', hash={})
  Dir.entries(directory).each do |file|
    next if file =~ /^\./
    pathname = File.join(directory,file)
    if File.directory?(pathname)
      sha1_hash_directory_tree(pathname, File.join(prefix,file), hash)
    else
      hash[File.join(prefix,file)] = Digest::SHA1.hexdigest(File.read(pathname))
    end
  end
  
  hash
end

#start(foreground = false) ⇒ Object

Start application in the background



237
238
239
240
241
242
243
244
# File 'lib/rails-installer.rb', line 237

def start(foreground = false)
  server_class = RailsInstaller::WebServer.servers[config['web-server']]
  if not server_class
    message "** warning: web-server #{config['web-server']} unknown.  Use 'web-server=external' to disable."
  end
  
  server_class.start(self,foreground)
end

#stopObject

Stop application



247
248
249
250
251
252
253
254
255
256
# File 'lib/rails-installer.rb', line 247

def stop
  return unless File.directory?(install_directory)
  
  server_class = RailsInstaller::WebServer.servers[config['web-server']]
  if not server_class
    message "** warning: web-server #{config['web-server']} unknown.  Use 'web-server=external' to disable."
  end
  
  server_class.stop(self)
end

#system_silently(command) ⇒ Object

Call system, ignoring all output.



607
608
609
610
611
612
613
614
615
# File 'lib/rails-installer.rb', line 607

def system_silently(command)
  if RUBY_PLATFORM =~ /mswin32/
    null = 'NUL:'
  else
    null = '/dev/null'
  end
  
  system("#{command} > #{null} 2> #{null}")
end

#write_yml(filename, object) ⇒ Object

Save a yaml file.



579
580
581
582
583
# File 'lib/rails-installer.rb', line 579

def write_yml(filename,object)
  File.open(filename,'w') do |f|
    f.write(YAML.dump(object))
  end
end