Newer Older
f16725e 3 months ago History
802 lines | 22.864kb
Bogdan Timofte authored 3 months ago
1
package SmartCollector;
2

            
3
use strict;
4
use warnings;
5
use DBI;
6
use JSON::XS;
7
use Time::HiRes qw(time);
8
use File::Slurp;
9
use Config::Simple;
10
use Digest::SHA qw(sha256_hex);
11

            
12
=head1 NAME
13

            
14
SmartCollector - SMART data collection module for autoSMART
15

            
16
=head1 DESCRIPTION
17

            
18
This module handles the collection of SMART data from HDDs identified in Madagascar inventory,
19
processes the data, and stores it in PostgreSQL for long-term analysis and AI predictions.
20

            
21
=head1 SYNOPSIS
22

            
23
    use SmartCollector;
24

            
25
    my $collector = SmartCollector->new(
26
        config_file => '/path/to/smart.conf',
27
        db_config   => '/path/to/database.conf'
28
    );
29

            
30
    # Collect data from all monitored drives
31
    $collector->collect_all();
32

            
33
    # Collect data from specific drive
34
    $collector->collect_drive('/dev/sda');
35

            
36
=cut
37

            
38
sub new {
39
    my ($class, %args) = @_;
40

            
41
    my $self = {
42
        cluster_config => $args{cluster_config} || '/etc/pve/autoSMART/cluster.conf',
43
        local_config   => $args{local_config} || '/etc/default/autosmart',
44
        debug          => $args{debug} || 0,
45
        node_id        => $args{node_id} || `hostname`,
46
        smart_params   => {},
47
        db_handle      => undef,
48
        local_settings => {},
49
    };
50

            
51
    chomp $self->{node_id};
52

            
53
    bless $self, $class;
54
    $self->_load_local_config();
55
    $self->_load_cluster_config();
56
    $self->_connect_database();
57

            
58
    return $self;
59
}
60

            
61
=head2 _load_local_config
62

            
63
Load local node-specific configuration from /etc/default/autosmart
64

            
65
=cut
66

            
67
sub _load_local_config {
68
    my $self = shift;
69

            
70
    return unless -f $self->{local_config};
71

            
72
    open my $fh, '<', $self->{local_config}
73
        or die "Cannot read local config: $self->{local_config}: $!";
74

            
75
    while (my $line = <$fh>) {
76
        chomp $line;
77
        next if $line =~ /^\s*#/ || $line =~ /^\s*$/;
78

            
79
        if ($line =~ /^(\w+)=(.+)$/) {
80
            my ($key, $value) = ($1, $2);
81
            $value =~ s/^["']|["']$//g;  # Remove quotes
82
            $self->{local_settings}->{$key} = $value;
83
        }
84
    }
85

            
86
    close $fh;
87

            
88
    # Apply debug settings
89
    if ($self->{local_settings}->{AUTOSMART_DEBUG_ENABLED} eq 'true') {
90
        $self->{debug} = $self->{local_settings}->{AUTOSMART_DEBUG_LEVEL} || 1;
91
    }
92

            
93
    $self->_log("Loaded local configuration from $self->{local_config}");
94
}
95

            
96
=head2 _load_cluster_config
97

            
98
Load cluster-wide configuration from Proxmox shared storage
99

            
100
=cut
101

            
102
sub _load_cluster_config {
103
    my $self = shift;
104

            
105
    unless (-f $self->{cluster_config}) {
106
        die "Cluster configuration not found: $self->{cluster_config}";
107
    }
108

            
109
    my $cfg = Config::Simple->new($self->{cluster_config})
110
        or die "Cannot load cluster config file: $self->{cluster_config}";
111

            
112
    # Load monitoring settings
113
    $self->{collection_interval} = $cfg->param('cluster.collection_interval')
114
        || $self->{local_settings}->{AUTOSMART_COLLECTION_INTERVAL} || 300;
115
    $self->{collection_timeout} = $cfg->param('cluster.collection_timeout')
116
        || $self->{local_settings}->{AUTOSMART_COLLECTION_TIMEOUT} || 30;
117
    $self->{madagascar_inventory} = $cfg->param('madagascar.inventory_path');
118

            
119
    # Load cluster information
120
    $self->{cluster_name} = $cfg->param('cluster.cluster_name');
121
    $self->{cluster_nodes} = [split /,/, ($cfg->param('cluster.nodes') || '')];
122

            
123
    # Load SMART parameters from cluster config
124
    my @param_keys = $cfg->param(-block => 'smart_parameters');
125

            
126
    foreach my $key (@param_keys) {
127
        my $value = $cfg->param("smart_parameters.$key");
128
        my ($threshold, $weight, $enabled, $description) = split /,/, $value, 4;
129

            
130
        $self->{smart_params}->{$key} = {
131
            threshold   => $threshold,
132
            weight      => $weight,
133
            enabled     => ($enabled eq 'true'),
134
            description => $description,
135
        } if $enabled eq 'true';
136
    }
137

            
138
    $self->_log("Loaded cluster configuration: $self->{cluster_name} (" .
139
                keys(%{$self->{smart_params}}) . " SMART parameters)");
140
}
141

            
142
=head2 _connect_database
143

            
144
Establish PostgreSQL database connection using cluster configuration
145

            
146
=cut
147

            
148
sub _connect_database {
149
    my $self = shift;
150

            
151
    my $cfg = Config::Simple->new($self->{cluster_config})
152
        or die "Cannot load cluster config for database: $self->{cluster_config}";
153

            
154
    my $dsn = sprintf("DBI:Pg:database=%s;host=%s;port=%s",
155
        $cfg->param('database.database'),
156
        $cfg->param('database.host'),
157
        $cfg->param('database.port')
158
    );
159

            
160
    my $timeout = $cfg->param('database.connection_timeout') || 30;
161

            
162
    $self->{db_handle} = DBI->connect(
163
        $dsn,
164
        $cfg->param('database.username'),
165
        $cfg->param('database.password'),
166
        {
167
            RaiseError => 1,
168
            AutoCommit => 1,
169
            pg_enable_utf8 => 1,
170
            connect_timeout => $timeout,
171
        }
172
    ) or die "Database connection failed: $DBI::errstr";
173

            
174
    # Register this node in the cluster
175
    $self->_register_node();
176

            
177
    $self->_log("Database connection established to cluster database");
178
}
179

            
180
=head2 get_madagascar_drives
181

            
182
Get list of HDDs from Madagascar inventory (cluster-shared)
183

            
184
=cut
185

            
186
sub get_madagascar_drives {
187
    my $self = shift;
188

            
189
    return [] unless -f $self->{madagascar_inventory};
190

            
191
    my $inventory_json = read_file($self->{madagascar_inventory});
192
    my $inventory = decode_json($inventory_json);
193

            
194
    my @drives = ();
195

            
196
    # Extract HDD information from Madagascar inventory
197
    if (ref $inventory eq 'HASH' && exists $inventory->{storage}) {
198
        foreach my $storage (@{$inventory->{storage}}) {
199
            # Only include drives for this node
200
            next unless $storage->{node_id} eq $self->{node_id} || !$storage->{node_id};
201
            next unless $storage->{type} eq 'HDD';
202
            next unless $storage->{device_path};
203

            
204
            push @drives, {
205
                device_path => $storage->{device_path},
206
                serial      => $storage->{serial},
207
                model       => $storage->{model},
208
                size_gb     => $storage->{size_gb},
209
                madagascar_id => $storage->{id},
210
                node_id     => $self->{node_id},
211
            };
212
        }
213
    }
214

            
215
    $self->_log("Found " . @drives . " HDDs for node $self->{node_id} in Madagascar inventory");
216
    return \@drives;
217
}
218

            
219
=head2 collect_smart_data
220

            
221
Collect SMART data from a specific drive
222

            
223
=cut
224

            
225
sub collect_smart_data {
226
    my ($self, $device_path) = @_;
227

            
228
    my $cmd = "smartctl -A -f brief -j '$device_path' 2>/dev/null";
229
    my $output = `$cmd`;
230
    my $exit_code = $? >> 8;
231

            
232
    # Parse smartctl JSON output
233
    my $smart_data = {};
234

            
235
    eval {
236
        $smart_data = decode_json($output);
237
    };
238

            
239
    if ($@) {
240
        $self->_log("Failed to parse SMART data for $device_path: $@");
241
        return undef;
242
    }
243

            
244
    return $self->_process_smart_data($smart_data, $device_path);
245
}
246

            
247
=head2 _process_smart_data
248

            
249
Process and normalize SMART data
250

            
251
=cut
252

            
253
sub _process_smart_data {
254
    my ($self, $raw_data, $device_path) = @_;
255

            
256
    my $processed = {
257
        device_path    => $device_path,
258
        timestamp      => time(),
259
        collection_ok  => ($raw_data->{smart_status}->{passed} || 0),
260
        temperature    => 0,
261
        parameters     => {},
262
    };
263

            
264
    # Extract device information
265
    if (exists $raw_data->{device}) {
266
        $processed->{model_name}   = $raw_data->{device}->{model_name} || '';
267
        $processed->{serial_number} = $raw_data->{device}->{serial_number} || '';
268
        $processed->{firmware}     = $raw_data->{device}->{firmware_version} || '';
269
    }
270

            
271
    # Extract temperature
272
    if (exists $raw_data->{temperature}) {
273
        $processed->{temperature} = $raw_data->{temperature}->{current} || 0;
274
    }
275

            
276
    # Extract SMART attributes
277
    if (exists $raw_data->{ata_smart_attributes}->{table}) {
278
        foreach my $attr (@{$raw_data->{ata_smart_attributes}->{table}}) {
279
            my $name = $attr->{name};
280

            
281
            # Only collect monitored parameters
282
            next unless exists $self->{smart_params}->{$name};
283

            
284
            $processed->{parameters}->{$name} = {
285
                id          => $attr->{id},
286
                value       => $attr->{value},
287
                worst       => $attr->{worst},
288
                thresh      => $attr->{thresh},
289
                raw_value   => $attr->{raw}->{value},
290
                when_failed => $attr->{when_failed} || '',
291
                flags       => $attr->{flags}->{string} || '',
292
            };
293
        }
294
    }
295

            
296
    return $processed;
297
}
298

            
299
=head2 store_smart_data
300

            
301
Store processed SMART data using hardware-based tracking with migration detection
302

            
303
=cut
304

            
305
sub store_smart_data {
306
    my ($self, $drive_info, $smart_data) = @_;
307

            
308
    eval {
309
        # Detect/handle HDD migration first
310
        my $hdd_id = $self->_detect_or_create_hdd($drive_info, $smart_data);
311

            
312
        # Check if we should store this reading using differential storage
313
        my $should_store = $self->_should_store_reading($hdd_id, $smart_data);
314

            
315
        if ($should_store->{store}) {
316
            # Insert SMART reading with differential storage information
317
            $self->_insert_smart_reading_differential($hdd_id, $drive_info, $smart_data, $should_store);
318

            
319
            $self->_log("Stored SMART data for HDD ID $hdd_id (Serial: $smart_data->{serial_number}, Type: $should_store->{type})", 2);
320
        } else {
321
            $self->_log("Skipped unchanged SMART data for HDD ID $hdd_id (Serial: $smart_data->{serial_number})", 3);
322
        }
323
    };
324

            
325
    if ($@) {
326
        $self->_log("ERROR storing SMART data: $@", 1);
327
        return 0;
328
    }
329

            
330
    return 1;
331
}
332

            
333
=head2 _detect_or_create_hdd
334

            
335
Detect HDD migration or create new HDD record using hardware identifiers
336

            
337
=cut
338

            
339
sub _detect_or_create_hdd {
340
    my ($self, $drive_info, $smart_data) = @_;
341

            
342
    my $serial = $smart_data->{serial_number} || 'unknown';
343
    my $model = $smart_data->{model_name} || 'unknown';
344
    my $device_path = $drive_info->{device_path};
345

            
346
    # Call PostgreSQL function to detect migration
347
    my $sth = $self->{db_handle}->prepare(q{
348
        SELECT detect_hdd_migration(?, ?, ?, ?, ?, 'collector')
349
    });
350

            
351
    $sth->execute(
352
        $serial,
353
        $model,
354
        $device_path,
355
        $self->{node_id},
356
        $drive_info->{slot} || undef
357
    );
358

            
359
    my ($hdd_id) = $sth->fetchrow_array();
360

            
361
    # If NULL returned, this is a new HDD - create it
362
    if (!defined $hdd_id) {
363
        $hdd_id = $self->_create_new_hdd($drive_info, $smart_data);
364
        $self->_log("New HDD discovered: $serial ($model) at $device_path", 2);
365
    } else {
366
        $self->_log("HDD tracked: ID $hdd_id, Serial $serial", 3);
367
    }
368

            
369
    return $hdd_id;
370
}
371

            
372
=head2 _create_new_hdd
373

            
374
Create new HDD record with hardware-based identification
375

            
376
=cut
377

            
378
sub _create_new_hdd {
379
    my ($self, $drive_info, $smart_data) = @_;
380

            
381
    my $sql = q{
382
        INSERT INTO hdd_inventory
383
        (serial_number, model_name, firmware, size_gb, manufacturer,
384
         current_device_path, current_node_id, current_slot,
385
         madagascar_id, first_seen, last_seen, status)
386
        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW(), 'active')
387
        RETURNING id
388
    };
389

            
390
    my $sth = $self->{db_handle}->prepare($sql);
391
    $sth->execute(
392
        $smart_data->{serial_number} || 'unknown',
393
        $smart_data->{model_name} || 'unknown',
394
        $smart_data->{firmware} || '',
395
        $drive_info->{size_gb} || 0,
396
        $self->_extract_manufacturer($smart_data->{model_name}),
397
        $drive_info->{device_path},
398
        $self->{node_id},
399
        $drive_info->{slot} || undef,
400
        $drive_info->{madagascar_id}
401
    );
402

            
403
    my ($hdd_id) = $sth->fetchrow_array();
404

            
405
    # Create discovery alert
406
    $self->_create_discovery_alert($hdd_id, $drive_info, $smart_data);
407

            
408
    return $hdd_id;
409
}
410

            
411
=head2 _extract_manufacturer
412

            
413
Extract manufacturer from model name
414

            
415
=cut
416

            
417
sub _extract_manufacturer {
418
    my ($self, $model_name) = @_;
419

            
420
    return 'Unknown' unless $model_name;
421

            
422
    # Common HDD manufacturer patterns
423
    my %manufacturers = (
424
        qr/^WD|Western\s*Digital/i => 'Western Digital',
425
        qr/^ST|Seagate/i           => 'Seagate',
426
        qr/^HGST|Hitachi/i         => 'HGST/Hitachi',
427
        qr/^TOSHIBA/i              => 'Toshiba',
428
        qr/^Samsung/i              => 'Samsung',
429
        qr/^Maxtor/i               => 'Maxtor',
430
        qr/^Fujitsu/i              => 'Fujitsu',
431
    );
432

            
433
    foreach my $pattern (keys %manufacturers) {
434
        return $manufacturers{$pattern} if $model_name =~ /$pattern/;
435
    }
436

            
437
    # Extract first word as fallback
438
    if ($model_name =~ /^(\w+)/) {
439
        return $1;
440
    }
441

            
442
    return 'Unknown';
443
}
444

            
445
=head2 _create_discovery_alert
446

            
447
Create alert for new HDD discovery
448

            
449
=cut
450

            
451
sub _create_discovery_alert {
452
    my ($self, $hdd_id, $drive_info, $smart_data) = @_;
453

            
454
    my $sql = q{
455
        INSERT INTO alert_history
456
        (hdd_id, serial_number, device_path, node_id, alert_type, message)
457
        VALUES (?, ?, ?, ?, 'discovery', ?)
458
    };
459

            
460
    my $message = sprintf(
461
        "New HDD discovered: %s (%s) at %s on node %s - Size: %s GB",
462
        $smart_data->{serial_number} || 'unknown',
463
        $smart_data->{model_name} || 'unknown',
464
        $drive_info->{device_path},
465
        $self->{node_id},
466
        $drive_info->{size_gb} || '?'
467
    );
468

            
469
    $self->{db_handle}->do($sql, undef,
470
        $hdd_id,
471
        $smart_data->{serial_number},
472
        $drive_info->{device_path},
473
        $self->{node_id},
474
        $message
475
    );
476
}
477

            
478
=head2 _should_store_reading
479

            
480
Check if SMART reading should be stored using differential storage logic
481

            
482
=cut
483

            
484
sub _should_store_reading {
485
    my ($self, $hdd_id, $smart_data) = @_;
486

            
487
    # Generate checksum of SMART parameters
488
    my $parameters_json = encode_json($smart_data->{parameters});
489
    my $checksum = sha256_hex($parameters_json . ($smart_data->{temperature} || ''));
490

            
491
    # Call PostgreSQL function to determine if we should store this reading
492
    my $sth = $self->{db_handle}->prepare(q{
493
        SELECT should_store_smart_reading(?, ?, ?, NOW())
494
    });
495

            
496
    $sth->execute($hdd_id, $parameters_json, $checksum);
497

            
498
    my $result = $sth->fetchrow_hashref();
499

            
500
    return {
501
        store => $result->{should_store},
502
        type => $result->{reading_type},
503
        changes_detected => $result->{changes_detected},
504
        changed_parameters => $result->{changed_parameters},
505
        previous_reading_id => $result->{previous_reading_id},
506
        checksum => $checksum
507
    };
508
}
509

            
510
=head2 _insert_smart_reading_differential
511

            
512
Insert SMART reading with differential storage information
513

            
514
=cut
515

            
516
sub _insert_smart_reading_differential {
517
    my ($self, $hdd_id, $drive_info, $smart_data, $storage_info) = @_;
518

            
519
    my $sql = q{
520
        INSERT INTO smart_readings
521
        (hdd_id, serial_number, device_path, node_id, timestamp,
522
         collection_ok, temperature, parameters_json, reading_type,
523
         changes_detected, changed_parameters, previous_reading_id, checksum)
524
        VALUES (?, ?, ?, ?, to_timestamp(?), ?, ?, ?, ?, ?, ?, ?, ?)
525
    };
526

            
527
    # For differential readings, only store changed parameters
528
    my $parameters_to_store;
529
    if ($storage_info->{type} eq 'differential' && $storage_info->{changed_parameters}) {
530
        # Extract only changed parameters
531
        my $changed_params = decode_json($storage_info->{changed_parameters});
532
        my $all_params = $smart_data->{parameters};
533
        $parameters_to_store = {};
534

            
535
        for my $param_name (@$changed_params) {
536
            $parameters_to_store->{$param_name} = $all_params->{$param_name};
537
        }
538
    } else {
539
        # Store all parameters for baseline/full readings
540
        $parameters_to_store = $smart_data->{parameters};
541
    }
542

            
543
    my $parameters_json = encode_json($parameters_to_store);
544

            
545
    $self->{db_handle}->do($sql,
546
        undef,
547
        $hdd_id,
548
        $smart_data->{serial_number},
549
        $drive_info->{device_path},
550
        $self->{node_id},
551
        $smart_data->{timestamp},
552
        $smart_data->{collection_ok},
553
        $smart_data->{temperature},
554
        $parameters_json,
555
        $storage_info->{type},
556
        $storage_info->{changes_detected} ? 'true' : 'false',
557
        $storage_info->{changed_parameters},
558
        $storage_info->{previous_reading_id},
559
        $storage_info->{checksum}
560
    );
561
}
562

            
563
=head2 _insert_smart_reading
564

            
565
Insert SMART reading linked to hardware ID (legacy method for compatibility)
566

            
567
=cut
568

            
569
sub _insert_smart_reading {
570
    my ($self, $hdd_id, $drive_info, $smart_data) = @_;
571

            
572
    my $sql = q{
573
        INSERT INTO smart_readings
574
        (hdd_id, serial_number, device_path, node_id, timestamp,
575
         collection_ok, temperature, parameters_json)
576
        VALUES (?, ?, ?, ?, to_timestamp(?), ?, ?, ?)
577
    };
578

            
579
    my $parameters_json = encode_json($smart_data->{parameters});
580

            
581
    $self->{db_handle}->do($sql,
582
        undef,
583
        $hdd_id,
584
        $smart_data->{serial_number},
585
        $drive_info->{device_path},
586
        $self->{node_id},
587
        $smart_data->{timestamp},
588
        $smart_data->{collection_ok},
589
        $smart_data->{temperature},
590
        $parameters_json
591
    );
592
}
593

            
594
=head2 collect_all
595

            
596
Collect SMART data from all drives in Madagascar inventory
597

            
598
=cut
599

            
600
sub collect_all {
601
    my $self = shift;
602

            
603
    my $drives = $self->get_madagascar_drives();
604
    my $successful = 0;
605
    my $failed = 0;
606
    my $storage_stats = {
607
        baseline => 0,
608
        full => 0,
609
        differential => 0,
610
        skipped => 0
611
    };
612

            
613
    foreach my $drive (@$drives) {
614
        my $smart_data = $self->collect_smart_data($drive->{device_path});
615

            
616
        if ($smart_data && $self->store_smart_data($drive, $smart_data)) {
617
            $successful++;
618
        } else {
619
            $failed++;
620
            $self->_log("Failed to collect/store data for $drive->{device_path}");
621
        }
622

            
623
        # Small delay between drives to avoid overwhelming system
624
        select(undef, undef, undef, 0.1);
625
    }
626

            
627
    # Get storage statistics for this collection run
628
    my $stats = $self->_get_recent_storage_stats();
629
    $self->_log("Collection complete: $successful successful, $failed failed");
630
    $self->_log("Storage efficiency - Baseline: $stats->{baseline}, Full: $stats->{full}, Differential: $stats->{differential}, Skipped: $stats->{skipped}");
631

            
632
    return {
633
        successful => $successful,
634
        failed => $failed,
635
        total => scalar(@$drives),
636
        storage_stats => $stats
637
    };
638
}
639

            
640
=head2 _get_recent_storage_stats
641

            
642
Get statistics about storage efficiency from recent readings
643

            
644
=cut
645

            
646
sub _get_recent_storage_stats {
647
    my $self = shift;
648

            
649
    my $sql = q{
650
        SELECT
651
            reading_type,
652
            COUNT(*) as count
653
        FROM smart_readings
654
        WHERE timestamp > NOW() - INTERVAL '1 hour'
655
        GROUP BY reading_type
656
        ORDER BY reading_type
657
    };
658

            
659
    my $sth = $self->{db_handle}->prepare($sql);
660
    $sth->execute();
661

            
662
    my $stats = {
663
        baseline => 0,
664
        full => 0,
665
        differential => 0,
666
        total => 0
667
    };
668

            
669
    while (my $row = $sth->fetchrow_hashref()) {
670
        $stats->{$row->{reading_type}} = $row->{count};
671
        $stats->{total} += $row->{count};
672
    }
673

            
674
    # Calculate efficiency percentage
675
    my $efficient_readings = $stats->{differential} + $stats->{baseline};
676
    my $efficiency_pct = $stats->{total} > 0 ?
677
        sprintf("%.1f", ($efficient_readings / $stats->{total}) * 100) : 0;
678

            
679
    $stats->{efficiency_percent} = $efficiency_pct;
680

            
681
    return $stats;
682
}
683

            
684
=head2 _log
685

            
686
Internal logging method with enhanced debug levels
687

            
688
=cut
689

            
690
sub _log {
691
    my ($self, $message, $level) = @_;
692

            
693
    $level ||= 1;  # Default to basic level
694

            
695
    # Check if we should log based on debug level
696
    return unless $self->{debug} >= $level;
697

            
698
    my $timestamp = scalar(localtime());
699
    my $node_id = $self->{node_id} || 'unknown';
700
    my $prefix = "[$timestamp] [$node_id] SmartCollector";
701

            
702
    if ($self->{debug}) {
703
        print "$prefix: $message\n";
704
    }
705

            
706
    # Also log to syslog if enabled
707
    if ($self->{local_settings}->{AUTOSMART_LOG_SYSLOG} eq 'true') {
708
        eval {
709
            use Sys::Syslog qw(:standard :macros);
710
            my $facility = $self->{local_settings}->{AUTOSMART_LOG_FACILITY} || 'daemon';
711
            openlog('autosmart', 'pid,ndelay', $facility);
712
            syslog(LOG_INFO, "SmartCollector[$node_id]: $message");
713
            closelog();
714
        };
715
    }
716

            
717
    # Log to file if specified
718
    my $log_file = $self->{local_settings}->{AUTOSMART_DEBUG_LOG_FILE};
719
    if ($log_file && $self->{debug} >= 2) {
720
        eval {
721
            open my $fh, '>>', $log_file;
722
            print $fh "$prefix: $message\n";
723
            close $fh;
724
        };
725
    }
726
}
727

            
728
=head2 _register_node
729

            
730
Register this node in the cluster database
731

            
732
=cut
733

            
734
sub _register_node {
735
    my $self = shift;
736

            
737
    eval {
738
        # Create cluster_nodes table if it doesn't exist
739
        $self->{db_handle}->do(q{
740
            CREATE TABLE IF NOT EXISTS cluster_nodes (
741
                node_id VARCHAR(100) PRIMARY KEY,
742
                hostname VARCHAR(255),
743
                ip_address INET,
744
                last_seen TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
745
                status VARCHAR(20) DEFAULT 'active',
746
                version VARCHAR(50),
747
                capabilities JSON,
748
                created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
749
            )
750
        });
751

            
752
        # Register/update this node
753
        my $hostname = `hostname -f`;
754
        chomp $hostname;
755

            
756
        my $ip = `hostname -I | awk '{print \$1}'`;
757
        chomp $ip;
758

            
759
        $self->{db_handle}->do(q{
760
            INSERT INTO cluster_nodes
761
            (node_id, hostname, ip_address, last_seen, status, version)
762
            VALUES (?, ?, ?, NOW(), 'active', '1.0')
763
            ON CONFLICT (node_id)
764
            DO UPDATE SET
765
                hostname = EXCLUDED.hostname,
766
                ip_address = EXCLUDED.ip_address,
767
                last_seen = NOW(),
768
                status = 'active'
769
        }, undef, $self->{node_id}, $hostname, $ip);
770

            
771
        $self->_log("Registered node $self->{node_id} in cluster", 2);
772
    };
773

            
774
    if ($@) {
775
        $self->_log("Warning: Failed to register node: $@", 1);
776
    }
777
}
778

            
779
=head2 DESTROY
780

            
781
Cleanup database connection
782

            
783
=cut
784

            
785
sub DESTROY {
786
    my $self = shift;
787
    $self->{db_handle}->disconnect() if $self->{db_handle};
788
}
789

            
790
1;
791

            
792
__END__
793

            
794
=head1 AUTHOR
795

            
796
AutoSMART Development Team
797

            
798
=head1 LICENSE
799

            
800
This software is part of the autoSMART project.
801

            
802
=cut