lib/arborist/monitor/snmp/disk.rb
author Katelyn Schiesser <katelyn.schiesser@gmail.com>
Mon, 27 Jul 2020 03:19:20 -0700
changeset 27 02b8e772e0c5
parent 26 54f2f57cc0b0
child 29 40bcd1565627
permissions -rw-r--r--
check for readonly filesystems in SNMP::Disk

# -*- ruby -*-
# vim: set noet nosta sw=4 ts=4 :

require 'arborist/monitor/snmp' unless defined?( Arborist::Monitor::SNMP )

# Disk capacity checks.
#
# Sets all configured mounts with their current usage percentage
# in an attribute named "mounts".
#
class Arborist::Monitor::SNMP::Disk
	include Arborist::Monitor::SNMP

	extend Configurability, Loggability
	log_to :arborist_snmp

	# OIDS required to pull disk information from net-snmp.
	#
	STORAGE_NET_SNMP = {
		path: '1.3.6.1.4.1.2021.9.1.2',
		percent: '1.3.6.1.4.1.2021.9.1.9',
		type: '1.3.6.1.2.1.25.3.8.1.4',
		access: '1.3.6.1.2.1.25.3.8.1.5'
	}

	# The OID that matches a local windows hard disk.
	#
	WINDOWS_DEVICES = [
		'1.3.6.1.2.1.25.2.1.4', # local disk
		'1.3.6.1.2.1.25.2.1.7'  # removables, but we have to include them for iscsi mounts
	]

	# OIDS required to pull disk information from Windows.
	#
	STORAGE_WINDOWS = {
		type: '1.3.6.1.2.1.25.2.3.1.2',
		path: '1.3.6.1.2.1.25.2.3.1.3',
		total: '1.3.6.1.2.1.25.2.3.1.5',
		used: '1.3.6.1.2.1.25.2.3.1.6'
	}

	# The fallback warning capacity.
	WARN_AT = 90

	# Access mode meanings
	ACCESS_READWRITE = 1
	ACCESS_READONLY  = 2

	# Configurability API
	#
	configurability( 'arborist.snmp.disk' ) do
		# What percentage qualifies as a warning
		setting :warn_at, default: WARN_AT

		# If non-empty, only these paths are included in checks.
		#
		setting :include do |val|
			if val
				mounts = Array( val ).map{|m| Regexp.new(m) }
				Regexp.union( mounts )
			end
		end

		# Paths to exclude from checks
		#
		setting :exclude,
			default: [ '^/dev(/.+)?$', '/dev$', '^/net(/.+)?$', '/proc$', '^/run$', '^/sys/', '/sys$' ] do |val|
			mounts = Array( val ).map{|m| Regexp.new(m) }
			Regexp.union( mounts )
		end

		# Paths to alert for read-only status
		setting :readonly_include, default: [ '^/$' ] do |val|
			mounts = Array( val ).map{|m| Regexp.new(m) }
			Regexp.union( mounts )
		end
	end


	### Return the properties used by this monitor.
	###
	def self::node_properties
		used_properties = USED_PROPERTIES.dup
		used_properties << :mounts
		return used_properties
	end


	### Class #run creates a new instance and immediately runs it.
	###
	def self::run( nodes )
		return new.run( nodes )
	end


	### Perform the monitoring checks.
	###
	def run( nodes )
		super do |host, snmp|
			self.gather_disks( host, snmp )
		end
	end


	#########
	protected
	#########

	### Collect mount point usage for +host+ from an existing (and open)
	### +snmp+ connection.
	###
	def gather_disks( host, snmp )
		current_mounts = self.system =~ /windows\s+/i ? self.windows_disks( snmp ) : self.unix_disks( snmp )
		config         = self.identifiers[ host ].last['config'] || {}
		warn_at        = config[ 'warn_at' ] || self.class.warn_at

		self.log.debug self.identifiers[ host ]

		includes = self.format_mounts( config, 'include' ) || self.class.include
		excludes = self.format_mounts( config, 'exclude' ) || self.class.exclude

		current_mounts.reject! do |path, data|
			path = path.to_s
			excludes.match( path ) || ( includes && ! includes.match( path ) )
		end

		errors   = []
		warnings = []
		current_mounts.each_pair do |path, data|
			warn = if warn_at.is_a?( Hash )
				warn_at[ path ] || WARN_AT
			else
				warn_at
			end

			self.log.debug "%s:%s -> %p, warn at %d" % [ host, path, data, warn ]

			if data[ :capacity ] >= warn.to_i
				if data[ :capacity ] >= 100
					errors << "%s at %d%% capacity" % [ path, data[ :capacity ] ]
				else
					warnings << "%s at %d%% capacity" % [ path, data[ :capacity ] ]
				end
			end

			if self.class.readonly_include.match( path ) && data[ :accessmode ] == ACCESS_READONLY
				errors << "%s is mounted read-only." % [ path ]
			end
		end

		# Remove any past mounts that configuration exclusions should
		# now omit.
		mounts = self.identifiers[ host ].last[ 'mounts' ] || {}
		mounts.keys.each{|k| mounts[k] = nil }

		mounts.merge!( current_mounts )

		self.results[ host ] = { mounts: mounts }
		self.results[ host ][ :error ]   = errors.join(', ')   unless errors.empty?
		self.results[ host ][ :warning ] = warnings.join(', ') unless warnings.empty?
	end


	### Return a single regexp for the 'include' or 'exclude' section of
	### resource node's +config+, or nil if nonexistent.
	###
	def format_mounts( config, section )
		list = config[ section ] || return
		mounts = Array( list ).map{|m| Regexp.new(m) }
		return Regexp.union( mounts )
	end


	### Fetch information for Windows systems.
	###
	def windows_disks( snmp )
		oids = [
			STORAGE_WINDOWS[:path],
			STORAGE_WINDOWS[:type],
			STORAGE_WINDOWS[:total],
			STORAGE_WINDOWS[:used]
		]

		paths = snmp.walk( oid: oids[0] ).each_with_object( [] ) do |(_, value), acc|
			acc << value
		end
		types = snmp.walk( oid: oids[1] ).each_with_object( [] ) do |(_, value), acc|
			acc << WINDOWS_DEVICES.include?( value )
		end
		totals = snmp.walk( oid: oids[2] ).each_with_object( [] ) do |(_, value), acc|
			acc << value
		end
		used = snmp.walk( oid: oids[3] ).each_with_object( [] ) do |(_, value), acc|
			acc << value
		end

		disks = {}
		paths.each_with_index do |path, idx|
			next if totals[ idx ].zero?
			next unless types[ idx ]
			disks[ path ] ||= {}
			disks[ path ][ :capacity ] = (( used[idx].to_f / totals[idx] ) * 100).round( 1 )
		end

		return disks
	end


	### Fetch information for Unix/MacOS systems.
	###
	def unix_disks( snmp )
		oids = [ STORAGE_NET_SNMP[:path], STORAGE_NET_SNMP[:percent], STORAGE_NET_SNMP[:access] ]
		paths = snmp.walk( oid: STORAGE_NET_SNMP[:path] ).each_with_object( [] ) do |(_, value), acc|
			acc << value
		end
		capacities = snmp.walk( oid: STORAGE_NET_SNMP[:percent] ).each_with_object( [] ) do |(_, value), acc|
			acc << value
		end
		accessmodes = snmp.walk( oid: STORAGE_NET_SNMP[:access] ).each_with_object( [] ) do |(_, value), acc|
			acc << value
		end

		pairs = paths.each_with_object( {} ).with_index do |(p, acc), idx|
			acc[p] = { capacity: capacities[idx], accessmode: accessmodes[idx] }
		end
		return pairs
	end

end # class Arborist::Monitor::SNMP::Disk