# -*- ruby -*- #encoding: utf-8 require 'set' require 'uri' require 'time' require 'pathname' require 'state_machines' require 'configurability' require 'loggability' require 'pluggability' require 'arborist' unless defined?( Arborist ) require 'arborist/mixins' require 'arborist/exceptions' require 'arborist/dependency' using Arborist::TimeRefinements # The basic node class for an Arborist tree class Arborist::Node include Enumerable, Arborist::HashUtilities extend Loggability, Pluggability, Configurability, Arborist::MethodUtilities # The key for the thread local that is used to track instances as they're # loaded. LOADED_INSTANCE_KEY = :loaded_node_instances # Regex to match a valid identifier VALID_IDENTIFIER = /^\w[\w\-]*$/ # The attributes of a node which are used in the operation of the system OPERATIONAL_ATTRIBUTES = %i[ type family status tags parent description dependencies status_changed status_last_changed last_contacted ack errors warnings quieted_reasons config ] # Node states that are unreachable by default. UNREACHABLE_STATES = %w[ down disabled quieted ] autoload :Root, 'arborist/node/root' autoload :Ack, 'arborist/node/ack' # Log via the Arborist logger log_to :arborist # Search for plugins in lib/arborist/node directories in loaded gems plugin_prefixes 'arborist/node' # Configurability API configurability( 'arborist.node' ) do # How many status entries to keep in a ring buffer setting :status_history_size, default: 0 # How many status transitions in the history constitutes flapping setting :flap_threshold, default: 0 end ## # :method: unknown? # Returns +true+ if the node is in an 'unknown' state. ## # :method: up? # Returns +true+ if the node is in an 'up' state. ## # :method: down? # Returns +true+ if the node is in an 'down' state. ## # :method: acked? # Returns +true+ if the node is in an 'acked' state. ## # :method: disabled? # Returns +true+ if the node is in an 'disabled' state. ## # :method: human_status_name # Return the node's status as a human-readable String. ## # :method: status # Return the +status+ of the node. This will be one of: +unknown+, +up+, +down+, +acked+, or # +disabled+. ## # :method: status= # :call-seq: # status=( new_status ) # # Set the status of the node to +new_status+. ## # :method: status? # :call-seq: # status?( status_name ) # # Returns +true+ if the node's status is +status_name+. state_machine( :status, initial: :unknown ) do state :unknown, :up, :down, :warn, :acked, :disabled, :quieted event :update do transition [:down, :warn, :unknown, :acked] => :up, unless: :has_errors_or_warnings? transition [:up, :warn, :unknown] => :down, if: :has_errors? transition [:up, :down, :unknown] => :warn, if: :has_only_warnings? end event :acknowledge do transition any - [:down] => :disabled transition :down => :acked end event :unacknowledge do transition [:acked, :disabled] => :warn, if: :has_warnings? transition [:acked, :disabled] => :down, if: :has_errors? transition [:acked, :disabled] => :unknown end event :handle_event do transition any - [:disabled, :quieted, :acked] => :quieted, if: :has_quieted_reason? transition :quieted => :unknown, unless: :has_quieted_reason? end event :reparent do transition any - [:disabled, :quieted, :acked] => :unknown transition :quieted => :unknown, unless: :has_quieted_reason? end before_transition [:acked, :disabled] => any, do: :save_previous_ack after_transition any => :acked, do: :on_ack after_transition :acked => :up, do: :on_ack_cleared after_transition :down => :up, do: :on_node_up after_transition :up => :warn, do: :on_node_warn after_transition [:unknown, :warn, :up] => :down, do: :on_node_down after_transition [:acked, :unknown, :warn, :up] => :disabled, do: :on_node_disabled after_transition any => :quieted, do: :on_node_quieted after_transition :disabled => :unknown, do: :on_node_enabled after_transition :quieted => :unknown, do: :on_node_unquieted after_transition any => any, do: :log_transition after_transition any => any, do: :make_transition_event after_transition any => any, do: :update_status_changed after_transition do: :add_status_to_update_delta after_transition do: :record_status_history after_failure do: :record_status_history end ### Return a curried Proc for the ::create method for the specified +type+. def self::curried_create( type ) if type.subnode_type? return self.method( :create ).to_proc.curry( 3 )[ type ] else return self.method( :create ).to_proc.curry( 2 )[ type ] end end ### Overridden to track instances of created nodes for the DSL. def self::new( * ) new_instance = super Arborist::Node.add_loaded_instance( new_instance ) return new_instance end ### Create a new node with its state read from the specified +hash+. def self::from_hash( hash ) return self.new( hash[:identifier] ) do self.marshal_load( hash ) end end ### Record a new loaded instance if the Thread-local variable is set up to track ### them. def self::add_loaded_instance( new_instance ) instances = Thread.current[ LOADED_INSTANCE_KEY ] or return # self.log.debug "Adding new instance %p to node tree" % [ new_instance ] instances << new_instance end ### Inheritance hook -- add a DSL declarative function for the given +subclass+. def self::inherited( subclass ) super body = self.curried_create( subclass ) Arborist.add_dsl_constructor( subclass, &body ) end ### Get/set the node type instances of the class live under. If no parent_type is set, it ### is a top-level node type. If a +block+ is given, it can be used to pre-process the ### arguments into the (identifier, attributes, block) arguments used to create ### the node instances. def self::parent_types( *types, &block ) @parent_types ||= [] types.each do |new_type| subclass = Arborist::Node.get_subclass( new_type ) @parent_types << subclass subclass.add_subnode_factory_method( self, &block ) end return @parent_types end singleton_method_alias :parent_type, :parent_types ### Returns +true+ if the receiver must be created under a specific node type. def self::subnode_type? return ! self.parent_types.empty? end ### Add a factory method that can be used to create subnodes of the specified +subnode_type+ ### on instances of the receiving class. def self::add_subnode_factory_method( subnode_type, &dsl_block ) if subnode_type.name name = subnode_type.plugin_name # self.log.debug "Adding factory constructor for %s nodes to %p" % [ name, self ] body = lambda do |*args, &constructor_block| if dsl_block # self.log.debug "Using DSL block to split args: %p" % [ dsl_block ] identifier, attributes = dsl_block.call( *args ) else # self.log.debug "Splitting args the default way: %p" % [ args ] identifier, attributes = *args end attributes ||= {} # self.log.debug "Identifier: %p, attributes: %p, self: %p" % # [ identifier, attributes, self ] return Arborist::Node.create( name, identifier, self, attributes, &constructor_block ) end define_method( name, &body ) else self.log.info "Skipping factory constructor for anonymous subnode class." end end ### Load the specified +file+ and return any new Nodes created as a result. def self::load( file ) self.log.info "Loading node file %s..." % [ file ] Thread.current[ LOADED_INSTANCE_KEY ] = [] begin Kernel.load( file ) rescue => err self.log.error "%p while loading %s: %s" % [ err.class, file, err.message ] raise end return Thread.current[ LOADED_INSTANCE_KEY ] ensure Thread.current[ LOADED_INSTANCE_KEY ] = nil end ### Return an iterator for all the nodes supplied by the specified +loader+. def self::each_in( loader ) return loader.nodes end ### Create a new Node with the specified +identifier+, which must be unique to the ### loaded tree. def initialize( identifier, *args, &block ) attributes = args.last.is_a?( Hash ) ? args.pop : {} parent_node = args.pop raise "Invalid identifier %p" % [identifier] unless identifier =~ VALID_IDENTIFIER # Attributes of the target @identifier = identifier @parent = parent_node ? parent_node.identifier : '_' @description = nil @tags = Set.new @properties = {} @config = {} @source = nil @children = {} @dependencies = Arborist::Dependency.new( :all ) @flap_threshold = nil @flapping = false @status_history_size = nil # Primary state @status = 'unknown' @status_changed = Time.at( 0 ) @status_last_changed = Time.at( 0 ) @status_history = [] # Attributes that govern state @errors = {} @warnings = {} @ack = nil @previous_ack = nil @last_contacted = Time.at( 0 ) @quieted_reasons = {} # Event-handling @update_delta = Hash.new do |h,k| h[ k ] = Hash.new( &h.default_proc ) end @pending_change_events = [] @subscriptions = {} self.modify( attributes ) self.instance_eval( &block ) if block end ###### public ###### ## # The node's identifier attr_reader :identifier ## # The URI of the source the object was read from attr_reader :source ## # The Hash of nodes which are children of this node, keyed by identifier attr_reader :children ## # Arbitrary attributes attached to this node via the manager API attr_reader :properties ## # The Time the node was last contacted attr_accessor :last_contacted ## # The Time the node's status last changed. attr_accessor :status_changed ## # The previous Time the node's status changed, for duration # calculations between states. attr_accessor :status_last_changed ## # The Hash of last errors encountered by a monitor attempting to update this # node, keyed by the monitor's `key`. attr_accessor :errors ## # The Hash of last warnings encountered by a monitor attempting to update this # node, keyed by the monitor's `key`. attr_accessor :warnings ## # The acknowledgement currently in effect. Should be an instance of Arborist::Node::ACK attr_accessor :ack ## # The acknowledgement previously in effect (if any). attr_accessor :previous_ack ## # The Hash of changes tracked during an #update. attr_reader :update_delta ## # The Array of events generated by the current update event attr_reader :pending_change_events ## # The Hash of Subscription objects observing this node and its children, keyed by # subscription ID. attr_reader :subscriptions ## # The node's secondary dependencies, expressed as an Arborist::Node::Sexp attr_accessor :dependencies ## # The reasons this node was quieted. This is a Hash of text descriptions keyed by the # type of dependency it came from (either :primary or :secondary). attr_reader :quieted_reasons ## # An array of statuses, retained after an update. attr_reader :status_history ## # The current flapping state of this node. attr_predicate_accessor :flapping ### Set the source of the node to +source+, which should be a valid URI. def source=( source ) @source = URI( source ) end ### Return the node family, so observers can know ancestry after ### serialization for custom node types that inherit from this class. def family return :node end ### Set one or more node +attributes+. This should be overridden by subclasses which ### wish to allow their operational attributes to be set/updated via the Tree API ### (+modify+ and +graft+). Supported attributes are: +parent+, +description+, ### +tags+, and +config+. def modify( attributes ) attributes = stringify_keys( attributes ) self.parent( attributes['parent'] ) self.description( attributes['description'] ) self.config( attributes['config'] ) if attributes['tags'] @tags.clear self.tags( attributes['tags'] ) end end # # :section: DSLish declaration methods # These methods are both getter and setter for a node's attributes, used # in the node source. # ### Get/set the node's parent node, which should either be an identifier or an object ### that responds to #identifier with one. def parent( new_parent=nil ) return @parent if new_parent.nil? @parent = if new_parent.respond_to?( :identifier ) new_parent.identifier.to_s else @parent = new_parent.to_s end end ### Get/set the node's description. def description( new_description=nil ) return @description unless new_description @description = new_description.to_s end ### Declare one or more +tags+ for this node. def tags( *tags ) tags.flatten! @tags.merge( tags.map(&:to_s) ) unless tags.empty? return @tags.to_a end ### Group +identifiers+ together in an 'any of' dependency. def any_of( *identifiers, on: nil ) return Arborist::Dependency.on( :any, *identifiers, prefixes: on ) end ### Group +identifiers+ together in an 'all of' dependency. def all_of( *identifiers, on: nil ) return Arborist::Dependency.on( :all, *identifiers, prefixes: on ) end ### Add secondary dependencies to the receiving node. def depends_on( *dependencies, on: nil ) dependencies = self.all_of( *dependencies, on: on ) self.log.debug "Setting secondary dependencies to: %p" % [ dependencies ] self.dependencies = check_dependencies( dependencies ) end ### Returns +true+ if the node has one or more secondary dependencies. def has_dependencies? return !self.dependencies.empty? end ### Get or set the node's configuration hash. This can be used to pass per-node ### information to systems using the tree (e.g., monitors, subscribers). def config( new_config=nil ) @config.merge!( stringify_keys( new_config ) ) if new_config return @config end ### Get or set the number of entries to store for the status ### history. def status_history_size( new_size=nil ) @status_history_size = new_size if new_size return @status_history_size || Arborist::Node.status_history_size || 0 end ### Get or set the number of transitions in the status ### history to determine if a node is considering 'flapping'. def flap_threshold( new_count=nil ) @flap_threshold = new_count if new_count return @flap_threshold || Arborist::Node.flap_threshold || 0 end # # :section: Manager API # Methods used by the manager to manage its nodes. # ### Return the simple type of this node (e.g., Arborist::Node::Host => 'host') def type return 'anonymous' unless self.class.name return self.class.name.sub( /.*::/, '' ).downcase end ### Add the specified +subscription+ (an Arborist::Subscription) to the node. def add_subscription( subscription ) self.subscriptions[ subscription.id ] = subscription end ### Remove the specified +subscription+ (an Arborist::Subscription) from the node. def remove_subscription( subscription_id ) return self.subscriptions.delete( subscription_id ) end ### Return subscriptions matching the specified +event+ on the receiving node. def find_matching_subscriptions( event ) return self.subscriptions.values.find_all {|sub| event =~ sub } end ### Return the Set of identifier of nodes that are secondary dependencies of this node. def node_subscribers self.log.debug "Finding node subscribers among %d subscriptions" % [ self.subscriptions.length ] return self.subscriptions.each_with_object( Set.new ) do |(identifier, sub), set| if sub.respond_to?( :node_identifier ) set.add( sub.node_identifier ) else self.log.debug "Skipping %p: not a node subscription" % [ sub ] end end end ### Update specified +properties+ for the node. def update( new_properties, monitor_key='_' ) self.last_contacted = Time.now self.update_properties( new_properties, monitor_key ) # Super to the state machine event method super events = self.pending_change_events.clone events << self.make_update_event events << self.make_delta_event unless self.update_delta.empty? results = self.broadcast_events( *events ) self.log.debug ">>> Results from broadcast: %p" % [ results ] events.concat( results ) return events ensure self.clear_transition_temp_vars end ### Update the node's properties with those in +new_properties+ (a String-keyed Hash) def update_properties( new_properties, monitor_key ) monitor_key ||= '_' new_properties = stringify_keys( new_properties ) self.log.debug "Updated via a %s monitor: %p" % [ monitor_key, new_properties ] self.update_errors( monitor_key, new_properties.delete('error') ) self.update_warnings( monitor_key, new_properties.delete('warning') ) self.merge_new_properties( new_properties ) end ### Merge the specified Hash of +new_properties+ with the node's current property ### Hash. def merge_new_properties( new_properties ) props = self.properties.dup updated_properties, properties_delta = self.merge_and_record_delta( props, new_properties ) compact_hash( updated_properties ) self.properties.replace( updated_properties ) self.update_delta['properties'] = properties_delta unless properties_delta.empty? end ### Merge the specified +newval+ into the node's properties at the given +key+, recording ### each change in the node's #update_delta if the +oldval+ is different. def merge_and_record_delta( properties, new_properties ) delta = {} new_properties.each_key do |key| newval = new_properties[ key ] oldval = properties[ key ] subdelta = nil # Merge them (recursively) if they're both merge-able if oldval.respond_to?( :merge! ) && newval.respond_to?( :merge! ) newval, subdelta = self.merge_and_record_delta( oldval, newval ) # Otherwise just directly compare them and record any changes elsif oldval != newval subdelta = [ oldval, newval ] end properties[ key ] = newval delta[ key ] = subdelta if subdelta end return properties, delta end ### Update the errors hash for the specified +monitor_key+ to +value+. def update_errors( monitor_key, value=nil ) if value self.errors[ monitor_key ] = value else self.errors.delete( monitor_key ) end end ### Update the warnings hash for the specified +monitor_key+ to +value+. def update_warnings( monitor_key, value=nil ) if value self.warnings[ monitor_key ] = value else self.warnings.delete( monitor_key ) end end ### Acknowledge any current or future abnormal status for this node. def acknowledge( **args ) super() self.ack = args events = self.pending_change_events.clone events << self.make_delta_event unless self.update_delta.empty? results = self.broadcast_events( *events ) self.log.debug ">>> Results from broadcast: %p" % [ results ] events.concat( results ) return events ensure self.clear_transition_temp_vars end ### Clear any current acknowledgement. def unacknowledge super() self.ack = nil events = self.pending_change_events.clone events << self.make_delta_event unless self.update_delta.empty? results = self.broadcast_events( *events ) self.log.debug ">>> Results from broadcast: %p" % [ results ] events.concat( results ) return events ensure self.clear_transition_temp_vars end ### Clear out the state used during a transition to track changes. def clear_transition_temp_vars self.previous_ack = nil self.update_delta.clear self.pending_change_events.clear end ### Return the node's state in an Arborist::Event of type 'node.update'. def make_update_event return Arborist::Event.create( 'node_update', self ) end ### Return an Event generated from the node's state changes. def make_delta_event self.log.debug "Making node.delta event: %p" % [ self.update_delta ] return Arborist::Event.create( 'node_delta', self, self.update_delta ) end ### Returns +true+ if the node's state has changed since the last time ### #snapshot_state was called. def state_has_changed? return ! self.update_delta.empty? end ### Returns +true+ if the specified search +criteria+ all match this node. def matches?( criteria, if_empty: true ) # Omit 'delta' criteria from matches; delta matching is done separately. criteria = criteria.dup criteria.delete( 'delta' ) self.log.debug "Node matching %p (%p if empty)" % [ criteria, if_empty ] return if_empty if criteria.empty? self.log.debug "Matching %p against criteria: %p" % [ self, criteria ] return criteria.all? do |key, val| self.match_criteria?( key, val ) end.tap {|match| self.log.debug " node %s match." % [ match ? "DID" : "did not"] } end ### Returns +true+ if the node matches the specified +key+ and +val+ criteria. def match_criteria?( key, val ) array_val = Array( val ) return case key when 'status' array_val.include?( self.status ) when 'type' array_val.include?( self.type ) when 'family' array_val.include?( self.family.to_s ) when 'parent' array_val.include?( self.parent ) when 'tag' then @tags.include?( val.to_s ) when 'tags' then array_val.all? {|tag| @tags.include?(tag) } when 'identifier' array_val.include?( self.identifier ) when 'config' val.all? {|ikey, ival| hash_matches(@config, ikey, ival) } else hash_matches( @properties, key, val ) end end ### Return a Hash of node state values that match the specified +value_spec+. def fetch_values( value_spec=nil ) state = self.properties.merge( self.operational_values ) state = stringify_keys( state ) state = make_serializable( state ) if value_spec self.log.debug "Eliminating all values except: %p (from keys: %p)" % [ value_spec, state.keys ] state.delete_if {|key, _| !value_spec.include?(key) } end return state end ### Return a Hash of the operational values that are included with the node's ### monitor state. def operational_values values = OPERATIONAL_ATTRIBUTES.each_with_object( {} ) do |key, hash| hash[ key ] = self.send( key ) end return values end ### Register subscriptions for secondary dependencies on the receiving node with the ### given +manager+. def register_secondary_dependencies( manager ) self.dependencies.all_identifiers.each do |identifier| # Check to be sure the identifier isn't a descendant or ancestor if manager.ancestors_for( self ).any? {|node| node.identifier == identifier} raise Arborist::ConfigError, "Can't depend on ancestor node %p." % [ identifier ] elsif manager.descendants_for( self ).any? {|node| node.identifier == identifier } raise Arborist::ConfigError, "Can't depend on descendant node %p." % [ identifier ] end sub = Arborist::NodeSubscription.new( self ) manager.subscribe( identifier, sub ) end end ### Publish the specified +events+ to any subscriptions the node has which match them. def publish_events( *events ) self.log.debug "Got events to publish: %p" % [ events ] self.subscriptions.each_value do |sub| sub.on_events( *events ) end end ### Send an event to this node's immediate children. def broadcast_events( *events ) events.flatten! results = self.children.flat_map do |identifier, child| self.log.debug "Broadcasting events to %p: %p" % [ identifier, events ] events.flat_map do |event| child.handle_event( event ) end end return results end ### Handle the specified +event+, delivered either via broadcast or secondary ### dependency subscription. def handle_event( event ) self.log.debug "Handling event %p" % [ event ] handler_name = "handle_%s_event" % [ event.type.gsub('.', '_') ] if self.respond_to?( handler_name ) self.log.debug "Handling a %s event." % [ event.type ] self.method( handler_name ).call( event ) else self.log.debug "No handler for a %s event!" % [ event.type ] end # Don't transition on informational events return [] if event.informational? super # to state-machine results = self.pending_change_events.clone self.log.debug ">>> Pending change events after: %p" % [ results ] results << self.make_delta_event unless self.update_delta.empty? child_results = self.broadcast_events( *results ) results.concat( child_results ) self.publish_events( *results ) unless results.empty? return results ensure self.clear_transition_temp_vars end ### Move a node from +old_parent+ to +new_parent+. def reparent( old_parent, new_parent ) old_parent.remove_child( self ) self.parent( new_parent.identifier ) new_parent.add_child( self ) self.quieted_reasons.delete( :primary ) super end ### Returns +true+ if this node's dependencies are not met. def dependencies_down? return self.dependencies.down? end alias_method :has_downed_dependencies?, :dependencies_down? ### Returns +true+ if this node's dependencies are met. def dependencies_up? return !self.dependencies_down? end ### Returns +true+ if any reasons have been set as to why the node has been ### quieted. Guard condition for transition to and from `quieted` state. def has_quieted_reason? return !self.quieted_reasons.empty? end ### Handle a 'node.down' event received via broadcast. def handle_node_down_event( event ) self.log.debug "Got a node.down event: %p" % [ event ] self.dependencies.mark_down( event.node.identifier ) if self.dependencies_down? self.quieted_reasons[ :secondary ] = "Secondary dependencies not met: %s" % [ self.dependencies.down_reason ] end if event.node.identifier == self.parent self.quieted_reasons[ :primary ] = "Parent down: %s" % [ self.parent ] # :TODO: backtrace? end end ### Handle a 'node.disabled' event received via broadcast. def handle_node_disabled_event( event ) self.log.debug "Got a node.disabled event: %p" % [ event ] self.dependencies.mark_down( event.node.identifier ) if self.dependencies_down? self.quieted_reasons[ :secondary ] = "Secondary dependencies not met: %s" % [ self.dependencies.down_reason ] end if event.node.identifier == self.parent self.quieted_reasons[ :primary ] = "Parent disabled: %s" % [ self.parent ] end end ### Handle a 'node.quieted' event received via broadcast. def handle_node_quieted_event( event ) self.log.debug "Got a node.quieted event: %p" % [ event ] self.dependencies.mark_down( event.node.identifier ) if self.dependencies_down? self.quieted_reasons[ :secondary ] = "Secondary dependencies not met: %s" % [ self.dependencies.down_reason ] end if event.node.identifier == self.parent self.quieted_reasons[ :primary ] = "Parent quieted: %s" % [ self.parent ] # :TODO: backtrace? end end ### Handle a 'node.up' event received via broadcast. def handle_node_up_event( event ) self.log.debug "Got a node.%s event: %p" % [ event.type, event ] self.dependencies.mark_up( event.node.identifier ) self.quieted_reasons.delete( :secondary ) if self.dependencies_up? if event.node.identifier == self.parent self.log.info "Parent of %s (%s) came back up." % [ self.identifier, self.parent ] self.quieted_reasons.delete( :primary ) end end alias_method :handle_node_warn_event, :handle_node_up_event # # :section: Hierarchy API # ### Enumerable API -- iterate over the children of this node. def each( &block ) return self.children.values.each( &block ) end ### Returns +true+ if the node has one or more child nodes. def has_children? return !self.children.empty? end ### Returns +true+ if the node is considered operational. def operational? return self.identifier.start_with?( '_' ) end ### Returns +true+ if the node's status indicates it shouldn't be ### included by default when traversing nodes. def unreachable? self.log.debug "Testing for reachability; status is: %p" % [ self.status ] return UNREACHABLE_STATES.include?( self.status ) end ### Returns +true+ if the node's status indicates it is included by ### default when traversing nodes. def reachable? return !self.unreachable? end ### Register the specified +node+ as a child of this node, replacing any existing ### node with the same identifier. def add_child( node ) self.log.debug "Adding node %p as a child. Parent = %p" % [ node, node.parent ] raise "%p is not a child of %p" % [ node, self ] if node.parent && node.parent != self.identifier self.children[ node.identifier ] = node end ### Append operator -- add the specified +node+ as a child and return +self+. def <<( node ) self.add_child( node ) return self end ### Unregister the specified +node+ as a child of this node. def remove_child( node ) self.log.debug "Removing node %p from children" % [ node ] return self.children.delete( node.identifier ) end # # :section: Utility methods # ### Return a description of the ack if it's set, or a generic string otherwise. def acked_description return self.ack.description if self.ack return "(unset)" end ### Return a string describing the node's status. def status_description case self.status when 'up', 'down', 'warn' return "%s as of %s" % [ self.status.upcase, self.last_contacted ] when 'acked' return "ACKed %s" % [ self.acked_description ] when 'disabled' return "disabled %s" % [ self.acked_description ] when 'quieted' reasons = self.quieted_reasons.values.join( ',' ) return "quieted: %s" % [ reasons ] when 'unknown' return "in an 'unknown' state" else return "in an unhandled state" end end ### Return a string describing node details; returns +nil+ for the base class. Subclasses ### may override this to add to the output of #inspect. def node_description return nil end ### Return a String representation of the object suitable for debugging. def inspect return "#<%p:%#x [%s] -> %s %p %s %s, %d children, %s>" % [ self.class, self.object_id * 2, self.identifier, self.parent || 'root', self.description || "(no description)", self.node_description.to_s, self.source, self.children.length, self.status_description, ] end # # :section: Serialization API # ### Restore any saved state from the +old_node+ loaded from the state file. This is ### used to overlay selective bits of the saved node tree to the equivalent nodes loaded ### from node definitions. def restore( old_node ) @status = old_node.status @properties = old_node.properties.dup @ack = old_node.ack.dup if old_node.ack @last_contacted = old_node.last_contacted @status_changed = old_node.status_changed @status_history = old_node.status_history @flapping = old_node.flapping? @errors = old_node.errors @warnings = old_node.warnings @quieted_reasons = old_node.quieted_reasons @status_last_changed = old_node.status_last_changed # Only merge in downed dependencies. old_node.dependencies.each_downed do |identifier, time| @dependencies.mark_down( identifier, time ) end end ### Return a Hash of the node's state. If +depth+ is greater than 0, that many ### levels of child nodes are included in the node's `:children` value. Setting ### +depth+ to a negative number will return all of the node's children. def to_h( depth: 0 ) hash = { identifier: self.identifier, type: self.class.name.to_s.sub( /.+::/, '' ).downcase, parent: self.parent, family: self.family, description: self.description, tags: self.tags, config: self.config, status: self.status, properties: self.properties.dup, ack: self.ack ? self.ack.to_h : nil, last_contacted: self.last_contacted ? self.last_contacted.iso8601 : nil, status_changed: self.status_changed ? self.status_changed.iso8601 : nil, status_last_changed: self.status_last_changed ? self.status_last_changed.iso8601 : nil, status_history: self.status_history, flapping: self.flapping?, errors: self.errors, warnings: self.warnings, dependencies: self.dependencies.to_h, quieted_reasons: self.quieted_reasons, } if depth.nonzero? # self.log.debug "including children for depth %p" % [ depth ] hash[ :children ] = self.children.each_with_object( {} ) do |(ident, node), h| h[ ident ] = node.to_h( depth: depth - 1 ) end else hash[ :children ] = {} end return hash end ### Marshal API -- return the node as an object suitable for marshalling. def marshal_dump return self.to_h.merge( dependencies: self.dependencies ) end ### Marshal API -- set up the object's state using the +hash+ from a ### previously-marshalled node. def marshal_load( hash ) self.log.debug "Restoring from serialized hash: %p" % [ hash ] @identifier = hash[:identifier] @properties = hash[:properties] @parent = hash[:parent] @description = hash[:description] @tags = Set.new( hash[:tags] ) @config = hash[:config] @children = {} @status = hash[:status] @status_changed = Time.parse( hash[:status_changed] ) @status_last_changed = Time.parse( hash[:status_last_changed] ) @status_history = hash[:status_history] @flapping = hash[:flapping] @ack = Arborist::Node::Ack.from_hash( hash[:ack] ) if hash[:ack] @errors = hash[:errors] @warnings = hash[:warnings] @properties = hash[:properties] || {} @last_contacted = Time.parse( hash[:last_contacted] ) @quieted_reasons = hash[:quieted_reasons] || {} self.log.debug "Deps are: %p" % [ hash[:dependencies] ] @dependencies = hash[:dependencies] @update_delta = Hash.new do |h,k| h[ k ] = Hash.new( &h.default_proc ) end @pending_change_events = [] @subscriptions = {} end ### Equality operator -- returns +true+ if +other_node+ has the same identifier, parent, and ### state as the receiving one. def ==( other_node ) return \ other_node.identifier == self.identifier && other_node.parent == self.parent && other_node.description == self.description && other_node.tags == self.tags end ######### protected ######### ### Detects if this node is considered 'flapping', returning +true+ if so. def check_flapping return false if self.flap_threshold.zero? runs = self.status_history.each_cons( 2 ).count do |status_1, status_2| status_1 != status_2 end self.log.debug "Observed %d changes in %d samples." % [ runs, self.status_history.size ] return runs >= self.flap_threshold end ### Ack the node with the specified +ack_data+, which should contain def ack=( ack_data ) if ack_data self.log.info "Node %s ACKed with data: %p" % [ self.identifier, ack_data ] @ack = Arborist::Node::Ack.from_hash( ack_data ) else self.log.info "Node %s ACK cleared explicitly" % [ self.identifier ] @ack = nil end self.add_previous_ack_to_update_delta end ### Save off the current acknowledgement so it can be used after transitions ### which unset it. def save_previous_ack self.log.debug "Saving previous ack: %p" % [ self.ack ] self.previous_ack = self.ack end ### Add the previous and current acknowledgement to the delta if either of them ### are set. def add_previous_ack_to_update_delta unless self.ack == self.previous_ack self.log.debug "Adding previous ack to the update delta: %p" % [ self.previous_ack ] self.update_delta[ 'ack' ] = [ self.previous_ack&.to_h, self.ack&.to_h ] end end ### State machine guard predicate -- Returns +true+ if the node has an ACK status set. def ack_set? self.log.debug "Checking to see if this node has been ACKed (it %s)" % [ @ack ? "has" : "has not" ] return @ack ? true : false end ### State machine guard predicate -- returns +true+ if the node has errors. def has_errors? has_errors = ! self.errors.empty? self.log.debug "Checking to see if last contact cleared remaining errors (it %s)" % [ has_errors ? "did not" : "did" ] self.log.debug "Errors are: %p" % [ self.errors ] return has_errors end ### State machine guard predicate -- Returns +true+ if the node has errors ### and does not have an ACK status set. def has_unacked_errors? return self.has_errors? && !self.ack_set? end ### State machine guard predicate -- returns +true+ if the node has warnings. def has_warnings? has_warnings = ! self.warnings.empty? self.log.debug "Checking to see if last contact cleared remaining warnings (it %s)" % [ has_warnings ? "did not" : "did" ] self.log.debug "Warnings are: %p" % [ self.warnings ] return has_warnings end ### State machine guard predicate -- returns +true+ if the node has warnings or errors. def has_errors_or_warnings? return self.has_errors? || self.has_warnings? end ### State machine guard predicate -- returns +true+ if the node has warnings but ### no errors. def has_only_warnings? return self.has_warnings? && ! self.has_errors? end ### Return a string describing the errors that are set on the node. def errors_description return "No errors" if self.errors.empty? return self.errors.map do |key, msg| "%s: %s" % [ key, msg ] end.join( '; ' ) end ### Return a string describing the warnings that are set on the node. def warnings_description return "No warnings" if self.warnings.empty? return self.warnings.map do |key, msg| "%s: %s" % [ key, msg ] end.join( '; ' ) end # # :section: State Callbacks # ### Log every status transition def log_transition( transition ) self.log.debug "Transitioned %s from %s to %s" % [ self.identifier, transition.from, transition.to ] end ### Update the last status change time. def update_status_changed( transition ) self.status_last_changed = self.status_changed self.status_changed = Time.now end ### Queue up a transition event whenever one happens def make_transition_event( transition ) node_type = "node_%s" % [ transition.to ] self.log.debug "Making a %s event for %p" % [ node_type, transition ] self.pending_change_events << Arborist::Event.create( node_type, self ) end ### Callback for when an acknowledgement is set. def on_ack( transition ) self.log.warn "ACKed: %s" % [ self.status_description ] end ### Callback for when an acknowledgement is cleared. def on_ack_cleared( transition ) self.ack = nil self.log.warn "ACK cleared for %s" % [ self.identifier ] end ### Callback for when a node goes from down to up def on_node_up( transition ) self.errors.clear self.log.warn "%s is %s" % [ self.identifier, self.status_description ] end ### Callback for when a node goes from up to down def on_node_down( transition ) self.log.error "%s is %s" % [ self.identifier, self.status_description ] self.update_delta[ 'errors' ] = [ nil, self.errors ] end ### Callback for when a node goes from up to warn def on_node_warn( transition ) self.log.error "%s is %s" % [ self.identifier, self.status_description ] self.update_delta[ 'warnings' ] = [ nil, self.warnings ] end ### Callback for when a node goes from up to disabled def on_node_disabled( transition ) self.errors.clear self.warnings.clear self.log.warn "%s is %s" % [ self.identifier, self.status_description ] end ### Callback for when a node goes from any state to quieted def on_node_quieted( transition ) self.log.warn "%s is %s" % [ self.identifier, self.status_description ] end ### Callback for when a node transitions from quieted to unknown def on_node_unquieted( transition ) self.log.warn "%s is %s" % [ self.identifier, self.status_description ] end ### Callback for when a node goes from disabled to unknown def on_node_enabled( transition ) self.log.warn "%s is %s" % [ self.identifier, self.status_description ] self.ack = nil end ### Add the transition from one state to another to the data used to build ### deltas for the #update event. def add_status_to_update_delta( transition ) self.update_delta[ 'status' ] = [ transition.from, transition.to ] end ### Add a flap change to the delta event. def add_flap_to_update_delta( from, to ) return if from == to self.update_delta[ 'flapping' ] = [ from, to ] end ### Retain the status in the node's history. ### def record_status_history( transition ) retain = self.status_history_size return if retain.zero? pre_state = self.flapping? self.status_history << self.status self.flapping = self.check_flapping current_size = self.status_history.size self.status_history.slice!( 0, current_size - retain ) if current_size >= retain self.add_flap_to_update_delta( pre_state, self.flapping? ) end ####### private ####### ### Check the specified +dependencies+ (an Arborist::Dependency) for illegal dependencies ### and raise an error if any are found. def check_dependencies( dependencies ) identifiers = dependencies.all_identifiers self.log.debug "Checking dependency identifiers: %p" % [ identifiers ] if identifiers.include?( '_' ) raise Arborist::ConfigError, "a node can't depend on the root node" elsif identifiers.include?( self.identifier ) raise Arborist::ConfigError, "a node can't depend on itself" end return dependencies end ### Turn any non-msgpack-able objects in the values of a copy of +hash+ to ### values that can be serialized and return the copy. def make_serializable( hash ) new_hash = hash.dup new_hash.keys.each do |key| val = new_hash[ key ] case val when Hash new_hash[ key ] = make_serializable( val ) when Arborist::Dependency, Arborist::Node::Ack new_hash[ key ] = val.to_h when Time new_hash[ key ] = val.iso8601 end end return new_hash end end # class Arborist::Node