Subversion Repositories sysadmin_scripts

Rev

Blame | Last modification | View Log | Download | RSS feed

#! /usr/bin/env perl

# getDriveInfo
# 
# Script to get information on drives for all Unix systems. It will retrieve 
# manufacturing information, SMART information and, with software RAID or 
# ZFS file systems, membership.
#
# Also looks for a file, /etc/drive_bays to determine which physical bay a
# drive is installed in (tab separated, first line header's 'bay' and 'serial'
# followed by the data).
#
# generates a tab separated table of all values retrieved
#
# Copyright (c) 2024, Daily Data, Inc.
# 
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions
# are met:
# 
#   Redistributions of source code must retain the above copyright notice,
#   this list of conditions and the following disclaimer.
#   Redistributions in binary form must reproduce the above copyright notice,
#   this list of conditions and the following disclaimer in the documentation
#   and/or other materials provided with the distribution.
# 
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
# THE POSSIBILITY OF SUCH DAMAGE.
#
# v0.1 RWR 20240708
# Added ability to read tab separated list of bays and serial numbers 
# in /etc/drive_bays to populate the physical bay's a drive is in. Format
# (\t means "tab"):
# bay\tserial
# 0\tserial number of drive 0
# 1\tserial number of drive in bay 1
# bay can be any string, so in one case we use 'internal' as the bay number

use strict;
use warnings;

use Data::Dumper;

my $TESTING = 0;

my $driveDefinitions; # hashref is global that everything will put info into

my $ignoreDriveTypes = { # a list of fake drive "types" that we just ignore.
         'VBOX HARDDISK' => 1, # virtualbox
         'CTLDISK' => 1,   # iSCSI drive
         };

# check for commands we want to run, so some of the must be on the
# system
my $commands = {
                  'smartctl' => '',
                  'lsblk' => '',
                  'geom' => '',
                  'zpool' => '',
                  'mdadm' => '',
                  'glabel' => '',
                  
               };
               

# remove leading and trailing spaces from string
sub trim {
 my $value = shift;
 $value =~ s/^\s+|\s+$//g if $value;
 return $value;
}

# cleanUp - passed a delimiter and a string, does the following (delimiter can be '')
#           chomps the string (removes trailing newlines)
#           removes all text BEFORE the delimiter, the delimiter, and any whitespace
#           thus, the string 'xxI Am x  a weird string' with a newline will become
#           'a weird string' with no newline
# will also look for single and double quotes surrounding entire string and remove them
# if they exist

sub cleanUp {
   my ($delimiter, $text) = @_;
   chomp $text;
   if ( $delimiter && $text =~ m/[^$delimiter]*$delimiter\s*(.*)/ ) {
      $text = $1;
   }
   if ( $text =~ m/^'(.*)'$/ ) {
      $text = $1;
   }
   if ( $text =~ m/^"(.*)"$/ ) {
      $text = $1;
   }
   return $text;
}


# checks if a command is valid on this system. If so, returns the full path to it
# else, returns empty string.
sub validCommandOnSystem {
   my $command = shift;
   $command = `which $command 2> /dev/null`;
   chomp $command;
   return -x $command ? $command : '';
}

# returns the operation system, all lower case
sub getOperatingSystem {
   return lc &cleanUp('', qx(uname -s));
}   


# acquires information using lsblk (Linux), if it is available
# uses global %driveDefinitions to store the results
sub lsblk {
   eval ( 'use JSON qw( decode_json );' );
   if ( $@ ) {
      warn "Could not load JSON library\n";    
      return;
   }
   my $output;
   if ( $TESTING ) {
      $output = `cat lsblk`; 
   } else {
      $output = qx'lsblk -bdJO 2>/dev/null';
      # older versions do not have the O option, so we'll run it without
      $output = qx'lsblk -bdJ 2>/dev/null' if $?;
   }
   my $drives = decode_json( join( '', $output ) );
   $drives = $drives->{'blockdevices'};
   while ( my $thisDrive = shift @{$drives} ) {
      if ( $thisDrive->{'type'} eq 'disk' ) {
         my $key = '/dev/' . $thisDrive->{'name'};
         $driveDefinitions->{$key}->{'capacity'} = $thisDrive->{'size'};
         $driveDefinitions->{$key}->{'model'} = defined $thisDrive->{'model'} ? $thisDrive->{'model'} : '';
         $driveDefinitions->{$key}->{'serial'} = defined( $thisDrive->{'serial'} ) ? $thisDrive->{'serial'} : '' ;
         $driveDefinitions->{$key}->{'sector'} = '';
      }
   }
}

