Madagascar / projects / autoSMART / scripts / autosmart-migration-report.pl
Newer Older
f16725e 3 months ago History
615 lines | 17.089kb
Bogdan Timofte authored 3 months ago
1
#!/usr/bin/perl
2

            
3
use strict;
4
use warnings;
5
use DBI;
6
use Getopt::Long;
7
use Config::Simple;
8
use JSON::XS;
9
use POSIX qw(strftime);
10

            
11
=head1 NAME
12

            
13
autosmart-migration-report.pl - HDD Migration Analysis and Reporting
14

            
15
=head1 SYNOPSIS
16

            
17
    autosmart-migration-report.pl [OPTIONS]
18

            
19
=head1 OPTIONS
20

            
21
    --config-dir DIR     Configuration directory (default: /etc/pve/autoSMART)
22
    --days N            Days of migration history (default: 30)
23
    --serial SERIAL     Report for specific HDD serial number
24
    --node NODE         Report migrations for specific node
25
    --type TYPE         Migration type: device_change, node_change, slot_change, all
26
    --format FORMAT     Output format: text, json, csv (default: text)
27
    --frequent-only     Show only frequently migrated drives (>3 migrations)
28
    --recent-only       Show only recent migrations (<24h)
29
    --output FILE       Write to file instead of stdout
30
    --help              Show this help
