Group
Extension

App-FargateStack/lib/App/FargateStack/Builder/Autoscaling.pm

package App::FargateStack::Builder::Autoscaling;

use strict;
use warnings;

use Carp;
use CLI::Simple::Constants qw(:booleans);
use Data::Dumper;
use English qw(-no_match_vars);
use List::Util qw(none any uniq pairs max);
use JSON;

use App::FargateStack::AutoscalingConfig;
use App::FargateStack::Constants;
use App::FargateStack::Builder::Utils qw(log_die dmp choose);

use Role::Tiny;

########################################################################
sub build_autoscaling {
########################################################################
  my ($self) = @_;

  my ( $config, $tasks ) = $self->common_args(qw(config tasks));

  foreach my $task_name ( keys %{$tasks} ) {
    # autoscaling only applies to http & daemon tasks
    next if $tasks->{$task_name} =~ /^(?:task|scheduled)$/xsm;

    next
      if !exists $tasks->{$task_name}->{autoscaling};

    $self->_build_autoscaling($task_name);
  }

  return $SUCCESS;
}

########################################################################
sub _build_autoscaling {
########################################################################
  my ( $self, $task_name ) = @_;

  my $dryrun = $self->get_dryrun;

  my $task = $self->get_config->{tasks}->{$task_name};

  my $app_autoscaling = $self->fetch_application_autoscaling;

  my $policy_name = $self->create_default( 'autoscaling-policy-name', $task_name );

  my $autoscaling_config
    = eval { return App::FargateStack::AutoscalingConfig->new( config => $task->{autoscaling}, policy_name => $policy_name ); };

  log_die( $self, $EVAL_ERROR )
    if !$autoscaling_config || $EVAL_ERROR;

  my $resource_id = $self->_resource_id($task_name);

  ######################################################################
  # scheduled actions
  ######################################################################
  if ( $autoscaling_config->has_scheduled_action ) {
    $self->build_scheduled_actions(
      task               => $task,
      resource_id        => $resource_id,
      app_autoscaling    => $app_autoscaling,
      autoscaling_config => $autoscaling_config,
    );
  }

  my $scaling_policy = $app_autoscaling->describe_scaling_policies(
    service_namespace => 'ecs',
    policy_names      => $policy_name,
    query             => 'ScalingPolicies',
  );

  $app_autoscaling->check_result( message => 'ERROR: could not describe scaling policies for: [%s]', $resource_id );
  $scaling_policy = $scaling_policy->[0];

  return
    if !$autoscaling_config->get_cpu && !$autoscaling_config->get_requests;

  my $policy_configuration = $self->create_policy_configuration( $task_name, $autoscaling_config );

  if ( !$scaling_policy ) {
    $self->log_warn( 'autoscaling: policy: [%s] does not exist...will be created...%s', $policy_name, $dryrun );

    $self->inc_required_resources(
      'autoscaling:scaling-policy' => sub {
        my ($dryrun) = @_;

        return $dryrun ? 'arn:???' : $autoscaling_config->get_policy_arn;
      }
    );

    if ( !$dryrun ) {
      my $arn = $self->create_autoscaling_policy(
        app_autoscaling      => $app_autoscaling,
        resource_id          => $resource_id,
        min_capacity         => $autoscaling_config->get_min_capacity,
        max_capacity         => $autoscaling_config->get_max_capacity,
        policy_name          => $policy_name,
        policy_configuration => $policy_configuration,
      );

      $autoscaling_config->update( policy_arn => $arn );
    }

    return $TRUE;
  }

  my $current_policy = $scaling_policy->{TargetTrackingScalingPolicyConfiguration};

  my $diffs = $self->display_diffs( $policy_configuration, $current_policy );

  if ($diffs) {
    $self->log_error( "autoscaling: scaling policy: [%s] has changed:\n%s\n", $policy_name, $diffs );

    log_die( $self, 'autoscaling: aborting update...use --force to force update' )
      if !$self->get_force;

    $self->log_warn( 'autoscaling: scaling policy: [%s] will be updated...%s', $policy_name, $dryrun );

    if ( !$dryrun ) {
      my $arn = $self->create_autoscaling_policy(
        app_autoscaling      => $app_autoscaling,
        resource_id          => $resource_id,
        min_capacity         => $autoscaling_config->get_min_capacity,
        max_capacity         => $autoscaling_config->get_max_capacity,
        policy_name          => $policy_name,
        policy_configuration => $policy_configuration,
      );

      $autoscaling_config->update( policy_arn => $arn );  # the arn should not change, but why not?
    }
  }
  else {
    $self->log_info( 'autoscaling: scaling policy: [%s] has not changed...skipping', $policy_name );
    $self->inc_existing_resources( autoscaling => $autoscaling_config->get_policy_arn );
  }

  return $TRUE;
}

