A A

Script to Quickly Setup WebApp Environment and Domain

Sat, Oct 11, 2008

Automation, Code, Linux, Ruby

Just sharing a script I wrote to quickly deploy Wordpress (and eventually a few other webapps) sites, which somebody might find useful. This uses Linode’s API* to add the domain name to the DNS server along with some subdomains. If you’re using another server, (Slicehost, your own, etc), you can alter the dns class to use that API, or just ignore the DNS stuff completely; Its optional.

This will be updated periodically as I refactor and add support for more apps (notably Joomla and Clipshare – though this would violate their terms unless you have the unlimited license). This was written primarily because I couldn’t stand setting up another vhost and Wordpress installation. There are plenty of existing deployers but I plan on adding very specific features and tweaking this for in-house work. I also wanted to try Rio (Ruby-IO). GPL license. Go nuts.

* As of 10/11, the apicore.rb file on the site has some syntactic errors in the domainResourceSave method. I sent an email out to the author about it. Problems aren’t major. You can get my apicore.rb here.

This won’t run unless you create the appropriate folder structure in /etc/mksite/. I’ll get going on this in a bit. See the code below:

#!/usr/bin/env ruby 
 
########################################################
# Isam M. 
# http://biodegradablegeek.com
# MKSITE.RB (0.2) Last updated Oct 19th, 2008
#
# mksite makes it quicker to setup sites and web
# apps by doing most of the tedious work.
#
#
#                     UNSTABLE
#           Run it in your imagination,
#              not on your system!
#
#
######################################################## 
 
require 'rubygems'
require 'rio'
require 'yaml'
require 'mysql'
require 'highline/import'
HighLine.track_eof = false
 
begin
  require 'apicore'
rescue LoadError
  puts "NOTICE: Unable to load apicore.rb - domains will not be added automatically"
end
 
Apache_sites = '/etc/apache2/sites-available/'
Subdomains = ['', 'www', 'mail', 'blog', "dev#{(rand*10).floor}"]
Testing = false
Homedir = ENV['HOME']
Username = ENV['USER']
Applications = [:Skeleton, :Clipshare, :Joomla, :PHPMotion, 'PmWiki (N/A)', :Wordpress]
$config = nil
$log = nil
 
def say(txt)
  super txt
#  $log << txt if $log
end
 
# flash('message', :notice || :error) to output msg
def flash(msg, message_type = :notice)
 if message_type.eql? :notice
    say("INFORMATION: #{msg}")
  elsif message_type.eql? :emphasize
    say("***************************************************")
    say("NOTICE: #{msg}n")
    say("***************************************************")
  elsif message_type.eql? :error
    STDERR.puts("FATAL ERROR: #{msg}n")
  end
end
 
# DZone 2111
def genAlpha(size=64)
  s=''
  size.times {
    s << (i = Kernel.rand(62); i += ((i < 10) ? 48 : ((i < 36) ? 55 : 61 ))).chr
  }
  s
end
 
if defined? ApiCore
  class DNSAPI < ApiCore
    # This depends on the Linode API Ruby bindings (apicore.rb)
    def initialize(key, debug=false, batching=false)
      super
      @batching=false
    end
 
    # Add domain. Return ID on success, nil on failure
    def addDomain domain
      return nil if !domain
      params = {}
      params[:DomainID] = 0
      params[:Domain] = domain
      params[:Type] = 'master'
      params[:Status] = 1
      params[:SOA_Email] = getVal('email') || ask("Enter SOA email for the domain: ")
      domainSave params
    end
 
    def addDomainResource(domain, resource, target, record_type = 'A')
      return nil if (did = getDomainIdByName domain).nil?
      params = {}
      params[:ResourceID] = 0
      params[:DomainID] = did
      params[:Name] = resource
      params[:Type] = record_type
      params[:Target] = target
      domainResourceSave params
    end
 
    def getDomainIdByName domain
      domainList.find do |dom|
        return dom["DOMAINID"] if dom["DOMAIN"].downcase == domain.downcase
      end
    end
  end
