#!/usr/bin/perl use strict; use warnings; use DBI; use JSON; use File::Slurp; use Getopt::Long; use POSIX qw(strftime); use Time::HiRes qw(sleep); # autoSMART Collector Daemon # Version: 1.0 # Description: Automated SMART data collection daemon my $config_file; my $debug = (defined $ENV{AUTOSMART_DEBUG} && $ENV{AUTOSMART_DEBUG} eq 'true') ? 1 : 0; my $foreground = 0; GetOptions( 'config=s' => \$config_file, 'debug' => \$debug, 'foreground' => \$foreground ) or die "Usage: $0 --config [--debug] [--foreground]\n"; if (defined $ENV{AUTOSMART_DEBUG}) { if ($ENV{AUTOSMART_DEBUG} eq 'true') { $debug = 1; log_message("AUTOSMART_DEBUG enabled via /etc/default/autonas or environment"); } else { $debug = 0; log_message("AUTOSMART_DEBUG disabled via /etc/default/autonas or environment"); } } die "Configuration file required\n" unless $config_file; die "Configuration file not found: $config_file\n" unless -f $config_file; # Load configuration my $config = load_config($config_file); my $node_id = $config->{node}{id} || `hostname -s`; chomp $node_id; log_message("Starting autoSMART collector daemon on node: $node_id"); log_message("Configuration loaded from: $config_file"); # Main collection loop my $last_full_scan = 0; my $scan_interval = $config->{node}{scan_interval} || 300; my $full_scan_interval = $config->{collection}{full_scan_interval} || 3600; while (1) { eval { my $current_time = time(); my $force_full = ($current_time - $last_full_scan) >= $full_scan_interval; if ($force_full) { log_message("Performing full SMART scan (forced)"); $last_full_scan = $current_time; } collect_smart_data($force_full); }; if ($@) { log_message("ERROR: Collection failed: $@"); } log_message("Sleeping for $scan_interval seconds...") if $debug; sleep($scan_interval); } sub collect_smart_data { my ($force_full) = @_; log_message("[DEBUG] Starting data collection cycle, force_full=" . ($force_full ? 'true' : 'false')) if $debug; # Connect to database my $dsn = "DBI:Pg:host=$config->{database}{host};dbname=$config->{database}{database}"; log_message("[DEBUG] Connecting to database: $dsn") if $debug; my $dbh = DBI->connect($dsn, $config->{database}{user}, $config->{database}{password}, {RaiseError => 1, AutoCommit => 1}) or die "Database connection failed: $DBI::errstr"; log_message("✓ Database connected") if $debug; # Test database connectivity if ($debug) { eval { my $sth = $dbh->prepare("SELECT COUNT(*) FROM hdd_inventory"); $sth->execute(); my ($count) = $sth->fetchrow_array(); log_message("[DEBUG] Database test: found $count HDDs in inventory"); $sth = $dbh->prepare("SELECT COUNT(*) FROM hdd_presence WHERE is_current = TRUE"); $sth->execute(); my ($presence_count) = $sth->fetchrow_array(); log_message("[DEBUG] Database test: found $presence_count current HDD presence records"); }; if ($@) { log_message("[DEBUG] Database test failed: $@"); } } # Scan for devices my @devices = glob('/dev/sd?'); push @devices, glob('/dev/nvme?n?'); log_message("[DEBUG] Found " . scalar(@devices) . " potential devices: " . join(', ', @devices)) if $debug; foreach my $device (@devices) { if (-b $device) { log_message("[DEBUG] Processing block device: $device") if $debug; } else { log_message("[DEBUG] Skipping non-block device: $device") if $debug; next; } eval { process_device($dbh, $device, $force_full); }; if ($@) { log_message("ERROR processing device $device: $@"); } } $dbh->disconnect(); log_message("Collection cycle complete") if $debug; } sub process_device { my ($dbh, $device, $force_full) = @_; log_message("[DEBUG] process_device: Processing $device") if $debug; # Get SMART data my $smartctl_cmd = "smartctl -A -i -H $device 2>&1"; log_message("[DEBUG] Running: $smartctl_cmd") if $debug; my @smart_output = `$smartctl_cmd`; my $exit_code = $? >> 8; if (!@smart_output) { log_message("[DEBUG] No SMART output for $device") if $debug; return; } log_message("[DEBUG] Got " . scalar(@smart_output) . " lines of SMART output from $device (exit code: $exit_code)") if $debug; # Check if smartctl indicates the device doesn't support SMART my $smart_output_text = join('', @smart_output); if ($smart_output_text =~ /SMART support is.*Unavailable|Device does not support SMART|No such device/) { log_message("[DEBUG] Device $device does not support SMART or is not accessible") if $debug; return; } my ($model, $serial, $temp, %smart_params); foreach my $line (@smart_output) { chomp $line; if ($line =~ /Device Model:\s+(.+)/) { $model = $1; log_message("[DEBUG] Found model: $model") if $debug; } elsif ($line =~ /Serial Number:\s+(.+)/) { $serial = $1; log_message("[DEBUG] Found serial: $serial") if $debug; } elsif ($line =~ /^\s*(\d+)\s+(.+?)\s+0x\w+\s+\d+\s+\d+\s+\d+\s+\w+\s+\w+\s+\w+\s+(\d+)/) { # Old format: ID ATTRIBUTE_NAME 0xXXXX DDD DDD DDD Pre-fail Always - RAW_VALUE my ($id, $name, $raw) = ($1, $2, $3); $name =~ s/\s+/_/g; $smart_params{$name} = $raw; if ($debug && scalar(keys %smart_params) <= 5) { log_message("[DEBUG] SMART param (old format): $name = $raw"); } if ($name =~ /Temperature|Temp/i) { $temp = $raw if (!defined $temp || $raw > 0); } } elsif ($line =~ /^\s*(\d+)\s+(.+?)\s+0x\w+\s+\d+\s+\d+\s+\d+\s+\S+\s+\S+\s+\S+\s+(\d+)/) { # New format: ID ATTRIBUTE_NAME FLAG VALUE WORST THRESH TYPE UPDATED WHEN_FAILED RAW_VALUE my ($id, $name, $raw) = ($1, $2, $3); $name =~ s/\s+/_/g; $smart_params{$name} = $raw; if ($debug && scalar(keys %smart_params) <= 5) { log_message("[DEBUG] SMART param (new format): $name = $raw"); } if ($name =~ /Temperature|Temp/i) { $temp = $raw if (!defined $temp || $raw > 0); } } } if (!$model || !$serial) { log_message("[DEBUG] Missing critical data for $device - model: " . ($model || 'NULL') . ", serial: " . ($serial || 'NULL')) if $debug; return; } if (!%smart_params) { log_message("[DEBUG] No SMART parameters found for $device") if $debug; return; } log_message("[DEBUG] Parsed device data - Model: $model, Serial: $serial, Temperature: " . ($temp || 'NULL') . ", Parameters: " . scalar(keys %smart_params)) if $debug; return unless ($model && $serial && %smart_params); log_message("Processing: $model ($serial) @ $device") if $debug; # Get or create HDD inventory entry my $hdd_id = get_or_create_hdd($dbh, $serial, $model, $device); # Check if we should store this reading my $params_json = encode_json(\%smart_params); if (!$force_full && !$config->{node}{store_unchanged}) { # Check for recent identical reading my $sth = $dbh->prepare(" SELECT id FROM smart_readings WHERE hdd_id = ? AND parameters_json = ? AND timestamp > NOW() - INTERVAL '1 hour' LIMIT 1 "); $sth->execute($hdd_id, $params_json); if ($sth->fetchrow_array()) { log_message(" Skipping unchanged parameters") if $debug; return; } } # Store SMART reading my $reading_type = $force_full ? 'full' : 'differential'; my $sth = $dbh->prepare(" INSERT INTO smart_readings (hdd_id, serial_number, device_path, node_id, timestamp, temperature, parameters_json, reading_type) VALUES (?, ?, ?, ?, NOW(), ?, ?::jsonb, ?) RETURNING id "); my $reading_id = $dbh->selectrow_array($sth, undef, $hdd_id, $serial, $device, $node_id, $temp || 0, $params_json, $reading_type); log_message(" ✓ SMART reading stored (ID: $reading_id, temp: " . ($temp || 0) . "°C, type: $reading_type)") if $debug; } sub get_or_create_hdd { my ($dbh, $serial, $model, $device_path) = @_; log_message("[DEBUG] get_or_create_hdd: serial=$serial, model=$model, device=$device_path, node=$node_id") if $debug; # Check if HDD exists my $sth = $dbh->prepare("SELECT id FROM hdd_inventory WHERE serial_number = ?"); $sth->execute($serial); my ($hdd_id) = $sth->fetchrow_array(); log_message("[DEBUG] HDD lookup result: hdd_id=" . ($hdd_id || 'NULL') . " for serial=$serial") if $debug; if ($hdd_id) { log_message("[DEBUG] Found existing HDD with id=$hdd_id, updating location and presence") if $debug; # Update current location in inventory $dbh->do("UPDATE hdd_inventory SET current_device_path = ?, current_node_id = ?, last_seen = NOW() WHERE id = ?", undef, $device_path, $node_id, $hdd_id); log_message("[DEBUG] Updated hdd_inventory location for hdd_id=$hdd_id") if $debug; # Mark all previous hdd_presence as historic for this serial my $affected_rows = $dbh->do("UPDATE hdd_presence SET is_current = FALSE WHERE serial_number = ? AND is_current = TRUE AND node <> ?", undef, $serial, $node_id); log_message("[DEBUG] Marked $affected_rows historic hdd_presence records for serial=$serial") if $debug; # Check if there is already a current presence for this serial/node my $sth2 = $dbh->prepare("SELECT id FROM hdd_presence WHERE serial_number = ? AND node = ? AND is_current = TRUE"); $sth2->execute($serial, $node_id); my ($presence_id) = $sth2->fetchrow_array(); if ($presence_id) { log_message("[DEBUG] Found existing presence record id=$presence_id, updating data_end") if $debug; # Update data_end $dbh->do("UPDATE hdd_presence SET data_end = NOW() WHERE id = ?", undef, $presence_id); log_message("[DEBUG] Updated data_end for presence_id=$presence_id") if $debug; } else { log_message("[DEBUG] No existing presence for serial=$serial node=$node_id, creating new record") if $debug; # Create new presence record $dbh->do("UPDATE hdd_presence SET is_current = FALSE WHERE serial_number = ? AND is_current = TRUE", undef, $serial); $sth2 = $dbh->prepare("INSERT INTO hdd_presence (serial_number, node, data_start, data_end, is_current) VALUES (?, ?, NOW(), NOW(), TRUE)"); $sth2->execute($serial, $node_id); my $new_presence_id = $dbh->last_insert_id(undef, undef, 'hdd_presence', undef); log_message("[DEBUG] Created new hdd_presence record with id=$new_presence_id for serial=$serial node=$node_id") if $debug; } return $hdd_id; } # Create new HDD entry log_message("[DEBUG] Creating new HDD entry for serial=$serial model=$model") if $debug; $sth = $dbh->prepare(" INSERT INTO hdd_inventory (serial_number, model_name, current_device_path, current_node_id, first_seen, last_seen) VALUES (?, ?, ?, ?, NOW(), NOW()) RETURNING id "); my $new_id = $dbh->selectrow_array($sth, undef, $serial, $model, $device_path, $node_id); log_message("[DEBUG] Created new HDD inventory entry with id=$new_id") if $debug; # Mark all previous hdd_presence as historic for this serial my $affected_rows = $dbh->do("UPDATE hdd_presence SET is_current = FALSE WHERE serial_number = ? AND is_current = TRUE", undef, $serial); log_message("[DEBUG] Marked $affected_rows historic hdd_presence records for new serial=$serial") if $debug; # Create new presence record my $sth2 = $dbh->prepare("INSERT INTO hdd_presence (serial_number, node, data_start, data_end, is_current) VALUES (?, ?, NOW(), NOW(), TRUE)"); $sth2->execute($serial, $node_id); my $new_presence_id = $dbh->last_insert_id(undef, undef, 'hdd_presence', undef); log_message("[DEBUG] Created new hdd_presence record with id=$new_presence_id for new serial=$serial node=$node_id") if $debug; return $new_id; } sub load_config { my ($file) = @_; my $content = read_file($file); my %config; # Simple YAML-like parser my $current_section; foreach my $line (split /\n/, $content) { $line =~ s/^\s+|\s+$//g; next if $line =~ /^#/ || $line eq ''; if ($line =~ /^(\w+):$/) { $current_section = $1; } elsif ($line =~ /^\s*(\w+):\s*(.+)$/) { $config{$current_section}{$1} = $2; } } return \%config; } sub log_message { my ($message) = @_; my $timestamp = strftime("%Y-%m-%d %H:%M:%S", localtime); print "[$timestamp] $message\n"; } __END__ =head1 NAME smart-collector-daemon.pl - autoSMART SMART Data Collection Daemon =head1 SYNOPSIS smart-collector-daemon.pl --config [--debug] [--foreground] =head1 DESCRIPTION Automated daemon for collecting SMART data from storage devices and storing in PostgreSQL database with differential storage optimization. =head1 OPTIONS =over 4 =item --config Configuration file path (required) =item --debug Enable debug logging =item --foreground Run in foreground (don't daemonize) =back =head1 AUTHOR autoSMART v1.0 - Hardware-based HDD tracking system =cut