# Inspired by capistrano, thanks Jamis! require 'base64' require 'multi_json' require 'engineyard-serverside/rails_assets' require 'engineyard-serverside/maintenance' require 'engineyard-serverside/dependency_manager' require 'engineyard-serverside/dependency_manager/legacy_helpers' require 'engineyard-serverside/callbacks/distributor' module EY module Serverside class DeployBase < Task include ::EY::Serverside::DependencyManager::LegacyHelpers # default task def deploy shell.status "Starting deploy at #{shell.start_time.asctime}" update_repository_cache cached_deploy end def cached_deploy shell.status "Deploying app from cached copy at #{Time.now.asctime}" load_ey_yml require_custom_tasks push_code shell.status "Starting full deploy" new_release do callback(:before_deploy) check_repository create_revision_file run_with_callbacks(:bundle) setup_services configure_platform symlink_configs setup_sqlite3_if_necessary run_with_callbacks(:compile_assets) enable_maintenance_page run_with_callbacks(:migrate) callback(:before_symlink) # We don't use run_with_callbacks for symlink because we need # to clean up manually if it fails. symlink end callback(:after_symlink) run_with_callbacks(:restart) disable_maintenance_page cleanup_old_releases gc_repository_cache callback(:after_deploy) shell.status "Finished deploy at #{Time.now.asctime}" rescue Exception => e shell.status "Finished failing to deploy at #{Time.now.asctime}" shell.error "Exception during deploy: #{e.inspect}\n#{e.backtrace}" puts_deploy_failure raise end def update_repository_cache source.update_repository_cache end def gc_repository_cache source.gc_repository_cache if config.gc? end def create_revision_file_command source.create_revision_file_command(paths.active_revision) end def short_log_message(revision) source.short_log_message(revision) end def unchanged_diff_between_revisions?(previous_revision, active_revision, asset_dependencies) source.same?(previous_revision, active_revision, asset_dependencies) end def check_repository check_dependencies end def check_dependencies dependency_manager.check end def rails_application? dependency_manager.rails_version end def bundle install_dependencies end def install_dependencies dependency_manager.install end def restart_with_maintenance_page load_ey_yml require_custom_tasks enable_maintenance_page restart disable_maintenance_page end def enable_maintenance_page maintenance.conditionally_enable end def disable_maintenance_page maintenance.conditionally_disable end def run_with_callbacks(task) callback("before_#{task}") send(task) callback("after_#{task}") end # task def push_code shell.status "Pushing code to all servers" servers.remote.run_for_each do |server| server.sync_directory_command(paths.repository_cache) end end # task def restart @restart_failed = true if config.restart_groups.to_i > 1 shell.status "Restarting app servers in #{config.restart_groups.to_i} groups" else shell.status "Restarting app servers" end servers.roles(:app_master, :app, :solo).in_groups(config.restart_groups.to_i) do |group| shell.substatus "Restarting #{group.size} server#{group.size == 1 ? '' : 's'}" group.run(restart_command) end shell.status "Finished restarting app servers" @restart_failed = false end def restart_command %{LANG="en_US.UTF-8" /engineyard/bin/app_#{config.app} deploy} end def ensure_git_ssh_wrapper ENV['GIT_SSH'] = ssh_executable.to_s end # create ssh wrapper on all servers def ssh_executable @ssh_executable ||= begin roles :app_master, :app, :solo, :util do run(generate_ssh_wrapper) end paths.ssh_wrapper end end # We specify 'IdentitiesOnly' to avoid failures on systems with > 5 private keys available. # We set UserKnownHostsFile to /dev/null because StrickHostKeyChecking no doesn't # ignore existing entries in known_hosts; we want to actively ignore all such. # Learned this at http://lists.mindrot.org/pipermail/openssh-unix-dev/2009-February/027271.html # (Thanks Jim L.) def generate_ssh_wrapper path = paths.ssh_wrapper <<-SCRIPT mkdir -p #{path.dirname} [ -x #{path} ] || cat > #{path} <<'SSH' #!/bin/sh unset SSH_AUTH_SOCK # Filter command mangling by git when using `GIT_SSH` and the string `plink` command=$(echo "$*" | sed -e "s/^-batch //") ssh -o CheckHostIP=no -o StrictHostKeyChecking=no -o PasswordAuthentication=no -o LogLevel=INFO -o IdentityFile=#{paths.deploy_key} -o IdentitiesOnly=yes ${command} SSH chmod 0700 #{path} SCRIPT end # task def cleanup_old_releases count = config.keep_releases.to_i if count < 1 shell.error "Refusing to delete all releases! keep_releases must be >= 1 (got #{count})" count = EY::Serverside::Deploy::Configuration::DEFAULT_KEEP_RELEASES shell.warning "Running instead with default value for keep_releases: #{count}" end clean_release_directory(paths.releases, count) end # Remove all but the most-recent +count+ releases from the specified # release directory. # IMPORTANT: This expects the release directory naming convention to be # something with a sensible lexical order. Violate that at your peril. def clean_release_directory(dir, count) @cleanup_failed = true ordinal = count.succ.to_s shell.status "Cleaning release directory: #{dir}" sudo "ls -r #{dir} | tail -n +#{ordinal} | xargs -I@ rm -rf #{dir}/@" @cleanup_failed = false end def abort_on_bad_paths_in_release_directory shell.substatus "Checking for disruptive files in #{paths.releases}" bad_paths = paths.all_releases.reject do |path| path.basename.to_s =~ /^[\d]+$/ end if bad_paths.any? shell.fatal "Bad paths found in #{paths.releases}:\n\t#{bad_paths.join("\n\t")}\nStoring files in this directory will disrupt latest_release, diff detection, rollback, and possibly other features." raise end end # task def rollback if config.rollback_paths! begin load_ey_yml require_custom_tasks rolled_back_release = paths.latest_release shell.status "Rolling back to previous release: #{short_log_message(config.active_revision)}" abort_on_bad_paths_in_release_directory run_with_callbacks(:symlink) sudo "rm -rf #{rolled_back_release}" bundle shell.status "Restarting with previous release." enable_maintenance_page run_with_callbacks(:restart) disable_maintenance_page shell.status "Finished rollback at #{Time.now.asctime}" rescue Exception shell.status "Failed to rollback at #{Time.now.asctime}" puts_deploy_failure raise end else shell.fatal "Already at oldest release, nothing to roll back to." exit(1) end end # task def migrate return unless config.migrate? @migrations_reached = true cmd = "cd #{paths.active_release} && PATH=#{paths.binstubs}:$PATH #{config.framework_envs} #{config.migration_command}" roles :app_master, :solo do shell.status "Migrating: #{cmd}" run(cmd) end end def new_release(&block) copy_repository_cache with_failed_release_cleanup do shell.status "Ensuring proper ownership." ensure_ownership(paths.active_release) block.call end end # task def copy_repository_cache paths.new_release! shell.status "Copying to #{paths.active_release}" exclusions = Array(config.copy_exclude).map { |e| %|--exclude="#{e}"| }.join(' ') run("mkdir -p #{paths.active_release} #{paths.shared_config} && rsync -aq #{exclusions} #{paths.repository_cache}/ #{paths.active_release}") end def create_revision_file run create_revision_file_command end def setup_services shell.status "Setting up external services." previously_configured_services = config.configured_services begin sudo(config.services_check_command) rescue EY::Serverside::RemoteFailure shell.info "Could not setup services. Upgrade your environment to get services configuration." return end begin sudo(config.services_setup_command) rescue EY::Serverside::RemoteFailure => e if previously_configured_services shell.warning <<-WARNING External services configuration not updated. Using previous version. Deploy again if your services configuration appears incomplete or out of date. #{e} WARNING end end if services = config.configured_services shell.status "Services configured: #{services.join(', ')}" dependency_manager.show_ey_config_instructions end end def setup_sqlite3_if_necessary if dependency_manager.uses_sqlite3? shell.status "Setting up SQLite3 Database for compatibility with application's chosen adapter" shell.warning "SQLite3 cannot persist across servers. Please upgrade to a supported database." shell.substatus "Create databases directory if needed" run "mkdir -p #{paths.shared}/databases" shell.substatus "Create SQLite database if needed" run "touch #{paths.shared}/databases/#{config.framework_env}.sqlite3" shell.substatus "Create config directory if needed" run "mkdir -p #{paths.active_release_config}" shell.substatus "Generating SQLite config" run <<-WRAP cat > #{paths.shared_config}/database.sqlite3.yml<<'YML' #{config.framework_env}: adapter: sqlite3 database: #{paths.shared}/databases/#{config.framework_env}.sqlite3 pool: 5 timeout: 5000 YML WRAP shell.substatus "Symlink database.yml" run "ln -nfs #{paths.shared_config}/database.sqlite3.yml #{paths.active_release_config}/database.yml" end end def configure_platform run configure_command end def configure_command ENV['EY_SERVERSIDE_CONFIGURE_COMMAND'] || begin configure_script = "/engineyard/bin/app_#{config.app}_configure" return <<-CONFIGURE if [ -x "#{configure_script}" ] || which "#{configure_script}" >/dev/null 2>&1; then EY_DEPLOY_APP='#{config.app}' EY_DEPLOY_RELEASE_PATH='#{paths.active_release.to_s}' #{configure_script}; fi CONFIGURE end end def symlink_configs shell.status "Preparing shared resources for release." shell.substatus "Set group write permissions" run "chmod -R g+w #{paths.active_release}" setup_shared_tmp symlink_tasks.each do |what, cmd| shell.substatus what run(cmd) end end def setup_shared_tmp if config.shared_tmp? shell.substatus "Creating and symlinking shared tmp directory if empty" run <<-SETUP_TMP if [ -e "#{paths.active_tmp}" ] && [ ! "$(ls #{paths.active_tmp} 2>&1)" ]; then rm -rf #{paths.active_tmp}; fi; if [ ! -e "#{paths.active_tmp}" ]; then mkdir -p #{paths.shared_tmp} && ln -nfs #{paths.shared_tmp} #{paths.active_tmp}; fi SETUP_TMP unless paths.active_tmp.symlink? note = "This application's repository has tmp/ with contents committed.\n" if config.precompile_assets? note << "Asset precompile speed can be increased by sharing tmp across releases.\n" end note << <<-NOTICE Enable shared tmp with the follow git command (if applicable): $ git rm -r tmp/* && echo '*' > tmp/.gitignore Or, disable this feature via ey.yml configuration as follows: defaults: shared_tmp: false NOTICE shell.notice note end else shell.substatus "Prepare shared pids directory" run "rm -rf #{paths.active_tmp}/pids" end end def symlink_tasks [ ["Symlink shared pids directory", "ln -nfs #{paths.shared}/pids #{paths.active_tmp}/pids"], ["Symlink shared log directory", "rm -rf #{paths.active_log} && ln -nfs #{paths.shared_log} #{paths.active_log}"], ["Remove public/system if symlinked", "if [ -L \"#{paths.public_system}\" ]; then rm -rf #{paths.public_system}; fi"], ["Create public directory", "mkdir -p #{paths.public}"], ["Symlink public system directory", "if [ ! -e \"#{paths.public_system}\" ]; then ln -ns #{paths.shared_system} #{paths.public_system}; fi"], ["Create config directory", "mkdir -p #{paths.active_release_config}"], ["Symlink other shared config files", "find #{paths.shared_config} -maxdepth 1 -type f -not -name 'database.yml' -exec ln -s {} #{paths.active_release_config} \\;"], ["Symlink database.yml if needed", "if [ -f \"#{paths.shared_config}/database.yml\" ]; then ln -nfs #{paths.shared_config}/database.yml #{paths.active_release_config}/database.yml; fi"], ["Symlink newrelic.yml if needed", "if [ -f \"#{paths.shared_config}/newrelic.yml\" ]; then ln -nfs #{paths.shared_config}/newrelic.yml #{paths.active_release_config}/newrelic.yml; fi"], ["Symlink mongrel_cluster.yml if needed", "if [ -f \"#{paths.shared_config}/mongrel_cluster.yml\" ]; then ln -nfs #{paths.shared_config}/mongrel_cluster.yml #{paths.active_release_config}/mongrel_cluster.yml; fi"], ] end # task def symlink shell.status "Symlinking code." run move_symlink(paths.active_release, paths.current, "deploying") ensure_ownership(paths.current) @symlink_changed = true rescue Exception sudo move_symlink(paths.previous_release(paths.active_release), paths.current, "reverting") ensure_ownership(paths.current) @symlink_changed = false raise end def ensure_ownership(*targets) sudo %|find #{targets.join(' ')} \\( -not -user #{config.user} -or -not -group #{config.group} \\) -exec chown #{config.user}:#{config.group} "{}" +| end # Move a symlink as atomically as we can. # # mv -T renames 'next' to 'current' instead of moving 'next' to current/next' # mv -T isn't available on OS X and maybe elsewhere, so fallback to rm && ln def move_symlink(source, link, name) next_link = link.dirname.join(name) mv_t = "ln -nfs #{source} #{next_link} && mv -T #{next_link} #{link} >/dev/null 2>&1" rm_ln = "rm -rf #{next_link} #{link} && ln -nfs #{source} #{link}" "((#{mv_t}) || (#{rm_ln}))" end def callback(what) @callbacks_reached ||= true Callbacks::Distributor.distribute(self, what) end def source ensure_git_ssh_wrapper @source ||= config.source(shell) end protected # FIXME: Legacy method, warn and remove. def serverside_bin About.binary end def puts_deploy_failure if @cleanup_failed shell.notice "[Relax] Your site is running new code, but clean up of old deploys failed." elsif maintenance.up? message = "[Attention] Maintenance page still up, consider the following before removing:\n" message << " * Deploy hooks ran. This might cause problems for reverting to old code.\n" if @callbacks_reached message << " * Migrations ran. This might cause problems for reverting to old code.\n" if @migrations_reached if @symlink_changed message << " * Your new code is symlinked as current.\n" else message << " * Your old code is still symlinked as current.\n" end message << " * Application servers failed to restart.\n" if @restart_failed message << "\n" message << "Need help? File a ticket for support.\n" shell.notice message else shell.notice "[Relax] Your site is still running old code and nothing destructive has occurred." end end def with_failed_release_cleanup yield rescue Exception => e shell.status "Release #{paths.active_release} failed, saving release to #{paths.releases_failed}." run "mkdir -p #{paths.releases_failed}" ensure_ownership(paths.active_release, paths.releases_failed) run "mv #{paths.active_release} #{paths.releases_failed}" clean_release_directory(paths.releases_failed, config.keep_failed_releases.to_i) raise e end def maintenance @maintenance ||= Maintenance.new(servers, config, shell) end def dependency_manager ensure_git_ssh_wrapper @dependency_manager ||= DependencyManager.new(servers, config, shell, self) end def compile_assets RailsAssets.detect_and_compile(config, shell, self) end end # DeployBase class Deploy < DeployBase end end end