Script to Quickly Setup WebApp Environment and Domain

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.

Leave a Reply

Your email address will not be published. Required fields are marked *

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>