########################################################################
sub _resource_id {
########################################################################
  my ( $self, $task_name ) = @_;

  return sprintf 'service/%s/%s', $self->get_config->{cluster}->{name}, $task_name;
}

########################################################################
sub create_policy_configuration {
########################################################################
  my ( $self, $task_name, $autoscaling_config ) = @_;

  my $scale_in_cooldown  = $autoscaling_config->get_scale_in_cooldown;
  my $scale_out_cooldown = $autoscaling_config->get_scale_out_cooldown;

  my $metric       = $autoscaling_config->get_metric;
  my $metric_value = $autoscaling_config->get_metric_value;

  my $predefined_metric_type = {
    cpu      => 'ECSServiceAverageCPUUtilization',
    requests => 'ALBRequestCountPerTarget',
  }->{$metric};

  my $resource_label = $metric eq 'requests' ? $self->create_resource_label($task_name) : q{};

  my $predefined_metric_specficiation = {
    PredefinedMetricType => $predefined_metric_type,
    $resource_label ? ( ResourceLabel => $resource_label ) : ()
  };

  my $policy_configuration = {
    TargetValue                   => $metric_value,
    PredefinedMetricSpecification => $predefined_metric_specficiation,
    ScaleOutCooldown              => $scale_out_cooldown,
    ScaleInCooldown               => $scale_in_cooldown,
  };

  $self->log_debug(
    sub {
      return Dumper(
        [ policy_configuration => $policy_configuration,
          metric               => $metric,
        ]
      );
    }
  );

  return $policy_configuration;
}

########################################################################
sub create_resource_label {
########################################################################
  my ( $self, $task_name ) = @_;

  my $config = $self->get_config;

  my $task = $config->{tasks}->{$task_name};

  my $target_arn = $task->{target_group_arn};

  my ($tg_part) = $target_arn =~ /(targetgroup\/.*)$/xsm;

  my $alb_arn = $config->{alb}->{arn};
  my ($alb_part) = $alb_arn =~ /loadbalancer\/(.*)$/xsm;

  return sprintf '%s/%s', $alb_part, $tg_part;
}

########################################################################
sub create_autoscaling_policy {
########################################################################
  my ( $self, %args ) = @_;

  my ( $resource_id, $min_capacity, $max_capacity, $policy_name, $policy_configuration, $app_autoscaling )
    = @args{qw(resource_id min_capacity max_capacity policy_name policy_configuration app_autoscaling)};

  my $query = sprintf 'ScalableTargets[?ResourceId == `%s`]|[0]', $resource_id;

  my $scalable_targets = $app_autoscaling->describe_scalable_targets( service_namespace => 'ecs', query => $query );
  $app_autoscaling->check_result( message => 'ERROR: could not describe scalable targets for: [%s]', $resource_id );

  if ( !$scalable_targets ) {

    $app_autoscaling->register_scalable_target(
      service_namespace  => 'ecs',
      scalable_dimension => 'ecs:service:DesiredCount',
      resource_id        => $resource_id,
      min_capacity       => $min_capacity,
      max_capacity       => $max_capacity,
    );

    $app_autoscaling->check_result( message => 'ERROR: could not register scalable target for: [%s]', $resource_id );
  }

  my $scaling_policy = $app_autoscaling->put_scaling_policy(
    policy_name          => $policy_name,
    service_namespace    => 'ecs',
    scalable_dimension   => 'ecs:service:DesiredCount',
    resource_id          => $resource_id,
    policy_type          => 'TargetTrackingScaling',
    policy_configuration => encode_json($policy_configuration),
  );

  $app_autoscaling->check_result( message => 'ERROR: could not create scaling policy target for: [%s]', $resource_id );

  return $scaling_policy->{PolicyARN};
}

