#!/usr/bin/perl
#
# Author:  Petter Reinholdtsen
# Date:    2006-09-19
# License: GNU General Public License
#
# This is the fsautoresize system.  It checks file systems, and
# automatically extend the full ones based on the provided
# instructions.


use strict;
use warnings;

use Getopt::Std;
use Sys::Syslog qw(openlog syslog closelog LOG_NOTICE);
use File::Basename;

# Using this module (instead of Filesys::DiskSpace) to get a version
# providing the device size, and not only free and used.
use Filesys::Df; # from debian package libfilesys-df-perl

# Config

# for each mount point in the list, extend it when it become too full
# (based on real size in KiB/MiB/GiB or percent), and increase it with
# either a static size or a percentage, until it reaches the upper
# limit (in real size or fraction of volume group).  Should also
# support scripting to decide if the partition should be extended or
# not, to handle more advanced logic like 100 MiB per user with home
# directories on /home/.

# read config
#
#  Format:
#    [regex]  minsizefree/min%free  maxsize/max%size (of vg)  size/% incrememt
# The last matching regex take effect.  Example:
#  .+ 1% 20g 10%
#  /usr 10% 10g 1g

my @conffiles = qw(/usr/share/debian-edu-config/fsautoresizetab
                   /site/etc/fsautoresizetab
                   /etc/fsautoresizetab);

my %opts;
getopts("dnv", \%opts) || usage();

# handle signals (for reload and shutdown)

# Check if all the listed mount points support online extending

# loop

#   check full file systems (libfilesys-df-perl, libfilesys-diskfree-perl,
#                            libfilesys-diskspace-perl,libfilesys-statvfs-perl)
#   resize if available space in volume group
#   send email if resize succeeded


$ENV{PATH} = "/sbin:/usr/sbin:/bin:/usr/bin";

my %fsops =
(
    'ext3' => {
        'online_supported' => \&ext3_online_supported,
        'online_resize' => \&ext3_online_resize,
    },
    'ext4' => {
        'online_supported' => \&ext3_online_supported,
        'online_resize' => \&ext3_online_resize,
    },
);

my %devopts =
(
    'lvm' => {
        'resize' => \&lvm_resize,
    }
);

sub usage {
    print <<EOF;
Usage: $0 [-dnv]

  Resize full partitions based on configuration templates.

   -d     run in the background and resize when partitions are full (experimental)
   -v     verbose output
   -n     disable dryrun, do the resizing

EOF

    print "The configuration is read from\n";
    print "  ", join("\n  ", @conffiles), "\n";
    exit 1;
}

sub run {
    my @cmd = @_;
    print STDERR "exec: ", join(" ", @cmd, $opts{n} ? "" : " (dryrun, add -n to activate)", "\n");
    if ($opts{n}) {
        system(@cmd);
        return 0 == $?;
    } else {
        return 1;
    }
    return undef;
}

sub ext3_online_supported {
    my %minfo = @_;
    my $device = $minfo{device};
    my $supported = 0;
    open(TUNE2FS, "tune2fs -l $device 2>/dev/null |") || die "Unable to check $device";
    while (<TUNE2FS>) {
        chomp;
        $supported = 1 if (m/Filesystem features: .*resize_inode /);
    }
    close(TUNE2FS);
    return $supported;
}

sub ext3_online_resize {
    my (%minfo) = @_;
    my $device = $minfo{device};

    my $device_resize = $devopts{$minfo{devicetype}}->{resize};
    my $retval;
    if (&$device_resize($minfo{device}, $minfo{newsize})) {
        $retval = run "resize2fs", "$device";
        if (!$retval) {
            # Perhaps resize2fs is too old.  Try ext2online instead.
            my $retval = run "ext2online", "$device";
        }
    } else {
        print STDERR "error: unable to resize $device\n";
    }
    # ext2online ?

    # fsck -f, lvresize, resize2fs
    return $retval;
}

sub lvm_resize {
    my ($device, $newsize) = @_;

    $device = map_dev_to_lvmdev($device);

    # Using lvextend and not lvresize, to make sure we do not try to
    # shrink the file system.
    return run("lvextend","-L${newsize}k", "$device");
}