# Acquires disk information if it available (FreeBSD)
# uses global %driveDefinitions to store the results
sub geom {
   my %disks;
   my @data = `geom disk list`;
   
   my %conversion = (
      'ident' => 'serial',
      'descr' => 'model',
      'Mediasize' => 'capacity',
      'Sectorsize' => 'sector'
      );
   
   my $currentDisk = '';
   for ( my $i = 0; $i < @data; $i++ ) {
      if ( $data[$i] =~ m/^Geom name:\s+(.*)/ ) {
         $currentDisk = &fixDisk($1);
      } elsif ( $data[$i] =~ m/^\s+([^:]+):\s+(.*)/ ) {
        $driveDefinitions->{$currentDisk}->{$conversion{$1}} = $2 if $conversion{$1};
      }
   }
   return 1;
}

# determines what to call to get drive definitions
sub getDriveDefinitions {
   if ( $commands->{'lsblk'} ) { # must be a Linux system
      &lsblk();
   } elsif ( $commands->{'geom'} ) { # FreeBSD
      &geom();
   } else {
      die "Could not locate lsblk or geom to get list of disks, aborting\n";
   }
   return 1;
}

# Actual call to smartctl
sub getSmartInformationReport {
   my ($drive, $type) = @_;
   $type = '' if ( $type =~ m/scsi/ );
   my @report = `smartctl -j -a $drive $type`;
   chomp @report;
   my %reportHash;
   for ( my $i = 0; $i < @report; $i++ ) {
      if ( $report[$i] =~ m/^(.*):(.*)$/ ) {
         $reportHash{$1} = trim($2);
      }
   }
   return \%reportHash;
} # getSmartInformationReport



sub getSmartInformation {
   my ( $drive,$type ) = @_;
   # my $report = &getSmartInformationReport( $drive, $type );
   $type = $type eq 'scsi' ? '' : "-t $type";
   my $report = &getJson( "smartctl -j $type -a $drive" );
   #print Dumper( $report ); die;
   # this is what we look for in the smart report
   $driveDefinitions->{$drive}->{'make'} = defined( $report->{'model_name'} ) ? $report->{'model_name'} : '';
   $driveDefinitions->{$drive}->{'model'} = defined( $report->{'model_family'} ) ? $report->{'model_family'} : '';
   $driveDefinitions->{$drive}->{'serial'} = defined( $report->{'serial_number'} ) ? $report->{'serial_number'} : '';
   $driveDefinitions->{$drive}->{'capacity'} = defined(    $report->{'user_capacity'}->{'bytes'} ) ?    $report->{'user_capacity'}->{'bytes'} : '';
   $driveDefinitions->{$drive}->{'sector'} = defined( $report->{'logical_block_size'} ) ? $report->{'logical_block_size'} : '';
   $driveDefinitions->{$drive}->{'rotation'} = defined( $report->{'rotation_rate'} ) ? $report->{'rotation_rate'} : '';

   $driveDefinitions->{$drive}->{'reboots'} = defined( $report->{'power_cycle_count'} ) ? $report->{'power_cycle_count'} : '';
   $driveDefinitions->{$drive}->{'temperature'} = defined( $report->{'temperature'}->{'current'} ) ? $report->{'temperature'}->{'current'} : '';
   $driveDefinitions->{$drive}->{'form_factor'} = defined( $report->{'form_factor'}->{'name'} ) ? $report->{'form_factor'}->{'name'} : '';
   $driveDefinitions->{$drive}->{'power_on_hours'} = defined( $report->{'power_on_time'}->{'hours'} ) ? $report->{'power_on_time'}->{'hours'} : '';

   my $toCheck = {
      '241' => 'lba_write',
      '233' => 'wearout',
      '9' => 'hours_on',
      '12' => 'power_cycle'
   };

   # make sure there are (blank) entries for everything
   foreach my $this ( keys %$toCheck ) {
      $driveDefinitions->{$drive}->{$toCheck->{$this}} = '';
   }
   # Now, fill in when we can find it
   foreach my $entry ( @{$report->{'ata_smart_attributes'}->{'table'}} ) {
      if ( defined( $toCheck->{$entry->{'id'}} ) ) {
         $driveDefinitions->{$drive}->{$toCheck->{$entry->{'id'}} . '_raw' } = $entry->{'raw'}->{'value'};
         $driveDefinitions->{$drive}->{$toCheck->{$entry->{'id'}} } = $entry->{'value'};
      }
   }


   return 1;
   my %keys = ( 
                  'Rotation Rate' => { 
                                          'tag' => 'rotation',
                                          'regex' => '(.*)'
                                      },
               );
   foreach my $key ( keys %keys ) {
      if ( defined( $report->{$key} ) && $report->{$key} =~ m/$keys{$key}->{'regex'}/ ) {
         $driveDefinitions->{$drive}->{$keys{$key}->{'tag'}} = $1;
      }
   }
   return 1;
}

