The Perl Toolchain Summit needs more sponsors. If your company depends on Perl, please support this very important event.
#!/usr/bin/perl

# Script to launch additional render slaves when running under Amazon AWS
# Will monitor the load level and launch a graded series of spot instances
# to deal with it.
#
# All values are hard-coded as constants during this testing phase
#
# Need following security groups:
# GBrowseMaster
#   allow inbound on 22 from all
#   allow inbound on 80 from all
#
# GBrowseSlave
#   allow inbound on 8101-8105 from GBrowseMaster group
#   (nothing else)
#
# Master server must be configured to allow http://localhost/server-status requests
# from localhost. The "Satisfy any" step ensures that no password will
# be required on this URL.
#
#<Location /server-status>
#    SetHandler server-status
#    Order deny,allow
#    Deny from all
#    Allow from 127.0.0.1
#    Satisfy any
#</Location>
# ExtendedStatus On
#


use strict;
use Getopt::Long;
use Parse::Apache::ServerStatus;
use FindBin '$Bin';
use VM::EC2;
use VM::EC2::Instance::Metadata;
use Parse::Apache::ServerStatus;

$SIG{TERM} = sub {exit 0};
$SIG{INT}  = sub {exit 0};
END {  terminate_instances()  }

# load averages:
# each item represents requests per second, lower and upper bounds
use constant LOAD_TABLE => [
    #load  min  max
    [ 0.01,  0,   1 ],
    [ 0.5,   0,   2 ],
    [ 1.0,   1,   4 ],
    [ 5.0,   3,   6 ],
    [ 10.0,  6,   8 ]
    ];

use constant IMAGE_TYPE       => 'm1.large';
use constant POLL_INTERVAL    => 0.5;  # minutes
use constant SPOT_PRICE       => 0.08;  # dollars/hour
use constant SECURITY_GROUP   => 'GBrowseSlave';
use constant CONFIGURE_SLAVES => "$Bin/gbrowse_configure_slaves.pl";
use constant SERVER_STATUS    => 'http://localhost/server-status';

my($Access_key,$Secret_key);
GetOptions(
	   'access_key=s'  => \$Access_key,
	   'secret_key=s'  => \$Secret_key,
    ) or exec 'perldoc',$0;

#setup defaults
$ENV{EC2_ACCESS_KEY} = $Access_key if defined $Access_key;
$ENV{EC2_SECRET_KEY} = $Secret_key if defined $Secret_key;

my $meta       = VM::EC2::Instance::Metadata->new();
my $imageId    = $meta->imageId;
my $instanceId = $meta->instanceId;
my $zone       = $meta->availabilityZone;
my $subnet     = eval {(values %{$meta->interfaces})[0]{subnetId}};
my $vpcId      = eval {(values %{$meta->interfaces})[0]{vpcId}};
my @groups     = $meta->securityGroups;

die "This instance needs to belong to the GBrowseMaster security group in order for this script to run correctly"
    unless "@groups" =~ /GBrowseMaster/;

warn "slave imageId=$imageId, zone=$zone, subnet=$subnet, vpcId=$vpcId\n";

(my $region = $zone)       =~ s/[a-z]$//;  #  zone=>region
my $ec2     = VM::EC2->new(-region=>$region);

my (@slave_security_groups) = $ec2->describe_security_groups({'group-name' => SECURITY_GROUP});
my $slave_security_group;
if ($vpcId) {
    ($slave_security_group)  = grep {$vpcId eq $_->vpcId} @slave_security_groups; 
} else {
    $slave_security_group = $slave_security_groups[0];
}

$slave_security_group or die "Could not find a security group named ",SECURITY_GROUP," in current region or VPC";

my $pr      = Parse::Apache::ServerStatus->new(url=>SERVER_STATUS);

while (1) { # main loop
    my $load = get_load();
    warn "current load = $load\n";
    my @instances = adjust_spot_requests($load);
    adjust_configuration(@instances);
    sleep (POLL_INTERVAL * 60);
}

terminate_instances();

exit 0;