sub map_dev_to_lvmdev {
    my $device = shift;
    my ($vg, $lv) = $device =~ m%/dev/mapper/([^-]+)-(.+)$%;
    if ($vg) { # Remap if using stupid new linux kernel and/or tools
        $device = "/dev/$vg/$lv";
    }
    return $device
}

sub guess_devicetype {
    my $device = shift;

    # Try to find the real device, to handle /dev/vg/lv -> /dev/mapper/vg-lv
    $device = readlink $device if ( -l $device );

    return "lvm" if ($device =~ m%^/dev/mapper/.+-.+$% || $device =~ m%^../dm-.+$%);
    return undef;
}

sub get_volumecapasity {
    my $device = shift;
    return 1000; # FIXME Placeholder
}

sub get_lvextents {
    my $device = shift;
    $device = map_dev_to_lvmdev($device);

    open(my $fh, "lvdisplay -c $device 2>/dev/null |") or
        die "Unable to extract lvm lv extent size for $device";
    my @f = split(/:/, <$fh>);
    close($fh);

    return @f[6,7];
}

sub supported_mountpoints {
    my %mountpoints;
    my %devices;
    open(M, "/proc/mounts") || die "Unable to open /proc/mounts";
    while (<M>) {
        chomp;
        my @f = split(/\s+/);
        my $device = $f[0];
        # Always use mapper names instead of kernel ones.
        if (index ($f[0], "/dev/dm-") != -1) {
            for my $mapdevice (glob "/dev/mapper/*") {
                my $dmdevice = basename(readlink $mapdevice) if -l $mapdevice;
                $device = $mapdevice if defined($dmdevice) && $dmdevice =~ basename($f[0]);
            }
        }
        my $mountpoint = $f[1];
        my $typename = $f[2];
        next unless (exists $fsops{$typename});
        my $devicetype = guess_devicetype($device),
        my $extents;

        # Only devices mounted several times once
        next if $devices{$device};
        $devices{$device} = 1;

        print STDERR "Checking $mountpoint [$device]\n" if $opts{v};
        if ( -d $mountpoint && -e $device) { # df only work if the directory is available
            my $ref = df($mountpoint);
            my ($size, $used, $avail) =
                ($ref->{blocks}, $ref->{used}, $ref->{bavail});
#            my ($fs_type, $fs_desc, $used, $avail, $fused, $favail) =
#                df $mountpoint;

            if (defined $devicetype && "lvm" eq $devicetype) {
                my ($volsizeblocks, $extents) = get_lvextents($device);
                # Convert from 512 byte blocks to kilobytes
                $size = $volsizeblocks/2;
            }

            my $fracavail = 100 * $avail / $size;
            print STDERR "  A: $size $used $avail ($fracavail%)\n" if $opts{v};
            my %minfo =
                (
                 mountpoint => $mountpoint,
                 device     => $device,
                 devicetype => $devicetype,
                 fstype     => $typename,
                 extents    => $extents,
                 # These three are in kilobytes
                 used       => $used,
                 available  => $avail,
                 size       => $size,
                 volsize    => get_volumecapasity($device),
                 );
            next unless (defined $minfo{'devicetype'} && exists
                         $devopts{$minfo{'devicetype'}});

            $mountpoints{$mountpoint} = \%minfo;
        }

    }
    close(M);
    return %mountpoints;
}