# reads a JSON command and decodes it
sub getJson {
   my $command = shift;
   eval ( 'use JSON qw( decode_json );' );
   if ( $@ ) {
      warn "Could not load JSON library when processing [$command]\n";    
      return;
   }
   my $output = qx/$command/;
   return decode_json( join( '', $output ) );
}


sub getSmart {
   # by running scan, we get the type of drive which we are looking at
   #my %allDrives = map { $_ =~ '(^[a-z0-9/]+)\s+(.*)\#'; ($1,$2) } `smartctl --scan`;
   my $drives = getJson( 'smartctl -j --scan' );
   $drives = $drives->{'devices'};
   my %allDrives;
   foreach my $thisDrive ( @$drives ) {
      $allDrives{$thisDrive->{'name'} } =  $thisDrive->{'type'} if $thisDrive->{'type'} ne 'atacam';
   }

   # Add smart information to the definitions,
   foreach my $thisDrive ( sort keys %allDrives ) {
      $driveDefinitions->{$thisDrive}->{'type'} = $allDrives{$thisDrive};
      &getSmartInformation( $thisDrive, $driveDefinitions->{$thisDrive}->{'type'} );
      $driveDefinitions->{$thisDrive}->{'capacity'} =~ s/,//g;
   }
   return 1;
}

sub mdadm {
   my @mdstat;
   if ( $TESTING ) {
      @mdstat = `cat mdstat`;
   } else {
      @mdstat = `cat /proc/mdstat`;
   }
   for ( my $line = 0; $line < @mdstat; $line++ ) {
      if ( $mdstat[$line] =~ m/^(md\d+)[\s:]+active (raid\d+)\s(.*)$/ ) { # this is the definition of an MD
         my $md = $1;
         my $raidLevel = $2;
         my @drives = split( /\s/, $3 );
         foreach my $currentDisk ( @drives ) {
            $currentDisk =~ m/^([a-z]+)(\d?)\[(\d+)\]/;
            my $disk = &fixDisk( $1 );
            my $component = $3;
            $driveDefinitions->{$disk}->{'vdev_type'} = $raidLevel;
            $driveDefinitions->{$disk}->{'pool'} = $md;
            $driveDefinitions->{$disk}->{'component'} = $3;
         }
      } # if it is a RAID member
   } # for $line
   return 1;
}

# some BSD systems will have partitions labeled before they
# are put into a zfs file system. This will build a translation
# for them
# returns 'label' => 'device'
# 'gptid/c73b7eae-f242-11ed-b0df-ecf4bbdad680' => 'da0p1'
sub glabel {
   my $labels;
   my @drives = `glabel status -s`;
   for ( my $i = 0; $i < @drives; $i++ ) {
      my @fields = split( /\s+/, $drives[$i] );
      $labels->{$fields[0]} = '/dev/' . $fields[2];
   }
   return $labels;
}

# ensure /dev/ in front of disk name   
sub fixDisk {
   my $disk = shift;
   my $partition = '';
   if ( $disk =~ m|^(/dev/)?([a-z]{2}\d+)p(\d+)$| ) { # BSD style
      $disk = "/dev/$2";
      $partition = $3;
   } elsif ( $disk =~ m|^(/dev/)?([a-z]{3})(\d+)$| ) { # linux style
      $disk = "/dev/$2";
      $partition = $3;
   }
   $disk = '/dev/' . $disk unless $disk =~ m/\/dev\//;
   return $disk
}

