aeolusproject/conductor

View on GitHub
src/app/models/deployment.rb

Summary

Maintainability
F
3 days
Test Coverage
#
#   Copyright 2011 Red Hat, Inc.
#
#   Licensed under the Apache License, Version 2.0 (the "License");
#   you may not use this file except in compliance with the License.
#   You may obtain a copy of the License at
#
#       http://www.apache.org/licenses/LICENSE-2.0
#
#   Unless required by applicable law or agreed to in writing, software
#   distributed under the License is distributed on an "AS IS" BASIS,
#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
#   See the License for the specific language governing permissions and
#   limitations under the License.
#

# == Schema Information
#
# Table name: deployments
#
#  id                     :integer         not null, primary key
#  name                   :string(1024)    not null
#  realm_id               :integer
#  owner_id               :integer
#  pool_id                :integer         not null
#  lock_version           :integer         default(0)
#  created_at             :datetime
#  updated_at             :datetime
#  frontend_realm_id      :integer
#  deployable_xml         :text
#  scheduled_for_deletion :boolean         default(FALSE), not null
#  uuid                   :text            not null
#

require 'util/deployable_xml'
require 'util/config_server_util'

class Deployment < ActiveRecord::Base
  acts_as_paranoid

  include Alberich::PermissionedObject
  class << self
    include CommonFilterMethods
  end

  before_destroy :destroyable?

  belongs_to :pool
  belongs_to :pool_family

  has_many :instances, :dependent => :destroy

  belongs_to :provider_realm
  belongs_to :frontend_realm

  belongs_to :owner, :class_name => "User", :foreign_key => "owner_id"

  has_many :events, :as => :source, :dependent => :destroy,
           :order => 'events.id ASC'

  has_many :provider_accounts, :through => :instances

  after_create "assign_owner_roles(owner)"

  scope :ascending_by_name, :order => 'deployments.name ASC'

  before_validation :replace_special_characters_in_name
  validates_presence_of :pool_id
  validates_presence_of :name
  validates_uniqueness_of :name, :scope => [:pool_id, :deleted_at]
  validates_length_of :name, :maximum => 50
  validates_presence_of :owner_id
  validate :pool_must_be_enabled, :on => :create
  before_destroy :destroy_deployment_config
  before_create :inject_launch_parameters
  before_create :generate_uuid
  before_create :set_pool_family
  before_create :set_new_state
  after_save :log_state_change
  after_save :handle_completed_rollback

  USER_MUTABLE_ATTRS = ['name']

  STATE_NEW                  = "new"
  STATE_PENDING              = "pending"
  STATE_RUNNING              = "running"
  STATE_INCOMPLETE           = "incomplete"
  STATE_SHUTTING_DOWN        = "shutting_down"
  STATE_STOPPED              = "stopped"
  STATE_FAILED               = "failed"
  STATE_ROLLBACK_IN_PROGRESS = "rollback_in_progress"
  STATE_ROLLBACK_COMPLETE    = "rollback_complete"
  STATE_ROLLBACK_FAILED      = "rollback_failed"

  STATES = [STATE_NEW, STATE_PENDING, STATE_RUNNING, STATE_INCOMPLETE,
            STATE_SHUTTING_DOWN, STATE_STOPPED, STATE_FAILED,
            STATE_ROLLBACK_IN_PROGRESS, STATE_ROLLBACK_COMPLETE,
            STATE_ROLLBACK_FAILED]
  # list of states in which it's possible to start single instance
  INSTANCE_STARTABLE_STATES = [STATE_NEW, STATE_PENDING, STATE_RUNNING,
                               STATE_INCOMPLETE, STATE_SHUTTING_DOWN,
                               STATE_STOPPED]

  validate :validate_xml
  validate :validate_launch_parameters

  def validate_xml
    begin
      deployable_xml.validate!
    rescue DeployableXML::ValidationError => e
      errors.add(:deployable_xml, e.message)
    rescue Nokogiri::XML::SyntaxError => e
      errors.add(:base, _('seems to be not valid Deployable XML: %s') % e.message)
    end
  end

  def validate_launch_parameters
    launch_parameters.each do |asm, services|
      services.each do |service, params|
        params.each do |param, value|
          if value.blank?
            errors.add(:launch_parameters, "#{asm}.#{service}.#{param} cannot be blank")
          end
        end
      end
    end
  end

  def pool_must_be_enabled
    errors.add(:pool, _('must be enabled')) unless pool and pool.enabled?
    errors.add(:pool, _('has all associated Providers disabled')) if pool and pool.pool_family.all_providers_disabled?
  end

  def perm_ancestors
    super + [pool, pool_family]
  end
  def derived_subtree(role = nil)
    subtree = super(role)
    subtree += instances if (role.nil? or role.privilege_target_match(Instance))
    subtree
  end
  def self.additional_privilege_target_types
    [Instance]
  end

  def get_action_list(user=nil)
    # FIXME: how do actions and states interact for deployments?
    # For instances the list comes from the provider based on current state.
    # Deployments don't currently have an explicit state field, but
    # something could be calculated from associated instances.
    ["start", "stop", "reboot"]
  end

  def valid_action?(action)
    get_action_list.include?(action)
  end

  def destroyable?
    instances.all? {|i| i.destroyable? }
  end

  def can_stop?
    [STATE_RUNNING, STATE_INCOMPLETE].include?(self.state)
  end

  def not_stoppable_or_destroyable_instances
    instances.find_all {|i| !(i.destroyable? or
                              i.state == Instance::STATE_RUNNING)}
  end

  def stop_instances_and_destroy!
    if destroyable?
      destroy!
    else
      self.state = Deployment::STATE_SHUTTING_DOWN
      # The deployment will be destroyed from an InstanceObserver callback once
      # all instances are stopped.
      self.scheduled_for_deletion = true
      self.save!

      # stop all deployment's instances
      instances.running.each {|instance| instance.stop(instance.owner)}
    end
  end

  def self.stoppable_inaccessible_instances(deployments)
    failed_accounts = {}
    res = []
    deployments.each do |d|
      next unless acc = d.provider_account
      failed_accounts[acc.id] = acc.connect.nil? unless failed_accounts.has_key?(acc.id)
      next unless failed_accounts[acc.id]
      res += d.instances.stoppable_inaccessible
    end
    res
  end

  def launch_parameters
    @launch_parameters ||= {}
  end

  def launch_parameters=(launch_parameters)
    @launch_parameters = launch_parameters
  end

  def create_and_launch(permission_session, user)
    begin
      # this method doesn't restore record state if this transaction
      # fails, wrapping transaction in rollback_active_record_state!
      # is not sufficient because restore then doesn't work if
      # you have some nested save operations inside the transaction
      transaction do
        save!
        create_instances_with_params!(permission_session, user)
        launch!(user)
      end
      true
    rescue
      errors.add(:base, $!.message)
      log_backtrace($!)
      false
    end
  end

  def launch!(user)
    self.reload unless self.new_record?
    self.state = STATE_PENDING
    save!

    all_inst_match, account, errs = pick_provider_selection_match

    if all_inst_match
      self.events << Event.create(
        :source => self,
        :event_time => DateTime.now,
        :status_code => 'deployment_launch_match',
        :summary => _('Attempting to launch this deployment on provider account %s') % account.name
      )
    else
      if errs.any?
        raise _('Match not found: %s') % errs.join(", ")
      else
        raise _('Unable to find a suitable Provider Account to host the Deployment. Check the quota of the Provider Accounts and the status of the Images.')
      end
    end

    # Array of InstanceMatches is converted to hashes
    # because if we use directly instance of InstanceMatch model,
    # delayed job tries to load this object from DB
    all_inst_match.map!{|m| m.attributes}

    if deployable_xml.requires_config_server?
      config_server_id = account.config_server.id
    else
      config_server_id = nil
    end

    delay.send_launch_requests(all_inst_match,
                               instances.map{|i| i.id},
                               config_server_id, user.id)
  end

  def pick_provider_selection_match
    assembly_instances_builder =
      DeployableMatching::AssemblyInstancesBuilder.
        build_from_instances(pool, instances)
    assembly_instances = assembly_instances_builder.assembly_instances
    provider_selection = ProviderSelection::Base.new(pool, assembly_instances)

    return [nil, nil, provider_selection.errors] unless provider_selection.valid?

    deployable_match = provider_selection.next_match

    all_inst_match = deployable_match.multi_assembly_match.map do |assembly_match|
      InstanceMatch.new(
        :pool_family => pool_family,
        :provider_account => assembly_match.provider_account,
        :hardware_profile => assembly_match.provider_hwp,
        :provider_image => assembly_match.provider_image.external_image_id,
        :provider_realm => assembly_match.provider_realm,
        :instance => assembly_match.instance
      )
    end

    [all_inst_match, deployable_match.provider_account, provider_selection.errors]
  end

  def send_launch_requests(all_inst_match, instance_ids, config_server_id, user_id)
    user = User.find(user_id)
    instances = instance_ids.map{|instance_id| Instance.find(instance_id)}

    if config_server_id.nil?
      config_server = nil
      instance_configs = {}
    else
      config_server = ConfigServer.find(config_server_id)

      # the instance configurations need to be generated from the entire set of
      # instances (and not each individual instance) in order to do parameter
      # dependency resolution across the set
      instance_configs = ConfigServerUtil.instance_configs(self,instances,config_server)
    end

    instances.each do |instance|
      instance.reset_attrs unless instance.state == Instance::STATE_NEW
      instance.instance_matches << InstanceMatch.new(
        all_inst_match.find{|m| m['instance_id'] == instance.id})
      begin
        instance.launch!(instance.instance_matches.last,
                         user,
                         config_server,
                         instance_configs[instance.uuid])
      rescue
        # be default launching of instances is terminated if an error occurs,
        # user can set "partial_launch" attribute - launch request is then
        # sent for all deployment's instances
        break unless partial_launch
      end
    end
    true
  end

  def self.list(order_field, order_dir)
    Deployment.all(:include => :owner,
                   :order => (order_field || 'name') +' '+ (order_dir || 'asc'))
  end

  def valid_deployable_xml?(xml)
    begin
      self.deployable_xml = DeployableXML.new(xml)
      deployable_xml.validate!
      true
    rescue
      errors.add(:base, _('seems to be not valid Deployable XML: %s') % "#{$!.message}")
      false
    end
  end

  def deployable_xml
    @deployable_xml ||= DeployableXML.new(self[:deployable_xml].to_s)
  end

  def properties
    result = {
      :name => name,
      :created => created_at,
      :pool => pool.name
    }

    result[:owner] = "#{owner.first_name}  #{owner.last_name}" if owner.present?

    result
  end

  def provider
    if deleted?
      inst = instances.unscoped.joins(:provider_account => :provider).first
    else
      inst = instances.joins(:provider_account => :provider).first
    end
    inst && inst.provider_account && inst.provider_account.provider
  end

  def provider_account
    if deleted?
      inst = instances.unscoped.joins(:provider_account).first
    else
      inst = instances.joins(:provider_account).first
    end
    inst && inst.provider_account
  end

  def check_assemblies_matches(permission_session, user)
    assembly_instances_builder =
      DeployableMatching::AssemblyInstancesBuilder.
        build_from_deployable(permission_session, user, pool, deployable_xml)

    if assembly_instances_builder.errors.any?
      return assembly_instances_builder.errors
    end

    assembly_instances = assembly_instances_builder.assembly_instances
    ProviderSelection::Base.new(pool, assembly_instances).errors
  end

  def all_instances_running?
    instances.deployed.count == instances.count
  end

  def any_instance_running?
    instances.any? {|i| i.state == Instance::STATE_RUNNING }
  end

  def uptime_1st_instance
    return nil if events.empty?

    first_running = events.find_last_by_status_code(:first_running)
    if instances.deployed.empty?
      all_stopped = events.find_last_by_status_code(:all_stopped)
      if all_stopped && first_running && all_stopped.event_time > first_running.event_time
        all_stopped.event_time - first_running.event_time
      else
        nil
      end
    else
      if first_running
        Time.now.utc - first_running.event_time
      else
        nil
      end
    end
  end

  def uptime_all
    return nil if events.empty?

    all_running = events.find_last_by_status_code(:all_running)
    some_stopped = events.find_last_by_status_code(:some_stopped)
    all_stopped = events.find_last_by_status_code(:all_stopped)

    if instances.deployed.count == instances.count && all_running
      Time.now.utc - all_running.event_time
    elsif instances.count > 1 && all_running && some_stopped
      some_stopped.event_time - all_running.event_time
    elsif all_stopped && all_running && all_stopped.event_time > all_running.event_time
      all_stopped.event_time - all_running.event_time
    else
      nil
    end
  end

  # A deployment "starts" when _all_ instances begin to run
  def start_time
    if instances.deployed.count == instances.count && ev = events.find_last_by_status_code(:all_running)
      ev.event_time
    else
      nil
    end
  end

  # A deployment "ends" when one or more instances stop, assuming they were ever all-running
  def end_time
    if events.find_last_by_status_code(:all_running)
      ev = events.find_last_by_status_code(:some_stopped) || events.find_last_by_status_code(:all_stopped)
      ev.present? ? ev.event_time : nil
    else
      nil
    end
  end

  def failed_instances
    instances.failed
  end

  def update_state(changed_instance)
    transition_method = "state_transition_from_#{state}".to_sym
    send(transition_method, changed_instance) if self.respond_to?(transition_method, true)
    if state_changed?
      save!
      true
    else
      false
    end
  end

  def copy_as_new
    d = Deployment.new(self.attributes)
    d.errors.merge!(self.errors)
    d
  end

  def events_of_deployment_and_instances
    instance_ids = instances.map(&:id)
    Event.all(:conditions => ["(source_type='Instance' AND source_id in (?))"\
                              "OR (source_type='Deployment' AND source_id=?)",
                              instance_ids, self.id],
              :order => "created_at ASC")

  end

  PRESET_FILTERS_OPTIONS = [
    {:title => "deployments.preset_filters.other_than_stopped", :id => "other_than_stopped", :query => where("deployments.state != ?", "stopped")},
    {:title => "deployments.preset_filters.new", :id => "new", :query => where("deployments.state" => "new")},
    {:title => "deployments.preset_filters.pending", :id => "pending", :query => where("deployments.state" => "pending")},
    {:title => "deployments.preset_filters.running", :id => "running", :query => where("deployments.state" => "running")},
    {:title => "deployments.preset_filters.incomplete", :id => "incomplete", :query => where("deployments.state" => "incomplete")},
    {:title => "deployments.preset_filters.shutting_down", :id => "shutting_down", :query => where("deployments.state" => "shutting_down")},
    {:title => "deployments.preset_filters.stopped", :id => "stopped", :query => where("deployments.state" => "stopped")},
    {:title => "deployments.preset_filters.failed", :id => "failed", :query => where("deployments.state" => "failed")},
    {:title => "deployments.preset_filters.rollback_failed", :id => "rollback_failed", :query => where("deployments.state" => "rollback_failed")}
  ]

  private

  def self.apply_search_filter(search)
    # TODO: after upgrading to 3.1 the SQL join statement can be done in Rails way by adding a has_many association to providers through provider_accounts
    #       (Rails before version 3.1 does not support nested associations with through param)
    if search
      includes(:pool, :provider_accounts => :provider).
          joins("LEFT OUTER JOIN provider_types ON provider_types.id = providers.provider_type_id").
          where("lower(pools.name) LIKE :search OR lower(deployments.name) LIKE :search OR lower(provider_types.name) LIKE :search",
                :search => "%#{search.downcase}%")
    else
      scoped
    end
  end

  def inject_launch_parameters
    launch_parameters.each_pair do |assembly, svcs|
      svcs.each_pair do |service, params|
        params.each_pair do |param, value|
          deployable_xml.set_parameter_value(assembly, service, param, value)
        end
      end
    end
    self.deployable_xml = deployable_xml.to_s
  end

  def destroy_deployment_config
    # the implication here is that if there is an instance in this deployment
    # with userdata, then a config server was associated with this deployment;
    # further, the config server associated with one instance is the same config
    # server used for all instances in the deployment
    # this logic could easily change
    if instances.any? {|instance| instance.user_data}
      # guard against the provider_account being nil
      if instances.first.provider_account
        configserver = instances.first.provider_account.config_server
        configserver.delete_deployment_config(uuid) if configserver
      end
    end
  end

  def generate_uuid
    self[:uuid] = UUIDTools::UUID.timestamp_create.to_s
  end

  def replace_special_characters_in_name
    name.gsub!(/[^a-zA-Z0-9]+/, '-') if !name.nil?
  end

  def set_pool_family
    self[:pool_family_id] = pool.pool_family_id
  end

  def create_instances_with_params!(permission_session, user)
    assembly_instances_builder =
      DeployableMatching::AssemblyInstancesBuilder.
        build_from_deployable(permission_session, user, pool, deployable_xml)
    assembly_instances = assembly_instances_builder.assembly_instances
    provider_selection = ProviderSelection::Base.new(pool, assembly_instances)

    provider_selection.assembly_instances.each do |assembly_instance|
      Instance.transaction do
        attrs = { :name => "#{name}/#{assembly_instance.assembly.name}",
                  :deployment => self,
                  :assembly_xml => assembly_instance.assembly.to_s,
                  :state => Instance::STATE_NEW }

        attrs.merge!(assembly_instance.attributes)
        instance = Instance.create!(attrs)

        assembly_instance.service_parameters.each do |parameter|
          next if parameter.reference?

          InstanceParameter.create!(:instance => instance,
                                    :service => parameter[:service],
                                    :name => parameter[:name],
                                    :type => parameter[:type],
                                    :value => parameter[:value])

        end

        self.instances << instance
      end
    end
  end

  def set_new_state
    self.state ||= STATE_NEW
  end

  def state_transition_from_pending(instance)
    if instances.all? {|i| i.state == Instance::STATE_RUNNING}
      self.state = STATE_RUNNING
    elsif partial_launch and instances.all? {|i| i.failed?}
      self.state = STATE_FAILED
    elsif partial_launch and instances.all? {|i| i.failed_or_running?}
      self.state = STATE_INCOMPLETE
    elsif !partial_launch and Instance::FAILED_STATES.include?(instance.state)
      # TODO: now this is done in instance's after_update callback - as part
      # of instance save transaction - this might be done on background by
      # using delayed_job
      deployment_rollback
    end
  end

  def state_transition_from_running(instance)
    if instance.state != STATE_RUNNING
      if instances.all? {|i| i.state == Instance::STATE_STOPPED}
        self.state = STATE_STOPPED
      else
        self.state = STATE_INCOMPLETE
      end
    end
  end

  def state_transition_from_incomplete(instance)
    if instances.all? {|i| i.state == Instance::STATE_RUNNING}
      self.state = STATE_RUNNING
    elsif instances.all? {|i| i.state == Instance::STATE_STOPPED}
      self.state = STATE_STOPPED
    end
  end

  def state_transition_from_shutting_down(instance)
    if instance.state == Instance::STATE_STOPPED and instances.all? {|i| i.inactive?}
      self.state = STATE_STOPPED
    end
  end

  def state_transition_from_rollback_in_progress(instance)
    # TODO: distinguish if an instance was created on provider side
    # or error occurred on create_instance request - in such case
    # the instance has not to be rollbacked
    if Instance::ACTIVE_FAILED_STATES.include?(instance.state)
      # if this instance stop failed, whole deployment rollback failed
      self.state = STATE_ROLLBACK_FAILED
      cleanup_failed_launch
    elsif instance.state == Instance::STATE_RUNNING
      deployment_rollback
    elsif instances.all? {|i| i.finished?}
      # some other instances might be failed (because their
      # launch failed), but it shouldn't be a problem if all
      # running instances stopped correctly
      self.state = STATE_ROLLBACK_COMPLETE
    end
  end

  def deployment_rollback
    unless self.state == STATE_ROLLBACK_IN_PROGRESS
      self.state = STATE_ROLLBACK_IN_PROGRESS
      save!
    end

    if instances.all? {|i| i.inactive? or i.state == Instance::STATE_NEW}
      self.state = STATE_ROLLBACK_COMPLETE
      save!
      return
    end

    delay.send_rollback_requests
  end

  def send_rollback_requests
    error_occured = false
    instances.running.each do |instance|
      error_occured = true unless instance.stop_with_event(nil)
    end
    if error_occured
      cleanup_failed_launch
      self.state = STATE_ROLLBACK_FAILED
      save!
    end
  end

  def log_state_change
    if state_changed?
      self.events << Event.create(
        :source => self,
        :event_time => DateTime.now,
        :status_code => self.state,
        :summary => _('State changed to %s') % self.state
      )
    end
  end

  def handle_completed_rollback
    if self.state_changed? and self.state == STATE_ROLLBACK_COMPLETE
      begin
        if self.events.where(
            :status_code => 'deployment_launch_match').count > 9
          raise "There was too many launch retries, aborting"
        end
        launch!(self.owner)
      rescue
        self.events << Event.create(
          :source => self,
          :event_time => DateTime.now,
          :status_code => 'deployment_launch_failed',
          :summary => _('Failed to launch deployment'),
          :description => $!.message
        )
        update_attribute(:state, STATE_FAILED)
        cleanup_failed_launch
        log_backtrace($!)
      end
    end
  end

  def cleanup_failed_launch
    instances.in_new_state.each do |instance|
      instance.update_attribute(:state, Instance::STATE_CREATE_FAILED)
    end
  end

  include CostEngine::Mixins::Deployment
end