31

            
32
=head1 DESCRIPTION
33

            
34
Analyze and report HDD migrations tracked by autoSMART. Shows drive movements
35
between nodes, device path changes, and slot changes with detailed history.
36

            
37
=cut
38

            
39
# Configuration
40
my $config_dir = '/etc/pve/autoSMART';
41
my $days = 30;
42
my $specific_serial = '';
43
my $specific_node = '';
44
my $migration_type = 'all';
45
my $format = 'text';
46
my $frequent_only = 0;
47
my $recent_only = 0;
48
my $output_file = '';
49
my $help = 0;
50

            
51
GetOptions(
52
    'config-dir=s'  => \$config_dir,
53
    'days=i'        => \$days,
54
    'serial=s'      => \$specific_serial,
55
    'node=s'        => \$specific_node,
56
    'type=s'        => \$migration_type,
57
    'format=s'      => \$format,
58
    'frequent-only' => \$frequent_only,
59
    'recent-only'   => \$recent_only,
60
    'output=s'      => \$output_file,
61
    'help'          => \$help,
62
) or die "Error parsing command line arguments\n";
63

            
64
if ($help) {
65
    print_help();
66
    exit 0;
67
}
68

            
69
# Validate options
70
unless ($format =~ /^(text|json|csv)$/) {
71
    die "Invalid format: $format (must be text, json, or csv)\n";
72
}
73

            
74
unless ($migration_type =~ /^(device_change|node_change|slot_change|all)$/) {
75
    die "Invalid migration type: $migration_type\n";
76
}
77

            
78
# Connect to database
79
my $db_config = "$config_dir/cluster.conf";
80
unless (-f $db_config) {
81
    die "Cluster configuration not found: $db_config\n";
82
}
83

            
84
my $cfg = Config::Simple->new($db_config);
85
my $dsn = sprintf("DBI:Pg:database=%s;host=%s;port=%s",
86
    $cfg->param('database.database'),
87
    $cfg->param('database.host'),
88
    $cfg->param('database.port')
89
);
90

            
91
my $dbh = DBI->connect(
92
    $dsn,
93
    $cfg->param('database.username'),
94
    $cfg->param('database.password'),
95
    { RaiseError => 1, AutoCommit => 1, pg_enable_utf8 => 1 }
96
) or die "Database connection failed: $DBI::errstr";
97

            
98
# Generate migration report
99
my $report_data = generate_migration_report($dbh);
100

            
101
# Output report
102
my $output_handle = \*STDOUT;
103
if ($output_file) {
104
    open $output_handle, '>', $output_file
105
        or die "Cannot open output file $output_file: $!\n";
106
}
107

            
108
if ($format eq 'json') {
109
    output_json($output_handle, $report_data);
110
} elsif ($format eq 'csv') {
111
    output_csv($output_handle, $report_data);
112
} else {
113
    output_text($output_handle, $report_data);
114
}
115

            
116
close $output_handle if $output_file;
117
$dbh->disconnect();
118

            
119
=head2 generate_migration_report
120

            
121
Generate comprehensive migration report
122

            
123
=cut
124

            
125
sub generate_migration_report {
126
    my $dbh = shift;
127

            
128
    my $data = {
129
        generated_at => time(),
130
        days_analyzed => $days,
131
        filters => {
132
            serial => $specific_serial,
133
            node => $specific_node,
134
            type => $migration_type,
135
            frequent_only => $frequent_only,
136
            recent_only => $recent_only,
137
        }
138
    };
139

            
140
    # Get migration statistics
141
    $data->{statistics} = get_migration_statistics($dbh);
142

            
143
    # Get migration details
144
    $data->{migrations} = get_migration_details($dbh);
145

            
146
    # Get frequently migrated drives
147
    $data->{frequent_migrants} = get_frequent_migrants($dbh);
148

            
149
    # Get drive current status
150
    $data->{drive_status} = get_drive_migration_status($dbh);
151

            
152
    return $data;
153
}
154

            
155
=head2 get_migration_statistics
156

            
157
Get overall migration statistics
158

            
159
=cut
160

            
161
sub get_migration_statistics {
162
    my $dbh = shift;
163

            
164
    my $stats = {};
165

            
166
    # Total migrations by type
167
    my $sql = q{
168
        SELECT
169
            migration_type,
170
            COUNT(*) as count,
171
            COUNT(DISTINCT serial_number) as unique_drives
172
        FROM hdd_migrations
173
        WHERE migration_timestamp >= NOW() - INTERVAL ? DAY
174
        GROUP BY migration_type
175
        ORDER BY count DESC
176
    };
177

            
178
    my $sth = $dbh->prepare($sql);
179
    $sth->execute($days);
180

            
181
    $stats->{by_type} = {};
182
    while (my $row = $sth->fetchrow_hashref()) {
183
        $stats->{by_type}->{$row->{migration_type}} = {
184
            count => $row->{count},
185
            unique_drives => $row->{unique_drives}
186
        };
187
    }
188

            
189
    # Migrations by node
190
    $sql = q{
191
        SELECT
192
            COALESCE(new_node_id, old_node_id) as node_id,
193
            COUNT(*) as migrations_involving_node
194
        FROM hdd_migrations
195
        WHERE migration_timestamp >= NOW() - INTERVAL ? DAY
196
        GROUP BY COALESCE(new_node_id, old_node_id)
197
        ORDER BY migrations_involving_node DESC
198
    };
199

            
200
    $sth = $dbh->prepare($sql);
201
    $sth->execute($days);
202

            
203
    $stats->{by_node} = {};
204
    while (my $row = $sth->fetchrow_hashref()) {
205
        $stats->{by_node}->{$row->{node_id}} = $row->{migrations_involving_node};
206
    }
207

            
208
    # Recent activity
209
    $sql = q{
210
        SELECT
211
            DATE(migration_timestamp) as date,
212
            COUNT(*) as migrations_per_day
213
        FROM hdd_migrations
214
        WHERE migration_timestamp >= NOW() - INTERVAL ? DAY
215
        GROUP BY DATE(migration_timestamp)
216
        ORDER BY date DESC
217
        LIMIT 7
218
    };
219

            
220
    $sth = $dbh->prepare($sql);
221
    $sth->execute($days);
222

            
223
    $stats->{recent_activity} = [];
224
    while (my $row = $sth->fetchrow_hashref()) {
225
        push @{$stats->{recent_activity}}, {
226
            date => $row->{date},
227
            count => $row->{migrations_per_day}
228
        };
229
    }
230

            
231
    return $stats;
232
}
233

            
234
=head2 get_migration_details
235

            
236
Get detailed migration records
237

            
238
=cut
239

            
240
sub get_migration_details {
241
    my $dbh = shift;
242

            
243
    my $sql = q{
244
        SELECT
245
            m.serial_number,
246
            hi.model_name,
247
            hi.current_device_path,
248
            hi.current_node_id,
249
            m.migration_type,
250
            m.migration_timestamp,
251
            m.old_device_path,
252
            m.old_node_id,
253
            m.old_slot,
254
            m.new_device_path,
255
            m.new_node_id,
256
            m.new_slot,
257
            m.detected_by,
258
            m.confidence_level,
259
            m.trigger_reason,
260
            m.verification_status
261
        FROM hdd_migrations m
262
        JOIN hdd_inventory hi ON m.hdd_id = hi.id
263
        WHERE m.migration_timestamp >= NOW() - INTERVAL ? DAY
264
    };
265

            
266
    my @params = ($days);
267

            
268
    # Add filters
269
    if ($specific_serial) {
270
        $sql .= " AND m.serial_number = ?";
271
        push @params, $specific_serial;
272
    }
273

            
274
    if ($specific_node) {
275
        $sql .= " AND (m.old_node_id = ? OR m.new_node_id = ?)";
276
        push @params, $specific_node, $specific_node;
277
    }
278

            
279
    if ($migration_type ne 'all') {
280
        $sql .= " AND m.migration_type = ?";
281
        push @params, $migration_type;
282
    }
283

            
284
    if ($recent_only) {
285
        $sql .= " AND m.migration_timestamp >= NOW() - INTERVAL '24 hours'";
286
    }
287

            
288
    $sql .= " ORDER BY m.migration_timestamp DESC LIMIT 100";
289

            
290
    my $sth = $dbh->prepare($sql);
291
    $sth->execute(@params);
292

            
293
    my @migrations = ();
294
    while (my $row = $sth->fetchrow_hashref()) {
295
        push @migrations, $row;
296
    }
297

            
298
    return \@migrations;
299
}
300

            
301
=head2 get_frequent_migrants
302

            
303
Get drives that migrate frequently
304

            
305
=cut
306

            
307
sub get_frequent_migrants {
308
    my $dbh = shift;
309

            
310
    my $min_migrations = $frequent_only ? 3 : 1;
311

            
312
    my $sql = q{
313
        SELECT
314
            hi.serial_number,
315
            hi.model_name,
316
            hi.current_device_path,
317
            hi.current_node_id,
318
            hi.migration_count,
319
            hi.last_migration,
320
            hi.first_seen,
321
            COUNT(m.id) as recent_migrations,
322
            string_agg(DISTINCT m.migration_type, ', ') as migration_types
323
        FROM hdd_inventory hi
324
        LEFT JOIN hdd_migrations m ON hi.id = m.hdd_id
325
            AND m.migration_timestamp >= NOW() - INTERVAL ? DAY
326
        WHERE hi.migration_count >= ?
327
        GROUP BY hi.id, hi.serial_number, hi.model_name, hi.current_device_path,
328
                 hi.current_node_id, hi.migration_count, hi.last_migration, hi.first_seen
329
        HAVING COUNT(m.id) > 0 OR hi.migration_count >= ?
330
        ORDER BY hi.migration_count DESC, hi.last_migration DESC
331
        LIMIT 20
332
    };
333

            
334
    my $sth = $dbh->prepare($sql);
335
    $sth->execute($days, $min_migrations, $min_migrations);
336

            
337
    my @frequent = ();
338
    while (my $row = $sth->fetchrow_hashref()) {
339
        push @frequent, $row;
340
    }
341

            
342
    return \@frequent;
343
}
344

            
345
=head2 get_drive_migration_status
346

            
347
Get current migration status of drives
348

            
349
=cut
350

            
351
sub get_drive_migration_status {
352
    my $dbh = shift;
353

            
354
    my $sql = q{
355
        SELECT
356
            migration_status,
357
            COUNT(*) as drive_count
358
        FROM drive_health_summary
359
        GROUP BY migration_status
360
        ORDER BY drive_count DESC
361
    };
362

            
363
    my $sth = $dbh->prepare($sql);
364
    $sth->execute();
365

            
366
    my %status = ();
367
    while (my $row = $sth->fetchrow_hashref()) {
368
        $status{$row->{migration_status}} = $row->{drive_count};
369
    }
370

            
371
    return \%status;
372
}
373

            
374
=head2 output_text
375

            
376
Output report as text
377

            
378
=cut
379

            
380
sub output_text {
381
    my ($fh, $data) = @_;
382

            
383
    print $fh "\n" . "="x80 . "\n";
384
    print $fh "autoSMART HDD Migration Report\n";
385
    print $fh "Generated: " . strftime("%Y-%m-%d %H:%M:%S", localtime($data->{generated_at})) . "\n";
386
    print $fh "Period: Last $data->{days_analyzed} days\n";
387
    print $fh "="x80 . "\n\n";
388

            
389
    # Statistics
390
    my $stats = $data->{statistics};
391

            
392
    print $fh "MIGRATION STATISTICS\n";
393
    print $fh "-"x40 . "\n";
394

            
395
    if (%{$stats->{by_type}}) {
396
        print $fh "By Type:\n";
397
        foreach my $type (sort keys %{$stats->{by_type}}) {
398
            my $info = $stats->{by_type}->{$type};
399
            printf $fh "  %-15s: %d migrations (%d unique drives)\n",
400
                   $type, $info->{count}, $info->{unique_drives};
401
        }
402
    }
403

            
404
    if (%{$stats->{by_node}}) {
405
        print $fh "\nBy Node:\n";
406
        foreach my $node (sort { $stats->{by_node}->{$b} <=> $stats->{by_node}->{$a} }
407
                         keys %{$stats->{by_node}}) {
408
            printf $fh "  %-15s: %d migrations\n", $node, $stats->{by_node}->{$node};
409
        }
410
    }
411

            
412
    # Recent activity
413
    if (@{$stats->{recent_activity}}) {
414
        print $fh "\nRecent Activity (Last 7 days):\n";
415
        foreach my $activity (@{$stats->{recent_activity}}) {
416
            printf $fh "  %s: %d migrations\n", $activity->{date}, $activity->{count};
417
        }
418
    }
419

            
420
    # Drive migration status
421
    if (%{$data->{drive_status}}) {
422
        print $fh "\nDrive Migration Status:\n";
423
        foreach my $status (sort keys %{$data->{drive_status}}) {
424
            printf $fh "  %-20s: %d drives\n", $status, $data->{drive_status}->{$status};
425
        }
426
    }
427

            
428
    # Frequently migrated drives
429
    if (@{$data->{frequent_migrants}}) {
430
        print $fh "\n" . "="x80 . "\n";
431
        print $fh "FREQUENTLY MIGRATED DRIVES\n";
432
        print $fh "="x80 . "\n";
433

            
434
        foreach my $drive (@{$data->{frequent_migrants}}) {
435
            printf $fh "\nSerial: %s (%s)\n",
436
                   $drive->{serial_number}, $drive->{model_name};
437
            printf $fh "Current: %s @ %s\n",
438
                   $drive->{current_device_path} || 'unknown',
439
                   $drive->{current_node_id} || 'unknown';
440
            printf $fh "Total migrations: %d (Recent: %d)\n",
441
                   $drive->{migration_count}, $drive->{recent_migrations};
442
            printf $fh "Last migration: %s\n",
443
                   $drive->{last_migration} || 'never';
444
            printf $fh "Migration types: %s\n",
445
                   $drive->{migration_types} || 'none';
446
        }
447
    }
448

            
449
    # Recent migrations
450
    if (@{$data->{migrations}}) {
451
        print $fh "\n" . "="x80 . "\n";
452
        print $fh "RECENT MIGRATIONS\n";
453
        print $fh "="x80 . "\n";
454

            
455
        foreach my $migration (@{$data->{migrations}}) {
456
            printf $fh "\n[%s] %s - %s\n",
457
                   $migration->{migration_timestamp},
458
                   $migration->{serial_number},
459
                   uc($migration->{migration_type});
460

            
461
            if ($migration->{migration_type} eq 'node_change') {
462
                printf $fh "  Moved: %s@%s -> %s@%s\n",
463
                       $migration->{old_device_path} || '?',
464
                       $migration->{old_node_id} || '?',
465
                       $migration->{new_device_path} || '?',
466
                       $migration->{new_node_id} || '?';
467
            } elsif ($migration->{migration_type} eq 'device_change') {
468
                printf $fh "  Device: %s -> %s (on %s)\n",
469
                       $migration->{old_device_path} || '?',
470
                       $migration->{new_device_path} || '?',
471
                       $migration->{new_node_id} || '?';
472
            }
473

            
474
            printf $fh "  Detected by: %s (confidence: %d/10)\n",
475
                   $migration->{detected_by}, $migration->{confidence_level};
476

            
477
            if ($migration->{trigger_reason}) {
478
                printf $fh "  Reason: %s\n", $migration->{trigger_reason};
479
            }
480
        }
481
    }
482

            
483
    print $fh "\n";
484
}
485

            
486
=head2 output_json
487

            
488
Output report as JSON
489

            
490
=cut
491

            
492
sub output_json {
493
    my ($fh, $data) = @_;
494

            
495
    my $json = JSON::XS->new->pretty->encode($data);
496
    print $fh $json;
497
}
498

            
499
=head2 output_csv
500

            
501
Output migrations as CSV
502

            
503
=cut
504

            
505
sub output_csv {
506
    my ($fh, $data) = @_;
507

            
508
    # CSV header
509
    print $fh "timestamp,serial_number,model_name,migration_type,old_location,new_location,detected_by,confidence\n";
510

            
511
    foreach my $migration (@{$data->{migrations}}) {
512
        my @fields = (
513
            $migration->{migration_timestamp},
514
            $migration->{serial_number},
515
            $migration->{model_name} || '',
516
            $migration->{migration_type},
517
            sprintf("%s@%s", $migration->{old_device_path} || '', $migration->{old_node_id} || ''),
518
            sprintf("%s@%s", $migration->{new_device_path} || '', $migration->{new_node_id} || ''),
519
            $migration->{detected_by},
520
            $migration->{confidence_level}
521
        );
522

            
523
        # Escape CSV fields
524
        @fields = map { escape_csv($_) } @fields;
525
        print $fh join(',', @fields) . "\n";
526
    }
527
}
528

            
529
=head2 escape_csv
530

            
531
Escape CSV field
532

            
533
=cut
534

            
535
sub escape_csv {
536
    my $field = shift || '';
537

            
538
    if ($field =~ /[",\n]/) {
539
        $field =~ s/"/""/g;
540
        $field = "\"$field\"";
541
    }
542

            
543
    return $field;
544
}
545

            
546
=head2 print_help
547

            
548
Display help information
549

            
550
=cut
551

            
552
sub print_help {
553
    print <<'EOF';
554
autoSMART HDD Migration Report v1.0
555

            
556
USAGE:
557
    autosmart-migration-report.pl [OPTIONS]
558

            
559
OPTIONS:
560
    --config-dir DIR     Configuration directory (default: /etc/pve/autoSMART)
561
    --days N            Days of migration history to analyze (default: 30)
562
    --serial SERIAL     Report for specific HDD serial number
563
    --node NODE         Show migrations involving specific node
564
    --type TYPE         Filter by migration type:
565
                        device_change, node_change, slot_change, all (default)
566
    --format FORMAT     Output format: text, json, csv (default: text)
567
    --frequent-only     Show only frequently migrated drives (3+ migrations)
568
    --recent-only       Show only migrations in last 24 hours
569
    --output FILE       Write to file instead of stdout
570
    --help              Show this help message
571

            
572
EXAMPLES:
573
    # Show all migrations in last 7 days
574
    autosmart-migration-report.pl --days 7
575

            
576
    # Show only node changes
577
    autosmart-migration-report.pl --type node_change
578

            
579
    # Show migrations for specific drive
580
    autosmart-migration-report.pl --serial WD-WCC4N5123456
581

            
582
    # Show frequently migrated drives
583
    autosmart-migration-report.pl --frequent-only
584

            
585
    # Export recent migrations as CSV
586
    autosmart-migration-report.pl --recent-only --format csv --output migrations.csv
587

            
588
MIGRATION TYPES:
589
    device_change   Drive appeared at different /dev/sdX path
590
    node_change     Drive moved between Proxmox nodes
591
    slot_change     Drive moved to different physical slot/bay
592
    discovery       New drive detected for first time
593

            
594
OUTPUT:
595
    The report includes:
596
    - Overall migration statistics
597
    - Frequently migrated drives
598
    - Recent migration activity
599
    - Detailed migration logs
600
    - Drive migration status summary
601

            
602
EOF
603
}
604

            
605
__END__
606

            
607
=head1 AUTHOR
608

            
609
AutoSMART Development Team
610

            
611
=head1 LICENSE
612

            
613
This software is part of the autoSMART project.
614

            
615
=cut