sub get_load {
    if (-e '/tmp/gbrowse_load') {
	open my $fh,'/tmp/gbrowse_load';
	chomp (my $load = <$fh>);
	return $load;
    }
    my $stats = $pr->get or die $pr->errstr;
    return $stats->{rs};
}

sub adjust_spot_requests {
    my $load = shift;

    # first find out how many spot instances we want to have
    my ($min_instances,$max_instances) = (0,0);
    my $lt = LOAD_TABLE;
    for my $i (@$lt) {
	my ($load_limit,$min,$max) = @$i;
	if ($load > $load_limit) {
	    $min_instances = $min;
	    $max_instances = $max;
	}
    }

    warn "load=$load: min=$min_instances, max=$max_instances\n";

    # count the realized and pending 
    my @spot_requests = $ec2->describe_spot_instance_requests({'tag:Requestor' => 'gbrowse_launch_aws_slaves'});
    my @potential_instances;
    for my $sr (@spot_requests) {
	my $state    = $sr->state;
	my $instance = $sr->instance;
	if ($state eq 'open' or ($instance && $instance->instanceState =~ /running|pending/)) {
	    $instance->add_tag(Name => 'GBrowse Slave')      if $instance;
	    $instance->add_tag(GBrowseMaster => $instanceId) if $instance;  # we'll use this to terminate all slaves sometime later
	    push @potential_instances,$instance || $sr;
	}
    }

    warn "current active and pending spot instances = ",scalar @potential_instances;
    
    # what to do if there are too many spot requests for the current load
    # either cancel spot requests or shut instances down
    while (@potential_instances > $max_instances) {
	my $i = shift @potential_instances;
	if ($i->isa('VM::EC2::Instance')) {
	    warn "terminating $i";
	    $i->terminate();
	} else {
	    warn "cancelling spot request $i";
	    $ec2->cancel_spot_instance_requests($i);
	}
    }

    # what to do if there are too few
    if (@potential_instances < $min_instances) {
	warn "launching a new spot request";
	my @requests = $ec2->request_spot_instances(
	    -image_id             => $imageId,
	    -instance_type        => IMAGE_TYPE,
	    -instance_count       => 1,
	    -security_group_id    => $slave_security_group,
	    -spot_price           => SPOT_PRICE,
	    $subnet? (-subnet_id  => $subnet) : (),
	    -user_data         => "#!/bin/sh\nexec /opt/gbrowse/etc/init.d/gbrowse-slave start",
	    );
	@requests or warn $ec2->error_str;
	$_->add_tag(Requestor=>'gbrowse_launch_aws_slaves') foreach @requests;
	push @potential_instances,@requests;
    }
    return @potential_instances;
}

sub adjust_configuration {
    # this is a heterogeneous list of running instances and spot instance requests
    my @potential_instances = @_;
    warn "adjust_configuration(@potential_instances)";

    my @instances = grep {$_->isa('VM::EC2::Instance')} @potential_instances;
    if (@instances) {
	my @addresses = grep {$_} map  {$_->privateDnsName||$_->privateIpAddress}    @instances;
	return unless @addresses;
	warn "Adding slaves at address @addresses";
	my @a         = map {("http://$_:8101",
			      "http://$_:8102",
			      "http://$_:8103")} @addresses;
	my @args      = map  {('--set'=> "$_") } @a;
	system 'sudo',CONFIGURE_SLAVES,@args;
    } else {
	system 'sudo',CONFIGURE_SLAVES,'--set','';
    }
}

sub terminate_instances {
    $ec2 or return;
    warn "terminating all slave instances";
    my @spot_requests = $ec2->describe_spot_instance_requests({'tag:Requestor' => 'gbrowse_launch_aws_slaves'});
    my @instances     = $ec2->describe_instances({'tag:GBrowseMaster'=>$instanceId});
    my %to_terminate = map {$_=>1} @instances;
    foreach (@spot_requests) {
	$to_terminate{$_->instance}++;
	$ec2->cancel_spot_instance_requests($_);
    }
    my @i = grep {/^i-/} keys %to_terminate;
    warn "instances to terminate = @i";
    $ec2->terminate_instances(@i);
    system 'sudo',CONFIGURE_SLAVES,'--set','';
}

END { terminate_instances() }