app/models/app.rb
# Amahi Home Server
# Copyright (C) 2007-2013 Amahi
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License v3
# (29 June 2007), as published in the COPYING file.
#
# 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
# file COPYING for more details.
#
# You should have received a copy of the GNU General Public
# License along with this program; if not, write to the Amahi
# team at http://www.amahi.org/ under "Contact Us."
require 'tempfile'
require 'digest/md5'
require 'amahi_api'
require 'command'
require 'downloader'
require 'system_utils'
require 'container'
require 'docker'
class App < ApplicationRecord
# App and Log storage path is different for both production and development environment.
BASE_PORT = 35000
if Rails.env == "production"
APP_PATH = "/var/hda/apps/%s"
WEBAPP_PATH = "/var/hda/web-apps/%s"
INSTALLER_LOG = "/var/log/amahi-app-installer.log"
else # development, test, any other
APP_PATH = "#{HDA_TMP_DIR}/app/%s"
WEBAPP_PATH = "#{HDA_TMP_DIR}/web-apps/%s"
INSTALLER_LOG = "#{HDA_TMP_DIR}/amahi-app-installer.log"
end
belongs_to :webapp, :dependent => :destroy, optional: true
belongs_to :theme, :dependent => :destroy, optional: true
belongs_to :db, :dependent => :destroy, optional: true
belongs_to :server, :dependent => :destroy, optional: true
belongs_to :share, :dependent => :destroy, optional: true
belongs_to :plugin, :dependent => :destroy, optional: true
has_many :app_dependencies, :dependent => :destroy
has_many :children, :class_name => "AppDependency", :foreign_key => 'dependency_id'
has_many :dependencies, :through => :app_dependencies
scope :installed, ->{where(:installed => true)}
scope :in_dashboard,-> {where(:show_in_dashboard => true).installed}
scope :latest_first, ->{order('updated_at desc')}
before_destroy :before_destroy_hook
validates :name, :presence => true
validates :identifier, :presence => true, :uniqueness => true
def initialize(args)
super()
app = args[:app]
if app.nil?
AmahiApi::api_key = Setting.value_by_name("api-key")
app = AmahiApi::App.find(args[:identifier])
end
self.name = app.name
self.screenshot_url = app.screenshot_url
self.identifier = app.id
self.description = app.description
self.app_url = app.url
self.logo_url = app.logo_url
self.status = app.status
self.installed = false
end
# Not used anymore. Instead install-app script directly calls install_bg function.
def install_start
App.transaction do
self.installed = false
self.install_status = 0
self.save!
end
p = Process.fork do
App.transaction { install_bg }
return
end
Process.detach(p)
end
# Not used anymore. Instead install-app script directly calls uninstall_bg function.
def uninstall_start
p = Process.fork do
App.transaction { uninstall_bg }
return
end
Process.detach(p)
end
# This function is used to start background installation of apps
# It is important to start installs in background because installation takes long time
# and a web connection generally times out after a few seconds.
def self.install(identifier)
# first check status
return unless self.check_availability()
self.update_progress(0)
Rails.cache.write("type", "install")
Rails.cache.write("app-id", identifier)
# run the kickoff script
cmd = File.join(Rails.root, "script/install-app --environment=#{Rails.env} #{identifier} >> #{INSTALLER_LOG} 2>&1 &")
if Rails.env == "production"
c = Command.new cmd
c.execute
else
# execute the command directly not in production
system(cmd)
end
end
# This function is used to start background uninstallation of apps
def uninstall
# first check status
return unless App.check_availability()
App.update_progress(100)
Rails.cache.write("type", "uninstall")
Rails.cache.write("app-id", self.identifier)
# run the kickoff script
cmd = File.join(Rails.root, "script/install-app -u --environment=#{Rails.env} #{self.identifier} >> #{INSTALLER_LOG} 2>&1 &")
if Rails.env == "production"
c = Command.new cmd
c.execute
else
# execute the command directly not in production
system(cmd)
end
end
def self.available
AmahiApi::api_key = Setting.value_by_name("api-key")
begin
AmahiApi::App.find(:all).map do |online_app|
App.where(identifier: online_app.id).first ? nil : App.new({identifier: online_app.id, app: online_app})
end.compact
rescue
[]
end
end
def install_message
# NOTE: make sure these messages match the stages below
percent = Rails.cache.read("progress")
App.installation_message percent
end
def self.installation_message(percent)
case percent
when 0 then "Preparing to install ..."
when 10 then "Retrieving application information ..."
when 20 then "Installing app dependencies ..."
when 30 then "Installing package dependencies ..."
when 40 then "Downloading application and unpacking it ..."
when 60 then "Doing application configuration ..."
when 70 then "Creating associated server in your HDA ..."
when 80 then "Saving application settings ..."
when 100 then "Application installed."
when 999 then "Application failed to install (check /var/log/amahi-app-installer.log)."
when 950 then "Another app is getting installed."
else "Application message unknown during install."
end
end
def uninstall_message
return "Another app is getting installed." if Rails.cache.read("app-id") != identifier
# NOTE: make sure these messages match the stages below
case Rails.cache.read("progress")
when 100 then "Preparing to uninstall ..."
when 80 then "Retrieving application information ..."
when 60 then "Running uninstall scripts ..."
when 40 then "Removing application files ..."
when 20 then "Uninstalling application ..."
when 0 then "Application uninstalled."
else "Application message unknown during uninstall."
end
end
def install_status
App.installation_status(self.identifier)
end
def self.installation_status(identifier)
if Rails.cache.read("app-id") == identifier
Rails.cache.read("progress")
else
950
end
end
def install_status=(value)
# create it dynamically if it does not exist
status = Setting.where(:kind=>self.identifier, :name=> 'install_status').first_or_create
if value.nil?
status && status.destroy
return nil
end
status.update_attribute(:value, value.to_s)
value
end
def has_dependents?
children != []
end
# This function does the background installation. It is generally called by the script/install-app file.
# Please don't call this function directly for installation instead use App.install because this function might take a
# lot of time to finish and request can time out.
def install_bg
# Change permissions of docker.sock file
if Rails.env=="production"
cmd = Command.new("chmod 666 /var/run/docker.sock")
cmd.execute
end
initial_path = Dir.pwd
begin
# see the install_message method for the meaning of the messages
App.update_progress(0)
AmahiApi::api_key = Setting.value_by_name("api-key")
App.update_progress(10)
installer = AmahiApi::AppInstaller.find identifier
App.update_progress(20)
self.install_app_deps installer if installer.app_dependencies
App.update_progress(30)
self.install_pkg_deps installer if installer.pkg_dependencies
self.install_pkgs installer if installer.pkg
app_path = APP_PATH % identifier
mkdir app_path
webapp_path = nil
App.update_progress(40)
downloaded_file = nil
unless (installer.source_url.nil? or installer.source_url.blank?)
downloaded_file = Downloader.download_and_check_sha1(installer.source_url, installer.source_sha1)
Dir.chdir(app_path) do
FileUtils.rm_rf "source-file"
File.symlink downloaded_file, "source-file"
end
end
unless installer.url_name.nil?
(name, webapp_path) = self.install_webapp(installer, downloaded_file)
self.show_in_dashboard = true
else
self.show_in_dashboard = false
end
self.build_db(:name => installer.database) if installer.database && !installer.database.blank?
# if it has a share, create it and install it
if installer.share
sh = Share.where(:name=>installer.share).first
if sh
# FIXME: autohook to it. this is for legacy shares. not needed in new installs
self.share = sh
else
c = installer.share.capitalize
p = Share.default_full_path(installer.share)
# FIXME - use a relative path and not harcode the share path here?
self.build_share(:name => c, :path => p, :rdonly => false, :visible => true, :tags => "")
cmd = Command.new("chmod 777 #{p}")
cmd.execute
end
end
App.update_progress(60)
# Create a virtual host file for this app. For more info refer to app/models/webapp.rb
if installer.kind == 'theme'
self.theme = self.install_theme(installer, downloaded_file)
elsif installer.kind == 'plugin'
self.plugin = Plugin.install(installer, downloaded_file)
else
if installer.kind!="PHP5"
unless installer.url_name.blank?
self.build_webapp(:name => name, :path => webapp_path, :deletable => false, :custom_options => installer.webapp_custom_options, :kind => installer.kind)
end
end
end
# workaround : Skip creation of webapp for php5 kind apps
# run the script
initial_user = installer.initial_user
initial_password = installer.initial_password
# If installer.kind=="PHP5"
# Crete a container
# Run the install script inside the container
# Let install script handle the job of image creation
# begin
# if installer.kind=="PHP5"
# puts "Started building image for php5 app"
#
# # TODO: Create an image for this app
# # TODO: Handle failure
# # TODO: In future replace the content inside .build with a Dockerfile fetched from server.
# image = Docker::Image.build("from richarvey/nginx-php-fpm:php5\n WORKDIR /var/www")
# image.tag('repo' => "amahi/#{identifier}", 'force' => true)
# puts image
# end
# rescue => e
# puts e
# self.install_status = 999
# Dir.chdir(initial_path)
# raise e
# end
App.update_progress(70)
# if it has a server, install it and associate it
if installer.server
servername = installer.server
pidfile = nil
if servername =~ /\s*([^\s]+):(.+)/
servername = $1
pidfile = $2 unless $2.empty?
end
self.build_server(:name => servername, :comment => "#{self.name} Server", :pidfile => pidfile)
end
App.update_progress(80)
self.initial_user = installer.initial_user
self.initial_password = installer.initial_password
self.special_instructions = installer.special_instructions
self.version = installer.version || ""
# mark it as installed
self.installed = true
self.save!
if installer.install_script
# if there is an installer script, run it
Dir.chdir(webapp_path ? webapp_path : app_path) do
SystemUtils.run_script(installer.install_script, name, hda_environment(initial_user, initial_password, self.db))
end
end
# Once the app is saved in db then we can get its id and start running the container
# FIXME: Should this be added as an after create hook? But how would we know if its a php5 kind app?
# Should we store an extra field in db to identify the type of application as well?
# Or maybe make an api call inside the after_create?
if installer.kind=="PHP5"
puts "Going to start the container #{self.id}"
options = {
:image => "amahi/#{identifier}",
:volume => webapp_path,
:port => BASE_PORT+self.id
}
container = Container.new(id=identifier, options=options)
container.create
# We skipped creation of webapp earlier so we will create now since we have obtained an id for our app
webapp = Webapp.create(:name => name, :path => webapp_path, :deletable => false, :custom_options => installer.webapp_custom_options, :kind => installer.kind)
# Assign the webapp to the existing app for the workaround to work.
# later on maybe webapp has_one :app relation migt help
self.webapp = webapp
self.save!
# For php5 kind webapp default webapp creation method is skipped for the workaround to work and hence this.
webapp.create_php5_vhost
end
App.update_progress(100)
Dir.chdir(initial_path)
rescue Exception => e
App.update_progress(999)
Dir.chdir(initial_path)
raise e
end
end
def uninstall_bg
if Rails.env=="production"
cmd = Command.new("chmod 666 /var/run/docker.sock")
cmd.execute
end
# TODO: Write uninstallation case for php5 apps.
app_path = APP_PATH % identifier
begin
App.update_progress(100)
AmahiApi::api_key = Setting.value_by_name("api-key")
App.update_progress(80)
uninstaller = AmahiApi::AppUninstaller.find(identifier)
# Have to get the installer as well to get the app kind
installer = AmahiApi::AppInstaller.find identifier
# FIXME : How to do this with a single api call?
if uninstaller
# execute the uninstall script
App.update_progress(60)
if uninstaller.uninstall_script
if self.webapp && self.webapp.path
Dir.chdir(self.webapp.path) do
SystemUtils.run_script(uninstaller.uninstall_script, name, hda_environment)
end
else
Dir.chdir(app_path) do
SystemUtils.run_script(uninstaller.uninstall_script, name, hda_environment)
end
end
end
App.update_progress(20)
self.uninstall_pkgs uninstaller if uninstaller.pkg
# else
# FIXME - retry? what if an app is not
# live at this time??
end
# FIXME - what happens if this throws an exception?
if installer.kind=="PHP5"
# This one extra step is required to stop and remove the container
container = Container.new(id=identifier)
container.remove
end
# FIXME - set to nil to destroy??
App.update_progress(0)
self.destroy
FileUtils.rm_rf app_path
rescue Exception => e
App.update_progress(999)
FileUtils.rm_rf app_path
raise e
end
end
def theme?
theme_id != nil
end
def full_url
webapp ? webapp.full_url : "http://#{app_url}.#{Setting.value_by_name('domain')}"
end
def testing?
status == 'testing'
end
def live?
status == 'live'
end
def remote_url
webapp ? "http://#{webapp.name}" : ''
end
def self.update_progress(progress)
Rails.cache.write("progress", progress, expires_in: 59.minutes)
end
# check if app can begin installation or uninstallation
def self.check_availability
progress = Rails.cache.read("progress")
return true if progress.blank?
return true if progress == 999
type = Rails.cache.read("type")
if type == "install" and progress == 100
return true
elsif type == "uninstall" and progress == 0
return true
else
return false
end
end
protected
# extra environment for install scripts
# *please* update the docs of variables supported at
# http://wiki.amahi.org/index.php/Script_variables
def hda_environment(user = nil, password = nil, db=nil)
env = {}
net = Setting.value_by_name('net')
addr = Setting.value_by_name('self-address')
dom = Setting.value_by_name('domain')
env["HDA_IP"] = [net, addr].join '.'
env["HDA_DOMAIN"] = dom
env["HDA_APP_DIR"] = Dir.pwd
env["HDA_APP_NAME"] = self.name
user && env["HDA_APP_USERNAME"] = user
password && env["HDA_APP_PASSWORD"] = password
env["HDA_1ST_ADMIN"] = (User.admins.first.login || "no-admin" ) rescue "error"
if db
env["HDA_DB_DBNAME"] = db.name
env["HDA_DB_USERNAME"] = db.username
env["HDA_DB_PASSWORD"] = db.password
env["HDA_DB_HOSTNAME"] = db.hostname
end
if share
env["HDA_SHARE_NAME"] = share.name
env["HDA_SHARE_PATH"] = share.path
end
env
end
def before_destroy_hook
# uninstall
end
def mkdir(path)
FileUtils.mkdir_p(path)
end
def install_pkgs(installer)
pkgs = installer.pkg
return if pkgs.nil? or pkgs.blank?
pkgs.strip!
Platform.install(pkgs, installer.source_sha1)
end
def install_app_deps(installer)
deps = installer.app_dependencies
return [] if deps.nil? or deps.blank?
deps.strip!
deps.split(/[, ]+/).map do |identifier|
a = App.where(:identifier=>identifier).first
unless a
a = App.new({identifier: identifier})
a.install_bg
end
# add the dependency if it does not exist
self.dependencies << a
end
end
def install_pkg_deps(installer)
deps = installer.pkg_dependencies
return if deps.nil? or deps.blank?
deps = deps.gsub(/[, ][ ]*/,' ').strip
unless deps.blank?
Platform.install(deps)
end
end
def uninstall_pkgs(uninstaller)
deps = uninstaller.pkg
return if deps.nil? or deps.blank?
deps.strip!
unless deps.blank?
Platform.uninstall(deps)
end
end
def install_theme(installer, source)
return if (installer.source_url.nil? or installer.source_url.blank?)
dir = nil
Dir.chdir(File.join(Rails.root, THEME_ROOT)) do
mkdir '.unpack'
Dir.chdir(".unpack") do
SystemUtils.unpack(installer.source_url, source)
# if only one file, move it to html!
files = Dir.glob('*')
if files.size == 1
dir = files.first
begin; Dir.rmdir("../#{dir}"); rescue; end
File.rename(files.first, "../#{dir}")
else
# FIXME what to do if more file are unpacked
raise "WARNING: this application unpacks into more than one file. This is a warning sign that it may not install properly!"
end
end
FileUtils.rm_rf ".unpack"
end
Theme.dir2theme(dir)
end
def install_webapp(installer, source)
name = webapp_name(installer.url_name)
path = WEBAPP_PATH % name
# clean it first
FileUtils.rm_rf path
mkdir File.join(path, 'html')
mkdir File.join(path, 'logs')
return [name, path] if (installer.source_url.nil? or installer.source_url.blank?)
one_dir = true
Dir.chdir(path) do
mkdir 'unpack'
mkdir 'logs'
Dir.chdir("unpack") do
SystemUtils.unpack(installer.source_url, source)
# if only one file, move it to html!
files = Dir.glob('*')
if files.size == 1
Dir.rmdir("../html")
File.rename(files.first, "../html")
else
# FIXME what to do if more file are unpacked
puts "WARNING: this application unpacks into more than one file/dir. PLEASE contact the authors and well them to unpack into one dir only as most other apps so!"
puts "NOTE: check the unpack/ directory for all the files"
one_dir = false
end
end
FileUtils.rm_rf "unpack" if one_dir
end
[name, path]
end
def webapp_name(name)
i = 0
add = ""
begin
wa = Webapp.where(:name=>(name + add)).first
return (name+add) if wa.nil?
raise "cannot find a suitable webapp name. giving up at #{name+add}." if i > 29
i += 1
add = i.to_s
end while i < 100
end
end