else
  flash('ApiCore not loaded. Skipping DNS stuff', :notice)
end
 
class App
  class << self; attr_reader :message, :name, :version, :description, :vhost; end
  @message = nil
  @name = nil
  @version = nil
  @description = 'Just Another Web App'
  @vhost = 'generic'
 
  def initialize(rootdir, domain, db)
    flash("Initializing application (dir=#{rootdir}, domain=#{domain})")
    @rootdir = (rootdir[-1].chr.eql? '/') ? rootdir.chop : rootdir
    @domain = domain
    @db = db # hash of database info 
 
    # System stuff
    #if Testing
      #@templates = '/home/kiwi/Code/mksite/templates'
      #@configs = '/home/kiwi/Code/mksite/configs'
      #@vhosts = '/home/kiwi/Code/mksite/vhosts'
    #else
      @templates = '/etc/mksite/templates'
      @configs = '/etc/mksite/configs'
      @vhosts = '/etc/mksite/vhosts'
    #end
  end
 
  # This generally does not need to be overriden.
  # It does 'generic shit' like creating the rootdir
  # and setting permissions
  def envSetup
    if !rio(@rootdir).exist?
      flash("Creating directory #{@rootdir}")
      rio(@rootdir).mkpath
    end
 
    rio(@rootdir).chdir do |root|
      # Copy the generic public/private/log apache
      # structure to rootdir
      flash("Changing working dir to #{@rootdir}")
      flash("Working inside '#{root.to_s}'")
      rio(@templates,'skeleton.www').each { |df|
        # Overwrite existing files? .. yes.
        #while rio(root, df).exist? do
        df > root
        #flash("Copied #{df.to_s} to #{root.to_s}")
      } 
 
      # Set permissions (a+w on logs, etc)
      flash('Setting permissions...')
      flash('666 ./log/*.log')
      rio('./log/access.log').chmod(00666)
      rio('./log/error.log').chmod(00666)
      flash('700 ./private')
      rio('./private/').chmod(00700)
    end
  end
 
  def setup
    # envSetup()
    # databaseSetup()
    raise 'OVERRIDE ME'
    #flash("setup() template function invoked. 'OVERRIDE ME'", :log)
  end
 
  def databaseSetup
    # Create database if it doesn't already exist
    # This usually doesn't need to be overriden
    #Mysql.server_connect(@db['name'])
    #  flash('Checking database connection... ')
    begin
      dbo = Mysql.real_connect(@db['host'], @db['user'], @db['pass'])
      flash("Creating database '#{@db['name']}'");
      res = dbo.query("CREATE DATABASE IF NOT EXISTS #{@db['name']};")
      flash("Database server returned #{res}") if res
    rescue Mysql::Error => err
      flash('Unable to connect to access/create database', :error)
      flash("Error returned (#{err.errno}) = '#{err.error}'", :error)
      exit 1
    ensure
      dbo.close if dbo
    end
  end
 
  def dnsSetup
    return if !defined? ApiCore
 
    # Setup DNS - currently uses Linode API
    flash("Setting up DNS for '#{@domain}'")
    flash("This requires a Linode API keynLogin to linode.com and find it under 'My Profile'", :emphasize)
    dns = nil
    api_key = getVal('apikey')
    loop do
      api_key = ask('Paste your Linode API key (or 'skip'): ') if api_key.nil? || api_key.empty?
      break if api_key.downcase.eql? 'skip'
 
      # Check API key
      begin
        dns = DNSAPI.new api_key
        dns.domainList
        break
      rescue RuntimeError
        flash("API key invalid (or service down?). Learn to paste and try again.n")
        api_key = nil
      end
    end
 
    unless api_key.nil? or api_key.downcase.eql? 'skip'
      flash("Adding master domain '#{@domain}'")
      begin
        begin
          dns.addDomain(@domain)
        rescue RuntimeError
          flash('Unable to add domain (exists already?). Attempting to add subdomains...', :error)
        end
 
        server = getVal('server') ||
                   ask("Enter IP subdomains should point to (or 'skip'): ") { |q| q.default = 'skip' } 
 
        unless server.downcase.eql? 'skip'
          Subdomains.each do |sub|
            flash("Adding subdomain '#{sub}.#{@domain}' (points to #{server})")
            flash('Could not add subdomain', :error) if !dns.addDomainResource(@domain, sub, server)
          end
        end
     rescue
       raise
       flash("Unable to add domain/subdomain. Skipping", :error)
     end
    end
  end
 
  def serverSetup
    # Set Apache vhost
    vhost_file = (defined? self.class.vhost) ? self.class.vhost : 'generic'
    flash("Generating vhost file (#{@vhosts}/#{vhost_file}) for Apache 2.x")
    vhost = ''
    email = getVal('email') || ask("Enter a valid email for tech support: ")
    rio(@vhosts, vhost_file) > vhost
    vhost.gsub!('_MKS_DOMAIN_', @domain)
    vhost.gsub!('_MKS_EMAIL_', email)
    vhost.gsub!('_MKS_ROOT_', "#{@rootdir}")
    vhost.gsub!('_MKS_PUBLIC_', "#{@rootdir}/public")
    #rio(Testing ? '/tmp/' : Apache_sites, @domain).puts(vhost)
    rio('/tmp/', @domain).puts(vhost) 
 
    # Your enemies should not read this
    rio('/tmp/', @domain).chmod(00600)
    flash("vhost file has been generated as /tmp/#{@domain}n
          It is YOUR responsibility to move this to #{Apache_sites}n
          Site will not work until you 'a2ensite && apache2ctl restart'", :emphasize)
  end
 
  def postInstall()
    flash("Finished!", :emphasize)
  end
end
 
class Skeleton < App
  @name = 'Skeleton'
  @description = 'Generic WWW directory structure'
  @message = ''
  @vhost = 'generic'
 
  def setup
    self.envSetup()
    self.dnsSetup()
    self.serverSetup()
  end
end
 
class Wordpress < App
  @name = 'Wordpress'
  @version = '2.6.2'
  @description = 'A popular blogging platform'
  @message = 'Trying to make monies on the Internets?'
  @vhost = 'generic'
 
  def setup
    # This sets up the initial environment / permissions
    self.envSetup() 
 
    # Copy the wordpress skeleton directory to the new dir
    wproot = rio(@rootdir,'public')
    flash("Wordpress root will be #{wproot}")
    flash('Copying Wordpress data over... (may take awhile)')
    rio(@templates,'wordpress').each { |df| df > wproot } 
 
    rio(wproot).chdir do
      rio('wp-config-sample.php').rm()
      # Generate and output wp config
      flash('Generating wp-config.php based on your DB settings...')
      wpcfg = rio(@configs, 'wordpress.cfg')
      if !wpcfg.exist? || !wpcfg.readable?
        flash("Wordpress config template missing or unreadable, quitting", :error)
        exit 2
      end
 
      # Copy the config into a string, do things with it and then write it to disk
      config = ''
      rio(@configs, 'wordpress.cfg') > config
      flash("Warning: config file '#{@configs}/wordpress.cfg' is empty") if config.empty?
      config['_MKS_DB_HOST_'] = @db['host']
      config['_MKS_DB_USER_'] = @db['user']
      config['_MKS_DB_PASS_'] = @db['pass']
      config['_MKS_DB_NAME_'] = @db['name']
      config.gsub!('_MKS_SECRET_', genAlpha())
      flash("Writing #{wpcfg} data")
      rio('wp-config.php').w!.puts(config)
    end
 
    # Setup the database
    self.databaseSetup() 
 
    # Add DNS info
    self.dnsSetup() 
 
    # Setup the server/vhost
    self.serverSetup()
  end
end
 
class Clipshare < App
  def setup
    raise 'Clipshare support is currently not available. sowwie'
  end
end
 
class Joomla < App
  @name = 'Joomla'
  @version = '1.5.7'
  @description = 'A widely used Content Management System'
  @message = ''
  @vhost = 'generic'
 
  def setup
    # This sets up the initial environment / permissions
    self.envSetup() 
 
    # Copy the wordpress skeleton directory to the new dir
    flash('Copying Joomla data over... (may take awhile)')
    jooroot = rio(@rootdir,'public')
    flash("Joomla root will be #{jooroot}")
    rio(@templates,'joomla-1.5.7').each { |df| df > jooroot } 
 
    rio(jooroot).chdir do
      flash('Removing stock Joomla config...')
      rio('configuration.php-dist').rm()
 
      # Generate and output joomla config
      flash('Generating configuration.php based on provided settings...')
      joocfg = rio(@configs, 'joomla.cfg')
      if !joocfg.exist? || !joocfg.readable?
        flash("Joomla config template missing or unreadable, quitting", :error)
        exit 2
      end
 
      # Copy the config into a string, do things with it and then write it to disk
      config = ''
      rio(@configs, 'joomla.cfg') > config
      flash("Warning: config file '#{@configs}/joomla.cfg' is empty") if config.empty?
      config['_MKS_DB_HOST_'] = @db['host']
      config['_MKS_DB_USER_'] = @db['user']
      config['_MKS_DB_PASS_'] = @db['pass']
      config['_MKS_DB_NAME_'] = @db['name']
      config.gsub!('_MKS_DOMAIN_', @domain)
      config.gsub!('_MKS_SECRET_', genAlpha()) 
 
      flash("Writing ./configuration.php")
      rio('configuration.php').w!.puts(config) 
 
      flash("Making configuration.php world writable (0666)")
      rio('configuration.php').chmod(0666)
    end
 
    # Setup the database
    self.databaseSetup() 
 
    # Add DNS info
    self.dnsSetup() 
 
    # Setup the server/vhost
    self.serverSetup()
  end
 
  def postInstall()
    flash('Joomla is ready to be setup using the web interface', :emphasize)
    flash("Go to #{@domain} where the Joomla! web based installer will
           guide you through the rest of the installation")
    flash("Here's the database information:n
           USERNAME: #{@db['user']}n
           DB HOST : #{@db['host']}n
           DB NAME : #{@db['name']}") 
 
    flash("nAdmin panel is located @ #{@domain}/administrator ")
    flash("You can log into Admin using the username 'admin' along with the
    password that was generated or you chose during the web based install.")
    super
  end
end
 
class PHPMotion < App
  def setup
    raise 'PHPMotion support is currently not available. sowwie'
  end
end
 
class Log
  def initialize(filename="/tmp/#{Username}_mksite.log")
    @log = rio(filename)
    n=0; @log = rio("#{filename}.#{n+=1}") while @log.exist?
    @log.puts("# GENERATED BY MKS - BEGAN @ #{Time.now.to_i}")
    @log.chmod(00600)
    flash("Log file generated (important): #{@log.to_s} (no worries, set to 600)", :emphasize)
    return @log
  end
 
  # append to log
  def <<(data)
    #super <<(data)
    @log.puts(data)
    @log.puts("n")
    #@log << data << "n"
  end
end
 
def loadConfig()
  cfgpath = "#{ENV['HOME']}/.mksite"
  return YAML.load(rio(cfgpath).read()) if rio(cfgpath).exist?
  nil
end
 
def getVal(key, default=nil)
  return $config[key] if $config and defined? $config[key]
  default
end
 
def main
  exit 1 if 'root'==ENV['USER']
  if ($config = loadConfig())
    flash('~/.mksite config loaded')
  else
    flash('~/.mksite not found. It's fine, I'll annoy you with questions.')
  end
 
  puts $config.inspect if Testing
  flash('Press ^C (CTRL+C) at any time quit', :emphasize)
  # Let's ask neutral questions about the new site.
  if Testing
    rootdir = '/tmp/sandbox9/'
    domain = 'domain.cxm'
  else
    domain = ask('Enter site's domain name (no http:// or www): ') #{ |d| d.validate = !! /^www.|^http:/// }
    rootdir = ask('Root site directory (leave blank for default): ') { |q|
      q.default = "/home/#{Username}/www/#{domain}/"
      q.validate = /^/home/#{Username}//
    }
  end
 
  flash("Domain has been set to "#{domain}"")
  #$log = Log.new("/tmp/mks_#{domain}.log") 
 
  flash("Site will reside in "#{rootdir}", and the index/script")
  flash(" files (index.php, .htaccess etc) will go in "#{rootdir}/public/"nn")
 
  # Ask the user what app she wants to install and specific questions about that app
  app = nil
  choose do |menu|
    menu.prompt = 'Choose the software for your new site: '
    Applications.each do |app|
      menu.choice app do |a|
        app = a
      end
    end
  end
 
  flash("You chose #{app}. #{Kernel.const_get(app).message}n") 
 
  # Fetch DB info if this app needs a database
  # XXX Should just have a db flag in the App's class
  db = nil
  unless app.eql? :Skeleton
    flash('This app requires a database (only MySQL supported)')
    flash('It will be created if it doesn't exist')
 
    db = {}
    db['host'] = 'localhost'
    loop do
      if Testing
        db['user'] = 'kiwi'
        db['pass'] = ''
        db['name'] = 'kiwi_sandbox9'
      else
        db['user'] = getVal('db_user') || ask('Enter MySQL username: ')
        db['pass'] = getVal('db_pass') || ask('Enter MySQL password: ') { |q| q.echo = '*'}
        db['name'] = ask('Enter database name (should begin w/ your 'username_')') { |dbn| dbn.validate = /^#{Username}_/}
        puts db.inspect
      end
      flash("Please double check: mysql://#{db['user']}:#{'*' * (db['pass']).size}@#{db['host']}/#{db['name']}")
      break if Testing || agree('Is this correct?')
      flash("Please re-enter database infon")
    end
  end
 
  # Check permissions and database login
  flash('Doing a preliminary check', :emphasize)
  flash('Checking installation environment')
  # Quit if the directory exists and the user does not want to go ahead
  unless Testing
    exit 1 if rio(rootdir).exist? && !agree("Directory '#{rootdir}' exists! Continue? (Y/N)")
  end
 
  # Is the parent directory available and writable?
  parentdir = rio(rootdir).dirname
  unless parentdir.exist? && parentdir.writable?
    flash("Directory '#{parentdir.to_s}' either doesn't exist or is not writable. Check permissions", :error)
    exit 1
  end
 
  unless db.nil? || false==getVal('db_confirm')
    # Check DB connection - but this does not check if user has any privileges
    flash('Checking database connection')
    begin
      dbo = Mysql.real_connect(db['host'], db['user'], db['pass'])
      flash("Successfully connected to '#{dbo.get_server_info}'")
    rescue Mysql::Error => err
      flash('Unable to connect to database server', :error)
      flash("Error returned (#{err.errno}) = '#{err.error}'", :error)
      exit 1
    ensure
      dbo.close if dbo
    end
  end
 
  # Begin installation
  flash("Word on the server racks is... you're good to go", :emphasize)
  klass = Kernel.const_get(app)
  webapp = klass.new(rootdir, domain, db)
  webapp.setup()
  webapp.postInstall()
end
 
main()

Download the ASCII mksite.rb file here.
Add me. I'm lonely Why not subscribe to the feed?. If you’re on a mobile device I suggest Viigo

Tags: , , , , , , , , , ,

Leave a Reply