########################################################################
sub build_scheduled_actions {
########################################################################
  my ( $self, %args ) = @_;

  my ( $task, $resource_id, $app_autoscaling, $autoscaling_config )
    = @args{qw(task resource_id app_autoscaling autoscaling_config)};

  my $dryrun = $self->get_dryrun;

  my $config = $self->get_config;

  my $parsed_actions = $autoscaling_config->get_scheduled_actions;

  ######################################################################
  # configured actions, could be new or existing
  ######################################################################
  my @actions = keys %{$parsed_actions};

  ######################################################################
  # fetch existing scheduled actions
  ######################################################################
  my $schedules = $app_autoscaling->describe_scheduled_actions( service_namespace => 'ecs' );
  $app_autoscaling->check_result( message => 'ERROR: could not describe scheduled actions' );

  my $scheduled_actions = { map { ( $_->{ScheduledActionName} => $_ ) } @{ $schedules->{ScheduledActions} // [] } };

  my @scheduled_action_names = keys %{$scheduled_actions};

  ######################################################################
  # collect new scheduled actions...
  ######################################################################
  my %put_scheduled_actions;

  foreach my $action_name (@actions) {
    my $action = $parsed_actions->{$action_name};

    if ( none { $_ =~ /$action_name/xsm } @scheduled_action_names ) {
      $self->log_warn( 'autoscaling: schedule action: [%s] does not exist...will be created...%s', $action_name, $dryrun );

      $self->inc_required_resources( 'autoscaling:scheduled-action' => $action_name );

      $self->log_debug( sub { return Dumper( [ action => $action ] ) } );
      $put_scheduled_actions{$action_name} = $action;
      next;
    }

    my @existing_actions = grep { $_ =~ /$action_name/xsm } @scheduled_action_names;

    foreach (@existing_actions) {
      my $target_action = $scheduled_actions->{$_};

      my $min_capacity = $target_action->{ScalableTargetAction}->{MinCapacity};
      my $max_capacity = $target_action->{ScalableTargetAction}->{MaxCapacity};
      my $schedule     = $target_action->{Schedule};

      my $scale_type = $target_action->{ScheduledActionName} =~ /\-in\-/xsm ? 'ScaleIn' : 'ScaleOut';

      if ( $schedule eq $action->{$scale_type}->{Schedule}
        && $min_capacity == $action->{$scale_type}->{Action}->{MinCapacity}
        && $max_capacity == $action->{$scale_type}->{Action}->{MaxCapacity} ) {

        $self->log_info( 'autoscaling: scheduled action: [%s] for [%s] has not changed...skipping', $action_name, $scale_type );
        $self->inc_existing_resources(
          'autoscaling:scheduled' => [
            join "\n",
            sprintf '%s min_capacity:%s max_capacity: %s', $target_action->{Schedule},
            $target_action->{ScalableTargetAction}->{MinCapacity},
            $target_action->{ScalableTargetAction}->{MaxCapacity}

          ]
        );

      }
      else {
        $self->log_warn( 'autoscaling: scheduled action: [%s] for [%s] has changed...will be updated...%s',
          $action_name, $scale_type, $dryrun );
        $self->inc_required_resources( 'autoscaling:scheduled-action' => $action_name );
        $put_scheduled_actions{$action_name} = $action;
      }
    }
  }

  ######################################################################
  # Sanity check the top-level metric based capacities and scheduled actions min/max capacities
  ######################################################################
  if ( !$autoscaling_config->get_cpu && !$autoscaling_config->get_requests ) {
    ####################################################################
    # Rule: 1 If no metric scaling action exists, min/max should be equal
    ####################################################################
    my $error_msg = <<'END_OF_ERROR_MESSAGE';
ERROR: Configuration for schedule '%s' is inconsistent.
It defines a min_capacity (%d) that is different from its max_capacity (%d)
for its 'in' period, but no metric-based scaling policy (cpu or requests) has been defined.
Without a metric policy, the service can only scale to the minimum capacity and will never reach the maximum.
To resolve this, please set min_capacity and max_capacity to the same value for a fixed-capacity schedule.
END_OF_ERROR_MESSAGE

    foreach my $p ( pairs %put_scheduled_actions ) {
      if ( $p->[1]->{ScaleOut}->{Action}->{MinCapacity} != $p->[1]->{ScaleOut}->{Action}->{MaxCapacity} ) {
        $self->log_die(
          $error_msg, $p->[0],
          $p->[1]->{ScaleOut}->{Action}->{MinCapacity},
          $p->[1]->{ScaleOut}->{Action}->{MaxCapacity}
        );
      }

      if ( $p->[1]->{ScaleIn}->{Action}->{MinCapacity} != $p->[1]->{ScaleIn}->{Action}->{MaxCapacity} ) {
        $self->log_die(
          $error_msg, $p->[0],
          $p->[1]->{ScaleIn}->{Action}->{MinCapacity},
          $p->[1]->{ScaleIn}->{Action}->{MaxCapacity}
        );
      }
    }

    # presumbably existing schedules are sane...but we will check anyway
    foreach (@scheduled_action_names) {
      my $min_capacity = $scheduled_actions->{$_}->{ScalableTargetAction}{MinCapacity};
      my $max_capacity = $scheduled_actions->{$_}->{ScalableTargetAction}{MaxCapacity};

      log_die( $self, $error_msg, $min_capacity, $max_capacity )
        if $min_capacity != $max_capacity;
    }
  }
  else {
    ####################################################################
    # Rule 2: top-level max_capacity >= scheduled action max capacity
    ####################################################################
    my $error_msg = <<'END_OF_ERROR_MESSAGE';
ERROR: Configuration for a scheduled action is inconsistent.
It defines a max_capacity (%d) that is greater than the top-level max_capacity (%d).
In App::FargateStack, the top-level 'max_capacity' is the absolute ceiling for the service at all times.
To resolve this, please set the top-level 'max_capacity' to the highest value your service should ever scale to (%d or greater).
END_OF_ERROR_MESSAGE

    my $min_capacity = $autoscaling_config->get_min_capacity;
    my $max_capacity = $autoscaling_config->get_max_capacity;

    # new action max takes precendent over existing action max capacity
    my $max_scheduled_capacity;

    foreach ( uniq @scheduled_action_names, keys %put_scheduled_actions ) {
      $max_scheduled_capacity
        = max exists $put_scheduled_actions{$_}
        ? $put_scheduled_actions{$_}{ScaleOut}{Action}{MaxCapacity}
        : $scheduled_actions->{$_}->{ScalableTargetAction}{MaxCapacity};
    }

    if ( $max_capacity < $max_scheduled_capacity ) {
      $self->log_die( $error_msg, $max_scheduled_capacity, $max_capacity, $max_scheduled_capacity );
    }
  }

  if ( !$dryrun ) {
    foreach my $p ( pairs %put_scheduled_actions ) {
      my ( $action_name, $action ) = @{$p};

      $self->put_scheduled_action(
        name        => $action_name . '-out',
        resource_id => $resource_id,
        action      => $action->{ScaleOut}->{Action},
        schedule    => $action->{ScaleOut}->{Schedule},
      );

      $self->put_scheduled_action(
        name        => $action_name . '-in',
        resource_id => $resource_id,
        action      => $action->{ScaleIn}->{Action},
        schedule    => $action->{ScaleIn}->{Schedule},
      );
    }
  }

  return;
}

=pod
 aws application-autoscaling put-scheduled-action \
    --service-namespace ecs \
    --resource-id service/app-fargatestack-website-cluster/apache \
    --scalable-dimension ecs:service:DesiredCount \
    --schedule 'cron(0 18 ? *  MON-FRI *)' \
    --scheduled-action-name foo \
    --scalable-target-action '{"MinCapacity":1, "MaxCapacity":2}'

=cut

########################################################################
sub put_scheduled_action {
########################################################################
  my ( $self, %args ) = @_;

  my ( $action_name, $resource_id, $schedule, $action ) = @args{qw(name resource_id schedule action)};

  my $app_autoscaling = $self->fetch_application_autoscaling;

  my $result = $app_autoscaling->put_scheduled_action(
    resource_id            => $resource_id,
    schedule               => $schedule,
    scalable_target_action => encode_json($action),
    name                   => $self->create_default( 'scheduled-action-name', $action_name ),
  );

  $app_autoscaling->check_result( message => 'ERROR: could not put scheduled event: [%s]', $action_name );

  return $result;
}

1;


Powered by Groonga
Maintained by Kenichi Ishigaki <ishigaki@cpan.org>. If you find anything, submit it on GitHub.