sub calculate_resize {
    my ($minforef, $configref) = @_;
    my $mountpoint = $minforef->{mountpoint};
    my $lastconfig;
    for my $config (@$configref) {
        my $regex = ${$config}{'regex'};
        if ($mountpoint =~ m/^$regex$/) {
#            print STDERR "Matching '$regex'\n" if $opts{v};
            $lastconfig = $config;
        }
    }

    if ($lastconfig) {
        my $minfree   = $lastconfig->{minfree};
        my $maxsize   = $lastconfig->{maxsize};
        my $increment = $lastconfig->{increment};
        print(STDERR "info: $mountpoint matched regex ", $lastconfig->{regex},
              " from ", $lastconfig->{sourcefile}, "\n") if $opts{v};

        print STDERR "  $minfree $maxsize\n" if $opts{v};
        if ($minfree =~ m/(\d+)%$/) {
            $minfree = int($minforef->{size} * $1 / 100);
        }
        if ($maxsize =~ m/(\d+)%$/) {
            $maxsize = int($minforef->{volsize} * $1 / 100);
        }
        if ($increment =~ m/(\d+)%$/) {
            $increment = int($minforef->{size} * $1 / 100);
        }
        if (defined $lastconfig->{fstype} && "lvm" eq $lastconfig->{fstype}) {
            my $extents   = $lastconfig->{extents};
            my $extentsize= $lastconfig->{size} / $extents;
            if ($increment < $extentsize) {
                # Need to increase by at least one extent
                $increment = $extentsize;
            }
        }
        my $available = $minforef->{available};
        print STDERR "  $minfree>?$available $maxsize $increment\n" if $opts{v};
        if ($minfree > $available) {
            my $size = $minforef->{size};
            my $newsize = $size + $increment;
            $newsize = $maxsize if ($newsize > $maxsize);
            if ($newsize > $size) {
                $minforef->{newsize} = $newsize;
                print STDERR "  Need more than $available available, resizing to $newsize\n" if $opts{v};
            } else {
                # Upper limit is below the wanted size.  Not resizing
                $minforef->{newsize} = $minforef->{size};
            }
        } else {
            $minforef->{newsize} = $minforef->{size};
        }
    } else {
        print STDERR "info: unable to match $mountpoint in config file\n";
        $minforef->{newsize} = $minforef->{size};
    }
}

sub as_kilobyte {
    my $val = shift;
    $val = $1 * 1024 * 1024 * 1024 if ($val =~ m/^(\d+)t$/i);
    $val = $1 * 1024 * 1024 if ($val =~ m/^(\d+)g$/i);
    $val = $1 * 1024 if ($val =~ m/^(\d+)m$/i);
    return $val;
}

sub load_config {
    my @conffiles = @_;
    my @config;

    for my $file (@conffiles) {
        open(F, "<", $file) || next;
        while (<F>) {
            chomp;
            s/\#.*//;
            my ($regex, $minfree, $maxsize, $increment) = split(/\s+/);
            next unless $regex;
            $increment = "10%" if $increment eq "defaults";
            $minfree = as_kilobyte($minfree);
            $maxsize = as_kilobyte($maxsize);
            $increment = as_kilobyte($increment);
            my %fields =
                (
                 regex     => $regex,
                 minfree   => $minfree,
                 maxsize   => $maxsize,
                 increment => $increment,
                 sourcefile=> $file,
                 );
            push(@config, \%fields);
        }
        close F;
    }
    return @config;
}

sub fs_resize {
    my @config = @_;

    my %mountpoints = supported_mountpoints();

    for my $mountpoint (sort keys %mountpoints) {
        my %minfo = %{$mountpoints{$mountpoint}};

        calculate_resize(\%minfo, \@config);
        my $online_supported =
            $fsops{$minfo{fstype}}->{'online_supported'};
#        print(STDERR "S: $mountpoint ", $minfo{size}, " ",
#              $minfo{newsize}, "\n") if $opts{v};
        if ($minfo{size} != $minfo{newsize}) {
            print STDERR "info: trying to resize $mountpoint\n"
                if $opts{v};
            logmsg("overfull $mountpoint, resizing to $minfo{newsize}") if $opts{n};
            if (&$online_supported(%minfo)) {
                $fsops{$minfo{fstype}}->{'online_resize'}(%minfo);
            } else {
                print STDERR "warning: unable to resize $mountpoint, online resizing support is not detected\n";
            }
        }
    }
}

sub logmsg {
    my $msg = shift;
    openlog("debian-edu-fsautoresize", undef, 'user');
    syslog(LOG_NOTICE, "%s", $msg);
    closelog;
}

if ($opts{d}) { # Deamon mode, run in the background
    while (1) {
        my @config = load_config(@conffiles);
        fs_resize(@config);
        sleep 300;
    }
} else {
    my @config = load_config(@conffiles);
    fs_resize(@config);
}
exit 0;
