Madagascar / projects / autoSMART / scripts / smart-collector-daemon.pl
Newer Older
f16725e 3 months ago History
384 lines | 14.134kb
Bogdan Timofte authored 3 months ago
1
#!/usr/bin/perl
2
use strict;
3
use warnings;
4
use DBI;
5
use JSON;
6
use File::Slurp;
7
use Getopt::Long;
8
use POSIX qw(strftime);
9
use Time::HiRes qw(sleep);
10

            
11
# autoSMART Collector Daemon
12
# Version: 1.0
13
# Description: Automated SMART data collection daemon
14

            
15
my $config_file;
16
my $debug = (defined $ENV{AUTOSMART_DEBUG} && $ENV{AUTOSMART_DEBUG} eq 'true') ? 1 : 0;
17
my $foreground = 0;
18

            
19
GetOptions(
20
    'config=s' => \$config_file,
21
    'debug'    => \$debug,
22
    'foreground' => \$foreground
23
) or die "Usage: $0 --config <file> [--debug] [--foreground]\n";
24

            
25
if (defined $ENV{AUTOSMART_DEBUG}) {
26
    if ($ENV{AUTOSMART_DEBUG} eq 'true') {
27
        $debug = 1;
28
        log_message("AUTOSMART_DEBUG enabled via /etc/default/autonas or environment");
29
    } else {
30
        $debug = 0;
31
        log_message("AUTOSMART_DEBUG disabled via /etc/default/autonas or environment");
32
    }
33
}
34

            
35
die "Configuration file required\n" unless $config_file;
36
die "Configuration file not found: $config_file\n" unless -f $config_file;
37

            
38
# Load configuration
39
my $config = load_config($config_file);
40
my $node_id = $config->{node}{id} || `hostname -s`;
41
chomp $node_id;
42

            
43
log_message("Starting autoSMART collector daemon on node: $node_id");
44
log_message("Configuration loaded from: $config_file");
45

            
46
# Main collection loop
47
my $last_full_scan = 0;
48
my $scan_interval = $config->{node}{scan_interval} || 300;
49
my $full_scan_interval = $config->{collection}{full_scan_interval} || 3600;
50

            
51
while (1) {
52
    eval {
53
        my $current_time = time();
54
        my $force_full = ($current_time - $last_full_scan) >= $full_scan_interval;
55

            
56
        if ($force_full) {
57
            log_message("Performing full SMART scan (forced)");
58
            $last_full_scan = $current_time;
59
        }
60

            
61
        collect_smart_data($force_full);
62

            
63
    };
64

            
65
    if ($@) {
66
        log_message("ERROR: Collection failed: $@");
67
    }
68

            
69
    log_message("Sleeping for $scan_interval seconds...") if $debug;
70
    sleep($scan_interval);
71
}
72

            
73
sub collect_smart_data {
74
    my ($force_full) = @_;
75

            
76
    log_message("[DEBUG] Starting data collection cycle, force_full=" . ($force_full ? 'true' : 'false')) if $debug;
77

            
78
    # Connect to database
79
    my $dsn = "DBI:Pg:host=$config->{database}{host};dbname=$config->{database}{database}";
80
    log_message("[DEBUG] Connecting to database: $dsn") if $debug;
81

            
82
    my $dbh = DBI->connect($dsn, $config->{database}{user}, $config->{database}{password},
83
                          {RaiseError => 1, AutoCommit => 1})
84
        or die "Database connection failed: $DBI::errstr";
85

            
86
    log_message("✓ Database connected") if $debug;
87

            
88
    # Test database connectivity
89
    if ($debug) {
90
        eval {
91
            my $sth = $dbh->prepare("SELECT COUNT(*) FROM hdd_inventory");
92
            $sth->execute();
93
            my ($count) = $sth->fetchrow_array();
94
            log_message("[DEBUG] Database test: found $count HDDs in inventory");
95

            
96
            $sth = $dbh->prepare("SELECT COUNT(*) FROM hdd_presence WHERE is_current = TRUE");
97
            $sth->execute();
98
            my ($presence_count) = $sth->fetchrow_array();
99
            log_message("[DEBUG] Database test: found $presence_count current HDD presence records");
100
        };
101
        if ($@) {
102
            log_message("[DEBUG] Database test failed: $@");
103
        }
104
    }
105

            
106
    # Scan for devices
107
    my @devices = glob('/dev/sd?');
108
    push @devices, glob('/dev/nvme?n?');
109

            
110
    log_message("[DEBUG] Found " . scalar(@devices) . " potential devices: " . join(', ', @devices)) if $debug;
111

            
112
    foreach my $device (@devices) {
113
        if (-b $device) {
114
            log_message("[DEBUG] Processing block device: $device") if $debug;
115
        } else {
116
            log_message("[DEBUG] Skipping non-block device: $device") if $debug;
117
            next;
118
        }
119

            
120
        eval {
121
            process_device($dbh, $device, $force_full);
122
        };
123

            
124
        if ($@) {
125
            log_message("ERROR processing device $device: $@");
126
        }
127
    }
128

            
129
    $dbh->disconnect();
130
    log_message("Collection cycle complete") if $debug;
131
}
132

            
133
sub process_device {
134
    my ($dbh, $device, $force_full) = @_;
135

            
136
    log_message("[DEBUG] process_device: Processing $device") if $debug;
137

            
138
    # Get SMART data
139
    my $smartctl_cmd = "smartctl -A -i -H $device 2>&1";
140
    log_message("[DEBUG] Running: $smartctl_cmd") if $debug;
141
    my @smart_output = `$smartctl_cmd`;
142
    my $exit_code = $? >> 8;
143

            
144
    if (!@smart_output) {
145
        log_message("[DEBUG] No SMART output for $device") if $debug;
146
        return;
147
    }
148

            
149
    log_message("[DEBUG] Got " . scalar(@smart_output) . " lines of SMART output from $device (exit code: $exit_code)") if $debug;
150

            
151
    # Check if smartctl indicates the device doesn't support SMART
152
    my $smart_output_text = join('', @smart_output);
153
    if ($smart_output_text =~ /SMART support is.*Unavailable|Device does not support SMART|No such device/) {
154
        log_message("[DEBUG] Device $device does not support SMART or is not accessible") if $debug;
155
        return;
156
    }
157

            
158
    my ($model, $serial, $temp, %smart_params);
159

            
160
    foreach my $line (@smart_output) {
161
        chomp $line;
162

            
163
        if ($line =~ /Device Model:\s+(.+)/) {
164
            $model = $1;
165
            log_message("[DEBUG] Found model: $model") if $debug;
166
        } elsif ($line =~ /Serial Number:\s+(.+)/) {
167
            $serial = $1;
168
            log_message("[DEBUG] Found serial: $serial") if $debug;
169
        } elsif ($line =~ /^\s*(\d+)\s+(.+?)\s+0x\w+\s+\d+\s+\d+\s+\d+\s+\w+\s+\w+\s+\w+\s+(\d+)/) {
170
            # Old format: ID ATTRIBUTE_NAME 0xXXXX DDD DDD DDD Pre-fail Always - RAW_VALUE
171
            my ($id, $name, $raw) = ($1, $2, $3);
172
            $name =~ s/\s+/_/g;
173
            $smart_params{$name} = $raw;
174

            
175
            if ($debug && scalar(keys %smart_params) <= 5) {
176
                log_message("[DEBUG] SMART param (old format): $name = $raw");
177
            }
178

            
179
            if ($name =~ /Temperature|Temp/i) {
180
                $temp = $raw if (!defined $temp || $raw > 0);
181
            }
182
        } elsif ($line =~ /^\s*(\d+)\s+(.+?)\s+0x\w+\s+\d+\s+\d+\s+\d+\s+\S+\s+\S+\s+\S+\s+(\d+)/) {
183
            # New format: ID ATTRIBUTE_NAME FLAG VALUE WORST THRESH TYPE UPDATED WHEN_FAILED RAW_VALUE
184
            my ($id, $name, $raw) = ($1, $2, $3);
185
            $name =~ s/\s+/_/g;
186
            $smart_params{$name} = $raw;
187

            
188
            if ($debug && scalar(keys %smart_params) <= 5) {
189
                log_message("[DEBUG] SMART param (new format): $name = $raw");
190
            }
191

            
192
            if ($name =~ /Temperature|Temp/i) {
193
                $temp = $raw if (!defined $temp || $raw > 0);
194
            }
195
        }
196
    }
197

            
198
    if (!$model || !$serial) {
199
        log_message("[DEBUG] Missing critical data for $device - model: " . ($model || 'NULL') . ", serial: " . ($serial || 'NULL')) if $debug;
200
        return;
201
    }
202

            
203
    if (!%smart_params) {
204
        log_message("[DEBUG] No SMART parameters found for $device") if $debug;
205
        return;
206
    }
207

            
208
    log_message("[DEBUG] Parsed device data - Model: $model, Serial: $serial, Temperature: " . ($temp || 'NULL') . ", Parameters: " . scalar(keys %smart_params)) if $debug;
209

            
210
    return unless ($model && $serial && %smart_params);
211

            
212
    log_message("Processing: $model ($serial) @ $device") if $debug;
213

            
214
    # Get or create HDD inventory entry
215
    my $hdd_id = get_or_create_hdd($dbh, $serial, $model, $device);
216

            
217
    # Check if we should store this reading
218
    my $params_json = encode_json(\%smart_params);
219

            
220
    if (!$force_full && !$config->{node}{store_unchanged}) {
221
        # Check for recent identical reading
222
        my $sth = $dbh->prepare("
223
            SELECT id FROM smart_readings
224
            WHERE hdd_id = ? AND parameters_json = ?
225
            AND timestamp > NOW() - INTERVAL '1 hour'
226
            LIMIT 1
227
        ");
228
        $sth->execute($hdd_id, $params_json);
229

            
230
        if ($sth->fetchrow_array()) {
231
            log_message("  Skipping unchanged parameters") if $debug;
232
            return;
233
        }
234
    }
235

            
236
    # Store SMART reading
237
    my $reading_type = $force_full ? 'full' : 'differential';
238

            
239
    my $sth = $dbh->prepare("
240
        INSERT INTO smart_readings (hdd_id, serial_number, device_path, node_id, timestamp, temperature, parameters_json, reading_type)
241
        VALUES (?, ?, ?, ?, NOW(), ?, ?::jsonb, ?)
242
        RETURNING id
243
    ");
244

            
245
    my $reading_id = $dbh->selectrow_array($sth, undef, $hdd_id, $serial, $device, $node_id, $temp || 0, $params_json, $reading_type);
246

            
247
    log_message("  ✓ SMART reading stored (ID: $reading_id, temp: " . ($temp || 0) . "°C, type: $reading_type)") if $debug;
248
}
249

            
250
sub get_or_create_hdd {
251
    my ($dbh, $serial, $model, $device_path) = @_;
252

            
253
    log_message("[DEBUG] get_or_create_hdd: serial=$serial, model=$model, device=$device_path, node=$node_id") if $debug;
254

            
255
    # Check if HDD exists
256
    my $sth = $dbh->prepare("SELECT id FROM hdd_inventory WHERE serial_number = ?");
257
    $sth->execute($serial);
258
    my ($hdd_id) = $sth->fetchrow_array();
259

            
260
    log_message("[DEBUG] HDD lookup result: hdd_id=" . ($hdd_id || 'NULL') . " for serial=$serial") if $debug;
261

            
262
    if ($hdd_id) {
263
        log_message("[DEBUG] Found existing HDD with id=$hdd_id, updating location and presence") if $debug;
264

            
265
        # Update current location in inventory
266
        $dbh->do("UPDATE hdd_inventory SET current_device_path = ?, current_node_id = ?, last_seen = NOW()
267
                  WHERE id = ?", undef, $device_path, $node_id, $hdd_id);
268
        log_message("[DEBUG] Updated hdd_inventory location for hdd_id=$hdd_id") if $debug;
269

            
270
        # Mark all previous hdd_presence as historic for this serial
271
        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);
272
        log_message("[DEBUG] Marked $affected_rows historic hdd_presence records for serial=$serial") if $debug;
273

            
274
        # Check if there is already a current presence for this serial/node
275
        my $sth2 = $dbh->prepare("SELECT id FROM hdd_presence WHERE serial_number = ? AND node = ? AND is_current = TRUE");
276
        $sth2->execute($serial, $node_id);
277
        my ($presence_id) = $sth2->fetchrow_array();
278

            
279
        if ($presence_id) {
280
            log_message("[DEBUG] Found existing presence record id=$presence_id, updating data_end") if $debug;
281
            # Update data_end
282
            $dbh->do("UPDATE hdd_presence SET data_end = NOW() WHERE id = ?", undef, $presence_id);
283
            log_message("[DEBUG] Updated data_end for presence_id=$presence_id") if $debug;
284
        } else {
285
            log_message("[DEBUG] No existing presence for serial=$serial node=$node_id, creating new record") if $debug;
286
            # Create new presence record
287
            $dbh->do("UPDATE hdd_presence SET is_current = FALSE WHERE serial_number = ? AND is_current = TRUE", undef, $serial);
288
            $sth2 = $dbh->prepare("INSERT INTO hdd_presence (serial_number, node, data_start, data_end, is_current) VALUES (?, ?, NOW(), NOW(), TRUE)");
289
            $sth2->execute($serial, $node_id);
290
            my $new_presence_id = $dbh->last_insert_id(undef, undef, 'hdd_presence', undef);
291
            log_message("[DEBUG] Created new hdd_presence record with id=$new_presence_id for serial=$serial node=$node_id") if $debug;
292
        }
293
        return $hdd_id;
294
    }
295
    # Create new HDD entry
296
    log_message("[DEBUG] Creating new HDD entry for serial=$serial model=$model") if $debug;
297
    $sth = $dbh->prepare("
298
        INSERT INTO hdd_inventory (serial_number, model_name, current_device_path, current_node_id,
299
                                   first_seen, last_seen)
300
        VALUES (?, ?, ?, ?, NOW(), NOW())
301
        RETURNING id
302
    ");
303
    my $new_id = $dbh->selectrow_array($sth, undef, $serial, $model, $device_path, $node_id);
304
    log_message("[DEBUG] Created new HDD inventory entry with id=$new_id") if $debug;
305

            
306
    # Mark all previous hdd_presence as historic for this serial
307
    my $affected_rows = $dbh->do("UPDATE hdd_presence SET is_current = FALSE WHERE serial_number = ? AND is_current = TRUE", undef, $serial);
308
    log_message("[DEBUG] Marked $affected_rows historic hdd_presence records for new serial=$serial") if $debug;
309

            
310
    # Create new presence record
311
    my $sth2 = $dbh->prepare("INSERT INTO hdd_presence (serial_number, node, data_start, data_end, is_current) VALUES (?, ?, NOW(), NOW(), TRUE)");
312
    $sth2->execute($serial, $node_id);
313
    my $new_presence_id = $dbh->last_insert_id(undef, undef, 'hdd_presence', undef);
314
    log_message("[DEBUG] Created new hdd_presence record with id=$new_presence_id for new serial=$serial node=$node_id") if $debug;
315

            
316
    return $new_id;
317
}
318

            
319
sub load_config {
320
    my ($file) = @_;
321

            
322
    my $content = read_file($file);
323
    my %config;
324

            
325
    # Simple YAML-like parser
326
    my $current_section;
327
    foreach my $line (split /\n/, $content) {
328
        $line =~ s/^\s+|\s+$//g;
329
        next if $line =~ /^#/ || $line eq '';
330

            
331
        if ($line =~ /^(\w+):$/) {
332
            $current_section = $1;
333
        } elsif ($line =~ /^\s*(\w+):\s*(.+)$/) {
334
            $config{$current_section}{$1} = $2;
335
        }
336
    }
337

            
338
    return \%config;
339
}
340

            
341
sub log_message {
342
    my ($message) = @_;
343
    my $timestamp = strftime("%Y-%m-%d %H:%M:%S", localtime);
344
    print "[$timestamp] $message\n";
345
}
346

            
347
__END__
348

            
349
=head1 NAME
350

            
351
smart-collector-daemon.pl - autoSMART SMART Data Collection Daemon
352

            
353
=head1 SYNOPSIS
354

            
355
smart-collector-daemon.pl --config <config_file> [--debug] [--foreground]
356

            
357
=head1 DESCRIPTION
358

            
359
Automated daemon for collecting SMART data from storage devices and storing
360
in PostgreSQL database with differential storage optimization.
361

            
362
=head1 OPTIONS
363

            
364
=over 4
365

            
366
=item --config <file>
367

            
368
Configuration file path (required)
369

            
370
=item --debug
371

            
372
Enable debug logging
373

            
374
=item --foreground
375

            
376
Run in foreground (don't daemonize)
377

            
378
=back
379

            
380
=head1 AUTHOR
381

            
382
autoSMART v1.0 - Hardware-based HDD tracking system
383

            
384
=cut