lib/arborist/node.rb in arborist-0.0.1.pre20160128152542 vs lib/arborist/node.rb in arborist-0.0.1.pre20160606141735

- old
+ new

@@ -9,10 +9,12 @@ 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 @@ -22,66 +24,119 @@ extend Loggability, Pluggability, 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 struct for the 'ack' operational property - ACK = Struct.new( 'ArboristNodeACK', :message, :via, :sender, :time ) - ## - # The keys required to be set for an ACK - ACK_REQUIRED_PROPERTIES = %w[ message sender ] + 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' + ## + # :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, :acked, - :disabled + :disabled, + :quieted event :update do transition [:down, :unknown, :acked] => :up, if: :last_contact_successful? transition [:up, :unknown] => :down, unless: :last_contact_successful? transition :down => :acked, if: :ack_set? transition [:unknown, :up] => :disabled, if: :ack_set? transition :disabled => :unknown, unless: :ack_set? end + event :handle_event do + transition :unknown => :acked, if: :ack_and_error_set? + transition any - [:disabled, :quieted, :acked] => :quieted, if: :has_quieted_reason? + transition :quieted => :unknown, unless: :has_quieted_reason? + end + 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 [:unknown, :up] => :down, do: :on_node_down after_transition [:unknown, :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 end ### Return a curried Proc for the ::create method for the specified +type+. def self::curried_create( type ) - return self.method( :create ).to_proc.curry( 2 )[ 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( * ) @@ -111,21 +166,53 @@ ### Inheritance hook -- add a DSL declarative function for the given +subclass+. def self::inherited( subclass ) super - if name = subclass.name - name.sub!( /.*::/, '' ) - body = self.curried_create( subclass ) - Arborist.add_dsl_constructor( name, &body ) - else - self.log.info "Skipping DSL constructor for anonymous class." + 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. + def self::parent_types( *types ) + @parent_types ||= [] + + types.each do |new_type| + subclass = Arborist::Node.get_subclass( new_type ) + @parent_types << subclass + subclass.add_subnode_factory_method( self ) 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 ) + if subnode_type.name + name = subnode_type.plugin_name + body = lambda do |identifier, attributes={}, &block| + return Arborist::Node.create( name, identifier, self, attributes, &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 ] = [] @@ -148,35 +235,46 @@ end ### Create a new Node with the specified +identifier+, which must be unique to the ### loaded tree. - def initialize( identifier, &block ) + def initialize( identifier, *args, &block ) + attributes = args.last.is_a?( Hash ) ? args.pop : {} + parent_node = args.pop + raise "Invalid identifier %p" % [identifier] unless - identifier =~ /^\w[\w\-]*$/ + identifier =~ VALID_IDENTIFIER - @identifier = identifier - @parent = '_' - @description = nil - @tags = Set.new - @source = nil - @children = {} + # Attributes of the target + @identifier = identifier + @parent = parent_node ? parent_node.identifier : '_' + @description = nil + @tags = Set.new + @properties = {} + @source = nil + @children = {} + @dependencies = Arborist::Dependency.new( :all ) - @status = 'unknown' - @status_changed = Time.at( 0 ) + # Primary state + @status = 'unknown' + @status_changed = Time.at( 0 ) - @error = nil - @ack = nil - @properties = {} - @last_contacted = Time.at( 0 ) + # Attributes that govern state + @error = nil + @ack = nil + @last_contacted = Time.at( 0 ) + @quieted_reasons = {} - @update_delta = Hash.new do |h,k| + # Event-handling + @update_delta = Hash.new do |h,k| h[ k ] = Hash.new( &h.default_proc ) end @pending_update_events = [] @subscriptions = {} + self.log.debug "Setting node attributes to: %p" % [ attributes ] + self.modify( attributes ) self.instance_eval( &block ) if block end ###### @@ -226,17 +324,44 @@ ## # 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 + + ### Set the source of the node to +source+, which should be a valid URI. def source=( source ) @source = URI( source ) 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+, and + ### +tags+. + def modify( attributes ) + attributes = stringify_keys( attributes ) + + self.parent( attributes['parent'] ) + self.description( attributes['description'] ) + + 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. # @@ -261,15 +386,44 @@ 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 + + + # # :section: Manager API # Methods used by the manager to manage its nodes. # @@ -297,18 +451,10 @@ def find_matching_subscriptions( event ) return self.subscriptions.values.find_all {|sub| event =~ sub } end - ### Publish the specified +events+ to any subscriptions the node has which match them. - def publish_events( *events ) - self.subscriptions.each_value do |sub| - sub.on_events( *events ) - end - end - - ### Update specified +properties+ for the node. def update( new_properties ) new_properties = stringify_keys( new_properties ) self.log.debug "Updated: %p" % [ new_properties ] @@ -327,10 +473,11 @@ events = self.pending_update_events.clone events << self.make_update_event events << self.make_delta_event unless self.update_delta.empty? + self.broadcast_events( *events ) return events ensure self.update_delta.clear self.pending_update_events.clear end @@ -395,10 +542,12 @@ ### Returns +true+ if the node matches the specified +key+ and +val+ criteria. def match_criteria?( key, val ) return case key + when 'delta' + true when 'status' self.status == val when 'type' self.log.debug "Checking node type %p against %p" % [ self.type, val ] self.type == val @@ -438,10 +587,133 @@ 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::Subscription.new do |_, event| + self.handle_event( event ) + end + 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 published events: %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! + self.children.each do |identifier, child| + self.log.debug "Broadcasting %d events to %p" % [ events.length, identifier ] + events.each do |event| + child.handle_event( event ) + end + end + end + + + ### Handle the specified +event+, delivered either via broadcast or secondary + ### dependency subscription. + def handle_event( event ) + self.log.debug "Handling %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 ) + end + 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.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.up event: %p" % [ 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 + + + # # :section: Hierarchy API # ### Enumerable API -- iterate over the children of this node. @@ -488,19 +760,30 @@ # # :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' return "%s as of %s" % [ self.status.upcase, self.last_contacted ] when 'acked' - return "ACKed by %s %s" % [ self.ack.sender, self.ack.time.as_delta ] + return "ACKed %s" % [ self.acked_description ] when 'disabled' - return "disabled by %s %s" % [ self.ack.sender, self.ack.time.as_delta ] + return "disabled %s" % [ self.acked_description ] + when 'quieted' + reasons = self.quieted_reasons.values.join( ',' ) + return "quieted: %s" % [ reasons ] else return "in an unknown state" end end @@ -512,11 +795,11 @@ end ### Return a String representation of the object suitable for debugging. def inspect - return "#<%p:%#x [%s] -> %s %p %s%s, %d children, %s>" % [ + 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)", @@ -530,71 +813,93 @@ # # :section: Serialization API # + ### Restore any saved state from the +old_node+ loaded from the state file. + 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 + @error = old_node.error + @quieted_reasons = old_node.quieted_reasons + + # 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. - def to_hash + def to_h return { identifier: self.identifier, type: self.class.name.to_s.sub( /.+::/, '' ).downcase, parent: self.parent, description: self.description, tags: self.tags, - properties: self.properties.dup, 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, error: self.error, + dependencies: self.dependencies.to_h, + quieted_reasons: self.quieted_reasons, } end ### Marshal API -- return the node as an object suitable for marshalling. def marshal_dump - return self.to_hash + 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 ) - @identifier = hash[:identifier] - @properties = hash[:properties] + @identifier = hash[:identifier] + @properties = hash[:properties] - @parent = hash[:parent] - @description = hash[:description] - @tags = Set.new( hash[:tags] ) - @children = {} + @parent = hash[:parent] + @description = hash[:description] + @tags = Set.new( hash[:tags] ) + @children = {} - @status = hash[:status] - @status_changed = Time.parse( hash[:status_changed] ) + @status = 'unknown' + @status_changed = Time.parse( hash[:status_changed] ) - @error = hash[:error] - @properties = hash[:properties] - @last_contacted = Time.parse( hash[:last_contacted] ) + @error = hash[:error] + @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] - if hash[:ack] - ack_values = hash[:ack].values_at( *Arborist::Node::ACK.members ) - @ack = Arborist::Node::ACK.new( *ack_values ) + @update_delta = Hash.new do |h,k| + h[ k ] = Hash.new( &h.default_proc ) end + + @pending_update_events = [] + @subscriptions = {} + + self.ack = hash[:ack] 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 && - other_node.properties == self.properties && - other_node.status == self.status && - other_node.ack == self.ack && - other_node.error == self.error + other_node.tags == self.tags end ######### protected @@ -602,19 +907,11 @@ ### 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_data['time'] ||= Time.now - ack_values = ack_data.values_at( *Arborist::Node::ACK.members.map(&:to_s) ) - new_ack = Arborist::Node::ACK.new( *ack_values ) - - if missing = ACK_REQUIRED_PROPERTIES.find {|prop| new_ack[prop].nil? } - raise "Missing required ACK attribute %s" % [ missing ] - end - - @ack = new_ack + @ack = Arborist::Node::Ack.from_hash( ack_data ) else self.log.info "Node %s ACK cleared explicitly" % [ self.identifier ] @ack = nil end end @@ -635,10 +932,16 @@ [ self.error ? "wasn't" : "was" ] return !self.error end + ### Returns +true+ if the node has been acked and also has an error set. + def ack_and_error_set? + return self.error && self.ack_set? + end + + # # :section: State Callbacks # ### Log every status transition @@ -646,22 +949,34 @@ 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_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_update_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 ] - self.pending_update_events << - Arborist::Event.create( 'node_acked', self.fetch_values, self.ack.to_h ) end ### Callback for when an acknowledgement is cleared. def on_ack_cleared( transition ) - self.error = nil self.log.warn "ACK cleared for %s" % [ self.identifier ] + self.ack = nil end ### Callback for when a node goes from down to up def on_node_up( transition ) @@ -681,10 +996,22 @@ def on_node_disabled( transition ) 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 ] end @@ -698,23 +1025,22 @@ ####### private ####### - ### Returns true if the specified +hash+ includes the specified +key+, and the value - ### associated with the +key+ either includes +val+ if it is a Hash, or equals +val+ if it's - ### anything but a Hash. - def hash_matches( hash, key, val ) - actual = hash[ key ] or return false + ### 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 - if actual.is_a?( Hash ) - if val.is_a?( Hash ) - return val.all? {|subkey, subval| hash_matches(actual, subkey, subval) } - else - return false - end - else - return actual == val + 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 + end # class Arborist::Node