sub zfs {
   # grab a list of all pools and associated drives
   my @zpool = `zpool status`;
   # grab label to drive information
   my $labels = &glabel();
   my $i=0;
   while ( $i < @zpool ) {
      # bypass everything up to the pool: flag
      while ( $i < @zpool && $zpool[$i] !~ m/^\s*pool:\s*(\S+)/ ) {
         $i++;
      }
      last unless $i < @zpool;
      $zpool[$i] =~ m/^\s*pool:\s*(\S+)/;
      my $currentPool = $1;
      # bypass everything up to the headers
      while ( $i < @zpool && $zpool[$i] !~ m/^(\s+)NAME\s+STATE/ ) {
         $i++;
      }
      $zpool[$i] =~ m/^(\s+)NAME\s+STATE/;
      # the number of spaces preceding NAME is the number of spaces preceding the pool name
      my $poolNameIndent = $1;
      $i++;
      # The final loop will go through all the items which are in the pool configurations
      # and parse them out.
      my $currentComponent = '';
      my $poolType = '';
      my $poolTypeIndent = '';
      my $poolDeviceIndent = '';
      while ( $zpool[$i] =~ m/^$poolNameIndent(.*)/ ) {
         my $line = $1;
         if ( $line =~ m/^([a-zA-Z0-9_-]+)/ ) { # this is a pool name
            $currentComponent = $1;
         } elsif ( ! $poolTypeIndent && $line =~ m/^(\s+)([a-zA-Z0-9_-]+)/ ) {
            $poolTypeIndent = $1;
            $poolType = $2;
         } elsif ( $line =~ m/^$poolTypeIndent([a-zA-Z0-9_-]+)/ ) {
            $poolType = $1;
         } elsif ( ! $poolDeviceIndent && $line =~ m/^(\s+)([a-zA-Z0-9_\/-]+)/ ) {
            my $disk = &fixDisk( $labels->{$2} ? $labels->{$2} : $2 );
            $poolDeviceIndent = $1;
            $driveDefinitions->{$disk}->{'vdev_type'} = $poolType;
            $driveDefinitions->{$disk}->{'pool'} = $currentPool;
            $driveDefinitions->{$disk}->{'component'} = $currentComponent;
         } elsif ( $line =~ m/^$poolDeviceIndent([a-zA-Z0-9_\/-]+)/ ) {
            my $disk = &fixDisk($labels->{$1} ? $labels->{$1} : $1);
            $driveDefinitions->{$disk}->{'vdev_type'} = $poolType;
            $driveDefinitions->{$disk}->{'pool'} = $currentPool;
            $driveDefinitions->{$disk}->{'component'} = $currentComponent;
         }
         $i++;
      } # while
   } # while
   return 1;
}

sub getUsage {
   foreach my $drive ( keys %$driveDefinitions ) {
      $driveDefinitions->{$drive}->{'vdev_type'} = '';
      $driveDefinitions->{$drive}->{'pool'} = '';
      $driveDefinitions->{$drive}->{'component'} = '';
   }
   if ( $TESTING || $commands->{'mdadm'} ) {
      mdadm();
   } elsif ( $commands->{'zpool'} ) {
      zfs();
   }
      
   return 1;
}

# if /etc/drive_bays exists and is in proper format
# include the location of each drive
sub getDriveBays {
   my $driveBays = ();
   if ( -f '/etc/drive_bays' ) {
      open DATA, "</etc/drive_bays";
      my $line = <DATA>;
      while ( my $line = <DATA> ) {
         chomp $line;
         next unless $line;
         my ($bay, $serial) = split( "\t", $line );
         
         $driveBays->{$serial} = $bay;
      }
   }
   foreach my $drive ( keys %{$driveDefinitions} ) {
      $driveDefinitions->{$drive}->{'bay'} = defined( $driveBays->{$driveDefinitions->{$drive}->{'serial'}} ) ? $driveBays->{$driveDefinitions->{$drive}->{'serial'}} : '';
   }
   return 1;
}


sub report {
   my @headers;
   my @report;

   for my $drive ( sort keys %$driveDefinitions ) {
      my @line;
      # don't print iSCSI definitions
      next if defined( $driveDefinitions->{$drive}->{'Transport protocol'} ) && $driveDefinitions->{$drive}->{'Transport protocol'} eq 'ISCSI';
      #also, blow off our ignored types
      next if ( defined( $driveDefinitions->{$drive}->{'Model'} ) && defined( $ignoreDriveTypes->{ $driveDefinitions->{$drive}->{'Model'} }  ) );
      
      # remove comma's from capacity
      $driveDefinitions->{$drive}->{'Capacity'} =~ s/,//g if $driveDefinitions->{$drive}->{'Capacity'};
      push @line, $drive;
      unless ( @headers ) {
         # for some reason, doing this the fast way (with keys) did not work. some of the keys were joined together
         # so, doing it this way.
         push @headers, 'drive';
         foreach my $header ( sort keys %{$driveDefinitions->{$drive}} ) {
            $header =~ s/[^a-z0-9_]//gi;
            push @headers, $header;
         }
         #@headers = sort keys %{$driveDefinitions->{$drive}} unless @headers;
         #unshift @headers,'drive';
      }
      foreach my $key ( sort keys %{$driveDefinitions->{$drive}} ) {
         push @line,$driveDefinitions->{$drive}->{$key};
      }
      push @report, join( "\t", @line );;
   }
   unshift @report,join ("\t",@headers);
   return join( "\n", @report ) . "\n";
   #return Dumper( $driveDefinitions );;
}

# check the commands for validity
foreach my $command ( keys %$commands ) {
   $commands->{$command} = &validCommandOnSystem( $command );
}

my $os = &getOperatingSystem();

# get the drives in the machine with minimal information
&getDriveDefinitions( $driveDefinitions );

# figure out what the drives are used for, ie mdadm or zfs or something
&getUsage( );

&getSmart();

&getDriveBays();

print &report( $driveDefinitions );

1;