# frozen_string_literal: true module God class Task # Public: Gets/Sets the String name of the task. attr_accessor :name # Public: Gets/Sets the Numeric default interval to be used between poll # events. attr_accessor :interval # Public: Gets/Sets the String group name of the task. attr_accessor :group # Public: Gets/Sets the Array of Symbol valid states for the state machine. attr_accessor :valid_states # Public: Gets/Sets the Symbol initial state of the state machine. attr_accessor :initial_state # Gets/Sets the Driver for this task. attr_accessor :driver # Public: Sets whether the task should autostart when god starts. Defaults # to true (enabled). attr_writer :autostart # Returns true if autostart is enabled, false if not. def autostart? @autostart end # api attr_accessor :state, :behaviors, :metrics, :directory def initialize @autostart = true # initial state is unmonitored self.state = :unmonitored # the list of behaviors self.behaviors = [] # the list of conditions for each action self.metrics = { nil => [], :unmonitored => [], :stop => [] } # the condition -> metric lookup self.directory = {} # driver self.driver = Driver.new(self) end # Initialize the metrics to an empty state. # # Returns nothing. def prepare valid_states.each do |state| metrics[state] ||= [] end end # Verify that the minimum set of configuration requirements has been met. # # Returns true if valid, false if not. def valid? valid = true # A name must be specified. if name.nil? valid = false applog(self, :error, 'No name String was specified.') end # Valid states must be specified. if valid_states.nil? valid = false applog(self, :error, 'No valid_states Array or Symbols was specified.') end # An initial state must be specified. if initial_state.nil? valid = false applog(self, :error, 'No initial_state Symbol was specified.') end valid end ########################################################################### # # Advanced mode # ########################################################################### # Convert the given input into canonical hash form which looks like: # # { true => :state } or { true => :state, false => :otherstate } # # to - The Symbol or Hash destination. # # Returns the canonical Hash. def canonical_hash_form(to) to.instance_of?(Symbol) ? { true => to } : to end # Public: Define a transition handler which consists of a set of conditions # # start_states - The Symbol or Array of Symbols start state(s). # end_states - The Symbol or Hash end states. # # Yields the Metric for this transition. # # Returns nothing. def transition(start_states, end_states) # Convert end_states into canonical hash form. canonical_end_states = canonical_hash_form(end_states) Array(start_states).each do |start_state| # Validate start state. unless valid_states.include?(start_state) abort "Invalid state :#{start_state}. Must be one of the symbols #{valid_states.map { |x| ":#{x}" }.join(', ')}" end # Create a new metric to hold the task, end states, and conditions. m = Metric.new(self, canonical_end_states) if block_given? # Let the config file define some conditions on the metric. yield(m) else # Add an :always condition if no block was given. m.condition(:always) do |c| c.what = true end end # Populate the condition -> metric directory. m.conditions.each do |c| directory[c] = m end # Record the metric. metrics[start_state] ||= [] metrics[start_state] << m end end # Public: Define a lifecycle handler. Conditions that belong to a # lifecycle are active as long as the process is being monitored. # # Returns nothing. def lifecycle # Create a new metric to hold the task and conditions. m = Metric.new(self) # Let the config file define some conditions on the metric. yield(m) # Populate the condition -> metric directory. m.conditions.each do |c| directory[c] = m end # Record the metric. metrics[nil] << m end ########################################################################### # # Lifecycle # ########################################################################### # Enable monitoring. # # Returns nothing. def monitor move(initial_state) end # Disable monitoring. # # Returns nothing. def unmonitor move(:unmonitored) end # Move to the given state. # # to_state - The Symbol representing the state to move to. # # Returns this Task. def move(to_state) if driver.in_driver_context? # Called from within Driver. Record original info. orig_to_state = to_state from_state = state # Log. msg = "#{name} move '#{from_state}' to '#{to_state}'" applog(self, :info, msg) # Cleanup from current state. driver.clear_events metrics[from_state].each(&:disable) metrics[nil].each(&:disable) if to_state == :unmonitored # Perform action. action(to_state) # Enable simple mode. to_state = :up if [:start, :restart].include?(to_state) && metrics[to_state].empty? # Move to new state. metrics[to_state].each(&:enable) # If no from state, enable lifecycle metric. metrics[nil].each(&:enable) if from_state == :unmonitored # Set state. self.state = to_state # Broadcast to interested TriggerConditions. Trigger.broadcast(self, :state_change, [from_state, orig_to_state]) # Log. msg = "#{name} moved '#{from_state}' to '#{to_state}'" applog(self, :info, msg) else # Called from outside Driver. Send an async message to Driver. driver.message(:move, [to_state]) end self end # Notify the Driver that an EventCondition has triggered. # # condition - The Condition. # # Returns nothing. def trigger(condition) driver.message(:handle_event, [condition]) end def signal(sig) # noop end ########################################################################### # # Actions # ########################################################################### def method_missing(sym, *args) super unless /=$/.match?(sym.to_s) base = sym.to_s.chop.intern super unless valid_states.include?(base) self.class.send(:attr_accessor, base) send(sym, *args) end # Perform the given action. # # action - The Symbol action. # condition - The Condition. # # Returns this Task. def action(action, condition = nil) if !driver.in_driver_context? # Called from outside Driver. Send an async message to Driver. driver.message(:action, [action, condition]) elsif respond_to?(action) # Called from within Driver. command = send(action) case command when String msg = "#{name} #{action}: #{command}" applog(self, :info, msg) system(command) when Proc msg = "#{name} #{action}: lambda" applog(self, :info, msg) command.call else raise NotImplementedError end end end ########################################################################### # # Events # ########################################################################### def attach(condition) case condition when PollCondition driver.schedule(condition, 0) when EventCondition, TriggerCondition condition.register end end def detach(condition) case condition when PollCondition condition.reset when EventCondition, TriggerCondition condition.deregister end end ########################################################################### # # Registration # ########################################################################### def register! # override if necessary end def unregister! driver.shutdown end ########################################################################### # # Handlers # ########################################################################### # Evaluate and handle the given poll condition. Handles logging # notifications, and moving to the new state if necessary. # # condition - The Condition to handle. # # Returns nothing. def handle_poll(condition) # Lookup metric. metric = directory[condition] # Run the test. begin result = condition.test rescue Object => e cname = condition.class.to_s.split('::').last message = format("Unhandled exception in %{cname} condition - (%{class}): %{message}\n%{backtrace}", cname: cname, class: e.class, message: e.message, backtrace: e.backtrace.join("\n")) applog(self, :error, message) result = false end # Log. messages = log_line(self, metric, condition, result) # Notify. notify(condition, messages.last) if result && condition.notify # After-condition. condition.after # Get the destination. dest = if result && condition.transition # Condition override. condition.transition else # Regular. metric.destination && metric.destination[result] end # Transition or reschedule. if dest # Transition. begin move(dest) rescue EventRegistrationFailedError msg = "#{name} Event registration failed, moving back to previous state" applog(self, :info, msg) dest = state retry end else # Reschedule. driver.schedule(condition) end end # Asynchronously evaluate and handle the given event condition. Handles # logging notifications, and moving to the new state if necessary. # # condition - The Condition to handle. # # Returns nothing. def handle_event(condition) # Lookup metric. metric = directory[condition] # Log. messages = log_line(self, metric, condition, true) # Notify. notify(condition, messages.last) if condition.notify # Get the destination. dest = condition.transition || (metric.destination && metric.destination[true]) return unless dest move(dest) end # Determine whether a trigger happened. # # metric - The Metric. # result - The Boolean result from the condition's test. # # Returns Boolean def trigger?(metric, result) metric.destination && metric.destination[result] end # Log info about the condition and return the list of messages logged. # # watch - The Watch. # metric - The Metric. # condition - The Condition. # result - The Boolean result of the condition test evaluation. # # Returns the Array of String messages. def log_line(watch, metric, condition, result) status = if trigger?(metric, result) '[trigger]' else '[ok]' end messages = [] # Log info if available. if condition.info Array(condition.info).each do |condition_info| messages << "#{watch.name} #{status} #{condition_info} (#{condition.base_name})" applog(watch, :info, messages.last) end else messages << "#{watch.name} #{status} (#{condition.base_name})" applog(watch, :info, messages.last) end # Log. debug_message = "#{watch.name} #{condition.base_name} [#{result}] #{dest_desc(metric, condition)}" applog(watch, :debug, debug_message) messages end # Format the destination specification for use in debug logging. # # metric - The Metric. # condition - The Condition. # # Returns the formatted String. def dest_desc(metric, condition) if condition.transition { true => condition.transition }.inspect elsif metric.destination metric.destination.inspect else 'none' end end # Notify all recipients of the given condition with the specified message. # # condition - The Condition. # message - The String message to send. # # Returns nothing. def notify(condition, message) spec = Contact.normalize(condition.notify) unmatched = [] # Resolve contacts. resolved_contacts = spec[:contacts].inject([]) do |acc, contact_name_or_group| cons = Array(God.contacts[contact_name_or_group] || God.contact_groups[contact_name_or_group]) unmatched << contact_name_or_group if cons.empty? acc += cons acc end # Warn about unmatched contacts. unless unmatched.empty? msg = "#{condition.watch.name} no matching contacts for '#{unmatched.join(', ')}'" applog(condition.watch, :warn, msg) end # Notify each contact. resolved_contacts.each do |c| host = `hostname`.chomp rescue 'none' begin c.notify(message, Time.now, spec[:priority], spec[:category], host) msg = "#{condition.watch.name} #{c.info || "notification sent for contact: #{c.name}"} (#{c.base_name})" applog(condition.watch, :info, msg) rescue Exception => e applog(condition.watch, :error, "#{e.message} #{e.backtrace}") msg = "#{condition.watch.name} Failed to deliver notification for contact: #{c.name} (#{c.base_name})" applog(condition.watch, :error, msg) end end end end end