Group
Extension

App-FargateStack/lib/App/FargateStack.pm

package App::FargateStack;

########################################################################
# Copyright (C) 2025, TBC Development Group, LLC All rights reserved.  #
# This is free software and may be modified or redistributed under the #
# same terms as Perl itself.                                           #
#                                                                      #
# Repository: https://github.com/rlauer6/App-Fargate                   #
########################################################################

use strict;
use warnings;

use App::FargateStack::Builder::Utils qw(jmespath_mapping toCamelCase dmp choose confirm);
use App::FargateStack::Constants;
use App::FargateStack::Pod;
use Carp;
use CLI::Simple;
use CLI::Simple::Constants qw(:booleans :chars %LOG_LEVELS);
use Cwd qw(realpath);
use Data::Dumper;
use Date::Parse qw(str2time);
use English qw(no_match_vars);
use File::Basename qw(basename fileparse);
use List::Util qw(none any);
use Log::Log4perl;
use Pod::Usage;
use Scalar::Util qw(reftype looks_like_number);
use Text::ASCIITable::EasyTable;
use Term::ANSIColor;
use YAML qw(LoadFile);

use Role::Tiny::With;

# command methods
with 'App::FargateStack::Init';
with 'App::FargateStack::Logs';
with 'App::FargateStack::Route53';
with 'App::FargateStack::CloudTrail';
with 'App::FargateStack::CreateStack';
with 'App::FargateStack::Autoscaling';

with 'App::BenchmarkRole';

# builder methods
with 'App::FargateStack::Builder';
with 'App::FargateStack::Builder::Autoscaling';
with 'App::FargateStack::Builder::IAM';
with 'App::FargateStack::Builder::Certificate';
with 'App::FargateStack::Builder::Events';
with 'App::FargateStack::Builder::EFS';
with 'App::FargateStack::Builder::HTTPService';
with 'App::FargateStack::Builder::Cluster';
with 'App::FargateStack::Builder::LogGroup';
with 'App::FargateStack::Builder::SecurityGroup';
with 'App::FargateStack::Builder::Secrets';
with 'App::FargateStack::Builder::Service';
with 'App::FargateStack::Builder::S3Bucket';
with 'App::FargateStack::Builder::SQSQueue';
with 'App::FargateStack::Builder::TaskDefinition';
with 'App::FargateStack::Builder::Utils';
with 'App::FargateStack::Builder::WafV2';

our $VERSION = '1.0.50';

use parent qw(CLI::Simple);

__PACKAGE__->use_log4perl( config => $LOG4PERL_CONF );

caller or __PACKAGE__->main;

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

  my $log4perl_conf = $self->get_log4perl_conf;

  if ( !$self->get_color ) {
    $log4perl_conf =~ s/ColoredLevels//xsm;

    if ( $self->get_log_level && $self->get_log_level =~ /debug|trace/xsm ) {
      $log4perl_conf =~ s/ConversionPattern(.*?)$/ConversionPattern = [%d] (%M:%L) %m%n/xsm;
    }
  }

  $self->set_log4perl_conf($log4perl_conf);

  return $self->SUPER::init_logger;
}

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

  my $config = $self->get_config;

  my $subnets = $self->get_subnets;

  my $dryrun = $self->command eq 'plan' ? '(dryrun)' : $self->get_dryrun;

  $self->section_break;

  $self->log_info( '           account: [%s]', $self->get_account );
  $self->log_info( '           profile: [%s]', $self->get_profile );
  $self->log_info( '    profile source: [%s]', $self->get_profile_source );
  $self->log_info( '            region: [%s]', $self->get_region );
  $self->section_break;

  $self->log_info( '   route53 profile: [%s]', $config->{route53}->{profile} // q{-} );
  $self->log_info( '   route53 zone_id: [%s]', $config->{route53}->{zone_id} // q{-} );
  $self->section_break;

  $self->log_info( '          app name: [%s]', $config->{app}->{name} );
  $self->log_info( '       app version: [%s]', $config->{app}->{version} // q{-} );
  $self->log_info( '     https service: [%s]', $self->get_http           // q{-} );
  $self->log_info( '  scheduled events: [%s]', $self->has_events ? 'yes' : 'no' );
  $self->section_break;

  $self->log_info( '    subnets in VPC: [%s]', $config->{vpc_id} );
  $self->log_info( '            public: [%s]', join q{,}, @{ $subnets->{public}  || [] } );
  $self->log_info( '           private: [%s]', join q{,}, @{ $subnets->{private} || [] } );
  $self->section_break;

  $self->log_info( '            config: [%s]', $self->get_config_name );
  $self->log_info( '         log level: [%s]', $self->get_log_level // 'info' );
  $self->log_info( '             cache: [%s]', $self->get_cache ? 'enabled' : 'disabled' );
  $self->log_warn( '     update config: [%s]', $self->get_update ? 'yes' : 'no' );
  $self->log_warn( '            dryrun: [%s]', $dryrun           ? 'yes' : 'no' );

  return;
}

########################################################################
sub check_service_name {
########################################################################
  my ( $self, $service_name, $return_on_missing ) = @_;

  my $tasks = $self->common_args('tasks');

  return $service_name
    if $service_name && $tasks->{$service_name};

  # if only 1 service in config, then let's stat that
  my ( $default_service, $error ) = grep { $tasks->{$_}->{type} =~ /daemon|http/xsm } keys %{$tasks};

  return
    if $default_service || ( !$default_service && $return_on_missing );

  if ($error) {
    $self->log_error('ERROR: multiple service defined in configuration.');
  }
  elsif ($service_name) {
    $self->log_error( 'ERROR: no such service: [%s] in configuration', $service_name );
  }
  else {
    $self->log_error('ERROR: no services defined in configuration');
  }

  die sprintf "usage: %s status service-name\n", $ENV{SCRIPT_NAME};

  return $service_name;
}

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

  my ($config_name) = $self->get_args;

  my $options = $self->fetch_option_defaults;

  my $defaults_file = '.fargatestack/defaults.json';

  if ($config_name) {
    my ( $name, $path, $ext ) = fileparse( $config_name, qr/[.][^.]+$/xsm );

    $path = realpath($path);

    my $fqp = sprintf '%s/%s%s', $path, $name, $ext || 'yml';

    die sprintf "ERROR: file not found: [%s]\n", $fqp
      if !-s $fqp;

    $options->{config} = $fqp;

    $self->write_json_file( $defaults_file, $options );
    $config_name = $name;
  }

  my $data = [
    { Profile       => $options->{profile},
      'DNS Profile' => $options->{route53_profile},
      'Max Events', => $options->{max_events},
      'Config'      => $options->{config},
      'Region'      => $options->{region},
    }
  ];

  my @columns = ( 'Profile', 'DNS Profile', 'Region', 'Config', 'Max Events' );

  print {*STDOUT} easy_table(
    table_options => { headingText => sprintf 'Current Defaults: %s', $config_name },
    columns       => \@columns,
    data          => $data,
  );

  return $SUCCESS;
}

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

  my ($command) = $self->get_args;

  my %sub_commands = ( 'cloudtrail-events' => \&cmd_cloudtrail_events, );

  die sprintf "ERROR: not a valid command. Must be one of: \n\t%s\n", join "\n\t", keys %sub_commands
    if !$sub_commands{$command};

  return $sub_commands{$command}->($self);
}

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

  my $service_name = $self->check_service_name( @args, $self->get_args );

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

  my $cluster_name = $cluster->{name};

  $self->verify_service($service_name);

  require Text::Wrap;
  Text::Wrap->import('wrap');

  {
    ## no critic
    no warnings 'once';
    $Text::Wrap::columns = 100;
  }

  my @elems = qw(running_count desired_count status pending_count events task_definition);

  my $query = jmespath_mapping 'services[0]' => \@elems;
  my $ecs   = $self->fetch_ecs;

  my $result = $ecs->describe_services(
    cluster_name => $cluster_name,
    service_name => $service_name,
    query        => $query,
  );

  log_die( $self, "ERROR: could not describe service [%s]\n%s", $service_name, $self->get_ecs->get_error )
    if !$result;

  my ( $running_count, $desired_count, $status, $pending_count, $events, $task_definition_arn )
    = @{$result}{@elems};

  $pending_count //= q{-};

  $status = $self->maybe_color( $status eq 'ACTIVE' ? 'bright_green' : 'bright_yellow' => $status );

  my $title = sprintf "Service:[%s]\n", $self->maybe_color( bright_white => $service_name );

  $title .= sprintf "Status:[%s] Running:[%s] Pending:[%s] Desired:[%s]\n",
    $status,
    $self->maybe_color( green        => $running_count ),
    $self->maybe_color( yellow       => $pending_count ),
    $self->maybe_color( bright_white => $desired_count );

  $title .= sprintf 'Task Definition: [%s]', $self->maybe_color( 'bright_white' => $task_definition_arn );

  my @events = grep {defined} @{ $result->{events} }[ 0 .. ( $self->get_max_events - 1 ) ];

  my @data = map { { 'Time' => $_->{createdAt}, Event => wrap( q{}, q{}, $_->{message} ) } } @events;

  print {*STDOUT} easy_table(
    table_options => { headingText => $title, allowANSI => $TRUE },
    data          => \@data,
    columns       => [qw(Time Event)],
  );

  $self->display_task_status($service_name);

  return $SUCCESS;
}

########################################################################
sub display_task_status {
########################################################################
  my ( $self, $service_name ) = @_;

  my $ecs = $self->fetch_ecs;

  my $cluster_name = $self->get_config->{cluster}->{name};

  my $task_arns = $ecs->list_tasks( $cluster_name, 'taskArns' );
  $ecs->check_result( message => 'ERROR: could not list tasks for cluster: [%s]', $cluster_name );

  return
    if !@{$task_arns};

  $task_arns = [ map { basename($_) } @{$task_arns} ];

  my $query
    = sprintf
    'tasks[?group == `service:%s`].{started_at:startedAt, task_definition_arn:taskDefinitionArn, last_status:lastStatus, image_digest:containers[0].imageDigest}',
    $service_name;

  my $service_tasks = $ecs->describe_tasks( $cluster_name, $task_arns, $query );
  $ecs->check_result( message => 'ERROR: Could not describe tasks: [%s]', join q{,}, @{$task_arns} );

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

  my @data;

  foreach my $task ( @{$service_tasks} ) {
    my ( $image_digest, $task_definition_arn, $started_at, $last_status )
      = @{$task}{qw(image_digest task_definition_arn started_at last_status)};

    my $short_image_digest        = abbrev( $image_digest, 16 );
    my $short_task_definition_arn = ( split m{/}xsm, $task_definition_arn )[-1];

    my $latest_task_definition_arn = $self->get_latest_task_definition($service_name);

    my $latest_image_digest = $self->get_latest_image($service_name)->{imageDigest};

    $last_status = $self->maybe_color( $last_status eq 'RUNNING' ? 'green' : 'yellow' => $last_status );

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

    my $task_definition_status = choose {
      return $self->maybe_color( green => 'Current' )
        if $task_definition_arn eq $latest_task_definition_arn;

      return $self->maybe_color( red => ( split m{/}xsm, $latest_task_definition_arn )[-1] );
    };

    $image_digest //= q{};

    my $image_digest_status = choose {
      return $self->maybe_color( green => 'Current' )
        if $image_digest eq $latest_image_digest;

      return $self->maybe_color( red => abbrev( $latest_image_digest, 16 ) );
    };

    push @data,
      {
      'Started At'             => $started_at,
      'Status'                 => $last_status,
      'Task Definition'        => $short_task_definition_arn,
      'Task Definition Status' => $task_definition_status,
      'Image Digest'           => $short_image_digest,
      'Image Status'           => $image_digest_status,
      };
  }

  print {*STDOUT} "\n\n",
    easy_table(
    table_options => { headingText => 'Task Status', allowANSI => $TRUE },
    data          => \@data,
    columns       => [ 'Started At', 'Status', 'Task Definition', 'Task Definition Status', 'Image Digest', 'Image Status' ],
    );

  return;
}

########################################################################
sub get_default_task_name {
########################################################################
  my ( $self, $type, $filter ) = @_;

  my $tasks = $self->common_args('tasks');

  my ($task_name) = $self->get_args;

  return $task_name
    if $task_name;

  my @task_names = keys %{$tasks};

  return $task_names[0]
    if @task_names == 1;

  $type //= 'task';

  my @tasks = grep { $tasks->{$_}->{type} eq $type } keys %{$tasks};

  if ($filter) {
    @tasks = grep { defined $tasks->{$_}->{$filter} } @tasks;
  }

  # more than 1, error or we found just 1
  return $tasks[1] ? $EMPTY : $tasks[0];
}

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

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

  my $task_name = $self->get_default_task_name('task');

  die sprintf "usage: %s run-task task-name\n", $ENV{SCRIPT_NAME}
    if !$task_name;

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

  log_die( $self, "ERROR: no such task [%s] defined in config\n", $task_name )
    if !$task;

  log_die( $self, "ERROR: [%s] is not a task\n", $task_name )
    if $task->{type} ne 'task';

  my $subnet_id = $self->get_subnet_id;
  my $is_public = $FALSE;

  if ( !$subnet_id ) {
    my @subnets = @{ $self->get_subnets->{private} // [] };

    if ( !@subnets ) {
      $self->log_warn('run-task: using public subnets is not recommended...');
      @subnets = @{ $self->get_subnets->{public} // [] };
    }

    $subnet_id = $subnets[0];
  }
  elsif ( any { $subnet_id eq $_ } @{ $self->get_subnets->{public} // [] } ) {
    $self->log_error( 'run-task: subnet-id: [%s] is in a public subnet...consider running your jobs in a private subnet',
      $subnet_id );
    $is_public = $TRUE;
  }
  elsif ( none { $subnet_id eq $_ } @{ $self->get_subnets->{private} // [] } ) {
    log_die( $self, 'subnet: [%s] is not in a public or private subnet in this VPC.', $subnet_id );
  }

  my $network_configuration = {
    awsvpcConfiguration => {
      subnets        => [$subnet_id],
      securityGroups => [ $security_groups->{fargate}->{group_id} ],
      assignPublicIp => $is_public ? 'ENABLED' : 'DISABLED',
    }
  };

  # check for latest image...
  $self->check_latest_image($task_name);

  # this may be null if we are in dryrun mode and the config has not been updated
  my $cluster_name
    = $dryrun && !$cluster->{name}
    ? sprintf '%s-cluster', $config->{app}->{name}
    : $cluster->{name};

  $self->log_warn( 'run-task: cluster: [%s] launching task: [%s] in subnet: [%s]...%s',
    $cluster_name, $task_name, $subnet_id, $dryrun );

  $self->log_trace( sub { return Dumper( [ awsvpcConfiguration => $network_configuration ] ); } );

  return $SUCCESS
    if $dryrun;

  log_die( $self, 'run-task: cluster has not been created yet...run "apply" first' )
    if !$cluster_name;

  my $ecs = $self->fetch_ecs;

  my $result = $self->get_ecs->run_task(
    cluster               => $cluster->{name},
    task_definition       => $task_name,
    network_configuration => $network_configuration,
  );

  log_die( $self, "ERROR: could not run task [%s]\n%s\n", $task_name, $self->get_ecs->get_error )
    if !$result;

  my @failures = @{ $result->{failures} };

  log_die( $self, 'ERROR: task failed to launch: %s', Dumper( \@failures ) )
    if @failures;

  ($tasks) = @{ $result->{tasks} };

  my $task_arn = $tasks->{taskArn};

  my $should_wait = $self->get_wait ? '(waiting)' : $EMPTY;

  $self->log_warn( 'run-task: task [%s] launched. ARN: [%s]...%s', $task_name, $task_arn, $should_wait );

  my $poll_limit = $self->get_task_timeout / $DEFAULT_ECS_POLL_TIME;

  if ($should_wait) {
    my $poll_count = 0;

    while ( $poll_count++ < $poll_limit ) {

      my ( $status, $stopped_reason, $exit_code ) = $self->get_task_status( $cluster_name, $task_arn );

      $self->log_warn( 'run-task: task [%s] status: [%s], exit code:[%s], reason: [%s]',
        $task_name, map { $_ // q{-} } ( $status, $exit_code, $stopped_reason ) );

      last if $status eq 'STOPPED';

      sleep $DEFAULT_ECS_POLL_TIME;
    }

    my $log_group = $config->{log_group}->{name};

    # by convention our log groups are named after our app
    my $log_stream = sprintf '%s/%s/%s', $config->{app}->{name}, $task_name, ( split /\//xsm, $task_arn )[-1];

    require App::Logs;

    my $logs = App::Logs->new(
      %{ $self->get_global_options },
      log_group_name  => $log_group,
      log_stream_name => $log_stream
    );

    my $events = $logs->get_log_events();

    log_die( $self, "run-task: unable to get logs from log group: [%s], stream: [%s]\n%s",
      $log_group, $log_stream, $logs->get_error )
      if !$events;

    while ( $events && @{ $events->{events} } ) {

      foreach my $e ( @{ $events->{events} } ) {

        my ( $timestamp, $message ) = @{$e}{qw(timestamp message)};
        $timestamp = scalar localtime $timestamp / 1000;

        print {*STDOUT} sprintf "%s - %s\n", $timestamp, $message;
      }

      $events = $logs->get_next_log_events( $events->{nextForwardToken} );
    }
  }

  return $SUCCESS;
}

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

  my $tasks = $self->common_args('tasks');

  my $latest_image   = $self->get_latest_image($task_name);
  my $latest_digest  = $latest_image->{imageDigest};
  my $current_digest = $tasks->{$task_name}->{image_digest} // $EMPTY;

  if ( $current_digest && $current_digest ne $latest_digest ) {
    $self->log_error('run-task: You are not running the latest image!');
    $self->log_error( 'run-task: [%s] != [%s]', $latest_digest, $current_digest );
    $self->log_error('run-task: run "app-FargateStack register-task" to align the latest image with your task');

    log_die( $self, 'run-task: use --force to force service creation or align your task with new image' )
      if !$self->get_force;
  }

  return $TRUE;
}

########################################################################
sub get_task_status {
########################################################################
  my ( $self, $cluster_name, $task_arn ) = @_;

  my @elems = qw(last_status stopped_reason containers);

  my $query = jmespath_mapping 'tasks[0]' => \@elems;
  my $ecs   = $self->fetch_ecs;

  my $result = $ecs->describe_tasks( $cluster_name, $task_arn, $query );
  $ecs->check_result( message => 'ERROR: unable to describe task: [%s]', $task_arn );

  my ( $status, $stopped_reason, $containers ) = @{$result}{@elems};

  return ( $status, $stopped_reason, $containers->[0]->{exitCode} );
}

########################################################################
sub get_default_service_name {
########################################################################
  my ( $self, $skip_arg ) = @_;

  # skip retrieving the arg (used when we want to allow a count as the first arg)
  if ( !$skip_arg ) {
    my ($service_name) = $self->get_args;

    return $service_name
      if $service_name;
  }

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

  return grep { $tasks->{$_}->{type} =~ /^(https?|daemon)/xsm } keys %{$tasks};
}

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

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

  my ( $task_name, $desired_count ) = $self->get_args;

  if ( $task_name && looks_like_number $task_name ) {
    $desired_count = $task_name;
    $task_name     = $EMPTY;
  }

  if ( !$task_name || !exists $tasks->{$task_name} ) {
    ( $task_name, my $err ) = $self->get_default_service_name($TRUE);  # skip arg

    if ( $err || !$task_name ) {
      if ($err) {
        $self->log_error('ERROR: multiple services in configuration.');
      }
      elsif ( !$task_name ) {
        $self->log_error( 'ERROR: %s',
          $task_name ? "service: [$task_name] not found in configuration" : 'no service types in configuration' );
      }

      die sprintf "usage: %s deploy-service service-name\n", $ENV{SCRIPT_NAME};
    }
  }

  if ( !$desired_count || !looks_like_number $desired_count ) {
    $desired_count = $tasks->{$task_name}->{desired_count} // 1;
  }

  $self->log_info('service: checking to see if task and latest image are aligned...');

  $self->check_latest_image($task_name);

  return $self->build_service( $task_name, $desired_count );
}

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

  my $level = $warn ? 'warn' : 'die';

  my $config = $self->get_config;

  return $TRUE
    if $task_name && $config->{tasks}->{$task_name};

  log_die( $self, 'ERROR:  no such task [%s] defined in config', $task_name )
    if $level eq 'die';

  $self->get_logger->warn( 'WARNING: no such task [%s] defined in config...trying anyway  ¯\_(ツ)_/¯', $task_name );

  return;
}

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

  my ( $task_name, $err ) = $self->get_default_service_name();

  die "usage: %s remove-service task-name\n", $ENV{SCRIPT_NAME}
    if !$task_name || $err;

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

  my $cluster_name = $cluster->{name};

  $self->verify_service($task_name);

  $self->check_task( $task_name, 'warn' );

  $self->log_warn( 'remove-service: task [%s] will be deleted...%s', $task_name, $dryrun );

  return $SUCCESS
    if $dryrun;

  my $ecs = $self->fetch_ecs;

  my $result = $ecs->delete_service( $cluster->{name}, $task_name );
  $ecs->check_result( message => 'ERROR: could not stop service %s', $task_name );

  return $SUCCESS;
}

########################################################################
sub verify_service {
########################################################################
  my ( $self, $service_name ) = @_;

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

  my $cluster_name //= $cluster->{name};

  my $ecs = $self->fetch_ecs;

  my $services = $ecs->list_services( $cluster_name, 'serviceArns' );

  die sprintf "ERROR: could not list services for cluster: [%s]\n%s", $cluster_name, $ecs->get_error
    if !$services;

  die sprintf "ERROR: no services running in cluster: [%s]\n", $cluster_name
    if !@{$services};

  die sprintf "ERROR: service [%s] is not running in cluster: [%s]\n", $service_name, $cluster_name
    if none { $_ =~ /$service_name/xsm } @{$services};

  return;
}

########################################################################
sub get_task_image_digests {
########################################################################
  my ( $self, $task_name ) = @_;
  my $ecs = $self->fetch_ecs;

  my $cluster_name = $self->get_config->{cluster}->{name};

  my $task_arns = $ecs->list_tasks( $cluster_name, 'taskArns' );
  $ecs->check_result( message => 'ERROR: Could not list tasks for cluster: [%s]', $cluster_name );

  my $group = sprintf 'service:%s', $task_name;

  my $query = sprintf 'tasks[?group == `%s`].containers[].{imageDigest:imageDigest}[].imageDigest', $task_name;

  my $image_digests = $ecs->describe_tasks( $cluster_name, $task_arns, $query );
  $ecs->check_result( message => 'ERROR: could not describe tasks for task arns: [%s]', join q{,}, @{$task_arns} );

  return $image_digests;
}

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

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

  my $task_definition_arn = $tasks->{$task_name};

  my $ecs = $self->fetch_ecs;

  my $task_definitions = $ecs->list_task_definitions( $task_name, 'taskDefinitionArns' );
  $ecs->check_result( message => 'ERROR: could list task definitions for [%s]', $task_name );

  my ($latest_task_definition) = sort {
    my ($num_a) = $a =~ /:(\d+)$/xsm;
    my ($num_b) = $b =~ /:(\d+)$/xsm;
    $num_b <=> $num_a
  } @{$task_definitions};

  my ($latest_task_definition_version) = $latest_task_definition =~ /(:\d+)$/xsm;
  my ($task_definition_version)        = $task_definition_arn    =~ /(:\d+)$/xsm;

  return $latest_task_definition,;
}

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

  my $service_name = $self->check_service_name( @args, $self->get_args );

  my ($cluster) = $self->common_args(qw(cluster));

  my $cluster_name = $cluster->{name};

  $self->verify_service($service_name);

  my $ecs = $self->fetch_ecs;

  my $task_definition_arn = $self->get_latest_task_definition($service_name);

  my @elems = qw(status running_count desired_count pending_count task_definition);

  my $result = $ecs->update_service(
    cluster_name    => $cluster_name,
    service_name    => $service_name,
    task_definition => basename($task_definition_arn),
    query           => jmespath_mapping( service => \@elems ),
  );

  $ecs->check_result( message => 'ERROR: could not update service: [%s]', $service_name );

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

  my @data = {
    'Status'        => $self->maybe_color( bright_white => $result->{status} ),
    'Running Count' => $self->maybe_color( green        => $result->{running_count} ),
    'Desired Count' => $self->maybe_color( bright_white => $result->{desired_count} ),
    'Pending Count' => $self->maybe_color( yellow       => $result->{pending_count} ),
  };

  print {*STDOUT} easy_table(
    table_options => {
      allowANSI   => $TRUE,
      headingText => sprintf "Service Status\nTask Definition: %s",
      $result->{task_definition}
    },
    data    => \@data,
    columns => [ 'Status', 'Running Count', 'Pending Count', 'Desired Count' ]
  );

  print {*STDOUT} <<'END_OF_NOTE';
* Note that this command will not force redeployment of your services! To force redeployment of your servicce:

 app-FargateStack redeploy [service-name]

END_OF_NOTE

  return;
}

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

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

  my $cluster_name = $cluster->{name};

  $self->verify_service($task_name);

  my $ecs = $self->get_ecs;

  my $result = $ecs->update_service(
    cluster_name  => $cluster_name,
    desired_count => $desired_count,
    service_name  => $task_name,
  );

  log_die( $self, "ERROR: could not update service: [%s]\n%s", $task_name, $ecs->get_error )
    if !$result;

  return $result;
}

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

  my ( $task_name, $count ) = $self->get_args;

  if ( looks_like_number $task_name ) {
    $count     = $task_name;
    $task_name = $EMPTY;
  }

  $task_name = $self->check_service_name($task_name);
  my $command = $self->command;

  if ( $command eq 'start-service' ) {
    $count ||= 1;
  }
  elsif ( $command eq 'update-service' ) {
  }
  else {
    $count = 0;
  }

  if ( !$task_name ) {
    if ( $count == 0 ) {
      die sprintf "usage: %s -c config-name stop-service task-name\n", $ENV{SCRIPT_NAME};
    }

    die sprintf "usage: %s -c config-name start-service task-name [count]\n", $ENV{SCRIPT_NAME};
  }

  my $result = $self->update_task_count( $task_name, $count );

  sleep 2;  # wait a few seconds for status to be updated

  return $self->cmd_service_status($task_name);
}

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

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

  my $task_name = $self->get_default_task_name;

  my $action = $self->get_skip_register ? 'update-target' : 'register';

  log_die( $self, 'usage: %s %s task-name', $action, $ENV{SCRIPT_NAME} )
    if !$task_name;

  my $task_definition_file = sprintf 'taskdef-%s.json', $task_name;

  $self->check_task($task_name);

  log_die( $self, "ERROR: no task definition file found for %s\n", $task_name )
    if !-s $task_definition_file;

  my $task_definition_arn;

  my $ecs = $self->fetch_ecs;

  if ( !$self->get_skip_register ) {
    $self->log_warn( 'register: registering task definition for: [%s]...%s', $task_name, $dryrun );

    if ( !$dryrun ) {
      my $task_definition = $ecs->register_task_definition($task_definition_file);
      $ecs->check_result( message => 'ERROR: register: could not register [%s]\n%s', $task_definition_file );
      $self->log_trace( sub { return Dumper( [ task_definition => $task_definition ] ) } );

      $task_definition_arn = $task_definition->{taskDefinition}->{taskDefinitionArn};

      log_die( $self, 'register: no taskDefinitionArn found? %s', Dumper( [ task_definition => $task_definition ] ) )
        if !$task_definition_arn;

      $self->log_warn( 'register: registered...[%s]', $task_definition_arn );

      $tasks->{$task_name}->{arn} = $task_definition_arn;

      my $latest_image = $self->get_latest_image($task_name);

      $self->log_info( 'register: updating image digest: [%s]', $latest_image->{imageDigest} );
      $tasks->{$task_name}->{image_digest} = $latest_image->{imageDigest};

      $self->update_config;  # record new task definition arn
    }
  }

  ## - events -
  if ( $tasks->{$task_name}->{type} eq 'task' ) {
    require App::Events;

    my $event = $self->fetch_events;

    my $rule_name = sprintf '%s-schedule', $task_name;

    my $target = $event->list_targets_by_rule( $rule_name, 'Targets' );
    $self->check_result( message => 'ERROR: could not list targets for: [%s]', $rule_name );

    if ( $target && @{$target} ) {

      # we only need to update the config if we skipped
      # registration...this is to allow for updating an event target
      # with a new task definition manually
      if ( !$dryrun && $self->get_skip_register ) {
        $config->{tasks}->{$task_name}->{arn} = $task_definition_arn;
        $self->update_config;
      }

      $self->create_event_target($task_name);
    }
  }

  return $SUCCESS;
}

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

  my $tasks = $self->common_args('tasks');

  my $ecr = $self->fetch_ecr;

  my ($repo_name) = split /:/xsm, $tasks->{$task_name}->{image};

  my ($latest) = $ecr->get_latest_image($repo_name);

  return $latest;
}

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

  my $config = $self->get_config;

  return $SUCCESS;
}

########################################################################
sub cmd_version {
########################################################################

  my $version_stmt = <<'END_OF_TEXT';
%s %s
Copyright 2025 (c) TBC Development Group, LLC.

License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.
END_OF_TEXT

  my $pgm = $ENV{SCRIPT_NAME} // $PROGRAM_NAME;

  print {*STDOUT} sprintf $version_stmt, $pgm, $VERSION;

  return $SUCCESS;
}

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

  $self->set_dryrun('(dryrun)');

  return $self->build(@args);
}

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

  $self->set_dryrun($EMPTY);

  return $self->build(@args);
}

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

  $self->set_skip_register($TRUE);

  return $self->cmd_register_task_definition(@args);
}

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

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

  my ($task_id) = $self->get_args;

  my ($task_name) = $self->check_service_name( $task_id, $TRUE );

  if ($task_name) {
    $task_id = $tasks->{$task_name}->{arn};
  }

  my $ecs = $self->get_ecs;

  die sprintf "usage: %s stop-task task-name|task-id|task-arn\n", $ENV{SCRIPT_NAME}
    if none { length $task_id == $_ } ( 32, 36 );

  my $result = $ecs->stop_task( $config->{cluster}->{name}, $task_id );

  log_die( $self, "ERROR: could not stop task: [%s]\n%s", $task_id, $ecs->get_error )
    if !$result;

  return $SUCCESS;
}

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

  my $config = $self->get_config;

  my ($desired_status) = map {uc} $self->get_args;
  $desired_status //= $EMPTY;

  my $cluster_name = $config->{cluster}->{name};

  my $ecs = $self->get_ecs;

  my $result = $ecs->list_tasks(
    { cluster_name   => $cluster_name,
      query          => 'taskArns',
      desired_status => $desired_status
    }
  );
  $ecs->check_result( message => 'ERROR: could not list tasks for cluster: [%s]', $cluster_name );

  if ( !@{$result} ) {
    print {*STDERR} sprintf "No tasks currently running in cluster: [%s]\n", $cluster_name;

    return $SUCCESS;
  }

  my $task_list = [ map { basename $_ } @{$result} ];

  require Text::Wrap;
  Text::Wrap->import('wrap');

  {
    ## no critic
    no warnings 'once';
    $Text::Wrap::columns = 100;
  }

  my @elems = qw(status task_definition_arn last_status started_at memory cpu attachments task_arn stopped_reason);

  my $query = jmespath_mapping 'tasks[]' => \@elems;

  $result = $ecs->describe_tasks( $cluster_name, $task_list, $query );

  croak sprintf "ERROR: could not list describe tasks: [%s]\n%s", $cluster_name, $ecs->get_error
    if !$result;

  my $title = sprintf 'Tasks (cluster: %s)', $cluster_name;

  my @data;

  foreach ( @{$result} ) {
    my ( $status, $last_status, $start_time, $memory, $cpu, $arn, $stopped_reason )
      = @{$_}{qw(status last_status started_at memory cpu task_arn stopped_reason)};

    $stopped_reason = wrap( q{}, q{}, $stopped_reason // q{} );

    my $task_name = basename( $_->{task_definition_arn} );
    $task_name =~ s/:\d+$//;

    $status //= $last_status;
    $status = $self->maybe_color( $status =~ /running/xsmi ? 'green' : 'red' => $status );

    push @data,
      {
      'Start Time'     => $start_time,
      Status           => $status // $last_status,
      Memory           => $memory,
      CPU              => $cpu,
      'Task Id'        => basename($arn),
      'Task Name'      => $task_name,
      'Elapsed Time'   => elapsed_time($start_time),
      'Stopped Reason' => $stopped_reason,
      };
  }

  print {*STDOUT} easy_table(
    table_options => { headingText => $title, allowANSI => $TRUE },
    data          => \@data,
    columns       => [
      'Task Name', 'Task Id', 'Status', 'Memory', 'CPU',
      ( $desired_status ne 'STOPPED' ) ? ( 'Start Time', 'Elapsed Time' ) : ('Stopped Reason')
    ],
  );

  return $SUCCESS;
}

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

  return $self->update_rule_state($TRUE);
}

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

  return $self->update_rule_state($FALSE);
}

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

  $self->set_cache($FALSE);

  return $self->build(@args);
}

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

  my $config = $self->get_config;

  $self->set_dryrun( $self->get_dryrun ? '(dryrun)' : $EMPTY );

  my %tasks = %{ $config->{tasks} };

  if ( !$self->get_force ) {
    my $warning = <<'END_OF_WARNING';
** WARNING ** 

This command will remove ALL resources provisioned for the cluster: [%s]!

Resources that were not provisioned by App::FargateStack will NOT be removed.

END_OF_WARNING
    print {*STDOUT} sprintf $warning, $config->{cluster}->{name}, scalar %tasks;

    log_die( $self, 'Aborting...' )
      if !confirm( sprintf 'Are you sure you want to delete the entire stack?' );
  }

  foreach my $task_name ( keys %tasks ) {
    $self->log_warn( 'destroy: deleting resources for: [%s]', $task_name );
    $self->delete_task_resources($task_name);
  }

  return $SUCCESS;
}

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

  print {*STDERR} "TBD\n";

  return $SUCCESS;
}

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

  my $config = $self->get_config;

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

  my $roles = sprintf 'Execution Role: [%s] Task Role: [%s]', $config->{role}->{name}, $config->{task_role}->{name};

  my $title = <<'END_OF_TITLE';
Stack Summary

Cluster: [%s] VPC: [%s] Region: [%s] Tasks: [%s]
%s

END_OF_TITLE
  $title = sprintf $title, $config->{cluster}->{name}, $config->{vpc_id}, $config->{region}, scalar keys %{$tasks}, $roles;

  my @data;

  foreach my $task_name ( keys %{$tasks} ) {
    my $task = $tasks->{$task_name};

    my $environment = join "\n", map { sprintf '%s: %s', $_, $task->{environment}->{$_} } keys %{ $task->{environment} // {} };

    push @data, { Task => $task_name, Type => $task->{type}, Size => $task->{size}, Environment => $environment };
  }

  print {*STDOUT} easy_table(
    table_options => { headingText => $title },
    data          => \@data,
    columns       => [qw(Task Type Size Environment)],
  );

  return $SUCCESS;
}

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

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

  my $task_name = $self->get_default_task_name( 'task', 'schedule' );

  die sprintf "usage: %s delete-schedule task-name\n", $ENV{SCRIPT_NAME}
    if !$task_name;

  die sprintf "ERROR: %s is not a schedule task\n", $task_name
    if $tasks->{$task_name}->{type} ne 'task' || !$tasks->{$task_name}->{schedule};

  # $task_name must be a schedule because we filtered above
  if ( !$self->get_force && scalar keys %{$tasks} == 1 ) {

    print {*STDERR} <<"END_OF_ERROR";
This is the only task in your configuration.

If you want to stop the task from running, consider:
  * Running: disable-scheduled-task $task_name
    (This leaves the task in place but disables the EventBridge rule.)

If you're sure you want to delete this stack use the --force option.

Deletion aborted to prevent unintended removal of the entire stack.
END_OF_ERROR
    exit 1;
  }

  if ( !$self->get_force ) {
    log_die( $self, 'Aborting...' )
      if !confirm( sprintf 'Are you sure you want to delete the %s scheduled task', $task_name );
  }

  return $self->delete_task_resources($task_name);
}

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

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

  my $task_name = $self->get_default_task_name('task');

  die sprintf "usage: %s delete-task task-name\n", $ENV{SCRIPT_NAME}
    if !$task_name;

  die sprintf "ERROR: [%s] is a not a task\n", $task_name
    if $tasks->{$task_name}->{type} ne 'task';

  die sprintf "ERROR: [%s] is a scheduled task...use 'delete-schedule'\n", $task_name
    if $tasks->{$task_name}->{schedule};

  if ( !$self->get_force && scalar keys %{$tasks} == 1 ) {
    print {*STDERR} <<"END_OF_ERROR";
This is the only task in your configuration.

If you're finished with this task and want to remove all artifacts,
you can run:
  * destroy

Deletion aborted to prevent unintended removal of the entire stack.
END_OF_ERROR
    exit 1;
  }

  return $self->delete_task_resources($task_name);
}

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

  my $tasks     = $self->get_config->{tasks};
  my $task_name = $self->get_default_task_name( 'task', 'daemon' );

  die "usage: $ENV{SCRIPT_NAME} delete-daemon task-name\n"
    if !$task_name;

  die sprintf "ERROR: [%s] is not a daemon\n", $task_name
    if $tasks->{$task_name}->{type} ne 'daemon';

  if ( scalar keys %{$tasks} == 1 ) {
    my $msg = <<'END_OF_ERROR';
This is the only task in your configuration.

You can stop the daemon from running with the "stop-service" command.
END_OF_ERROR
    print {*STDERR} colored( $msg, 'bright_red' );
  }

  return $self->delete_task_resources($task_name);
}

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

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

  my ( $task_name, $err ) = $self->get_default_service_name();

  die "usage: $ENV{SCRIPT_NAME} delete-http task-name\n"
    if !$task_name || $err;

  die "ERROR: [$task_name] is not an http task"
    if !$tasks->{$task_name}->{type} =~ /^https?/xsm;

  if ( scalar keys %{$tasks} == 1 ) {
    my $msg = <<'END_OF_WARNING';
WARNING: This is the only task in your configuration.

 - You can stop the http service from running with the "stop-service" command.
 - You can delete only the service with the "delete-service" command.

END_OF_WARNING
    print {*STDERR} colored( $msg, 'bright_red' );
  }

  return $self->delete_task_resources($task_name);
}

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

  my $cluster      = $self->common_args('cluster');
  my $cluster_name = $cluster->{name};

  my ( $service_name, $err ) = $self->get_default_service_name;

  die sprintf "usage: %s redeploy service-name\n", $ENV{SCRIPT_NAME}
    if !$service_name || $err;

  my $ecs = $self->get_ecs;

  my $result = $ecs->update_service(
    cluster_name => $cluster_name,
    service_name => $service_name,
    force        => $TRUE
  );

  log_die( $self, "ERROR: could not update service: [%s]\n%s", $service_name, $ecs->get_error )
    if !$result;

  $self->log_info( 'redeploy: successfully updated service: [%s]', $service_name );

  return $SUCCESS;
}

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

  $self->fetch_option_defaults($TRUE);

  return $SUCCESS;
}

########################################################################
sub fetch_option_defaults {
########################################################################
  my ( $self, $reset ) = @_;

  my $options = {};

  my $defaults_file = '.fargatestack/defaults.json';

  if ( -s $defaults_file ) {
    $options = slurp_file( $defaults_file, $TRUE );
  }
  else {
    mkdir '.fargatestack';
  }

  return $self->write_json_file( $defaults_file, {} )
    if $reset;

  $options->{profile}         = $self->get_profile     // $options->{profile};
  $options->{config}          = $self->get_config_name // $options->{config};
  $options->{region}          = $self->default_region( $options->{region} );
  $options->{route53_profile} = $self->get_route53_profile // $options->{route53_profile};
  $options->{max_events}      = $self->get_max_events      // $options->{max_events};

  $self->set_profile( $options->{profile} );
  $self->set_config_name( $options->{config} );
  $self->set_route53_profile( $options->{route53_profile} );
  $self->set_max_events( $options->{max_events} );

  $self->write_json_file( $defaults_file, $options );

  return $options;
}

########################################################################
sub build_section_paths {
########################################################################
  my @items = @_;  # list of strings like '1:Title'

  my %paths;
  my @stack;

  foreach my $line (@items) {
    next if $line !~ /^(\d+):(.*)$/xsm;

    my ( $level, $title ) = ( $1, $2 );
    $level = int $level;  # Normalize level

    # Adjust the stack to this level
    $#stack = $level - 2;               # level 1 means index 0, so -2 to truncate above
    $stack[ $level - 1 ] = $title;

    # Join the stack up to this level
    my $path = join '/', @stack[ 0 .. $level - 1 ];
    $paths{$title} = $path;
  }

  return %paths;
}

########################################################################
sub parse_pod_sections {
########################################################################
  my $pod = slurp_file( $INC{'App/FargateStack/Pod.pm'} );
  my @sections;

  while ( $pod =~ /=head(\d+)\s+(.*?)$/gxsm ) {
    push @sections, "$1:$2";
  }

  return build_section_paths(@sections);
}

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

  my $subject = lc join $SPACE, @ARGV;
  $subject =~ s/\s+$//xsm;

  my %pod_sections = parse_pod_sections();
  my $section;

  if ( $subject && $subject ne 'help' ) {
    my ( $pod_section, $err ) = grep {/^$subject/smi} keys %pod_sections;

    if ($pod_section) {
      $section = $pod_sections{$pod_section};
      $section =~ s/[?]/\\?/gxsm;
    }
  }

  if ( !$section ) {
    $section = $HELP_SUBJECTS{$subject} // $EMPTY;

    if ( $subject && !$section && $subject ne 'help' ) {
      my @possible_subjects = grep {/$subject/xsmi} keys %HELP_SUBJECTS;

      if ( @possible_subjects == 1 ) {
        $section = $HELP_SUBJECTS{ $possible_subjects[0] };
      }
      elsif (@possible_subjects) {
        print {*STDERR} sprintf "'%s' was not found in the help index.\n\nPossible matches:\n\t* %s\n",
          $subject,
          join "\n\t* ",
          @possible_subjects;
        exit 1;
      }
    }

    if ( $section && ref $section ) {
      $section = uc $section->[0];
    }
    elsif ($section) {  # a help subject alias
      my $reference = $HELP_SUBJECTS{$section};
      $section = uc $reference->[0];
    }
  }

  eval {
    require IO::Pager;
    IO::Pager::open( *STDOUT, '|-:utf8', 'Unbuffered' );
  };

  if ( $subject && !$section ) {
    if ( $subject ne 'help' ) {
      print {*STDERR} sprintf "'%s' is not a valid subject\n", $subject;
    }

    my @data;

    foreach my $keyword ( sort keys %HELP_SUBJECTS ) {
      my $description = $HELP_SUBJECTS{$keyword};
      if ( ref $description ) {
        $description = $description->[1];
      }
      push @data, { Keyword => $keyword, Description => $description };
    }

    my $table = easy_table(
      columns       => [qw(Keyword Description)],
      data          => \@data,
      table_options => { headingText => 'Help Subjects' },
    );

    print {*STDOUT} $table;

    exit $SUCCESS;
  }

  return pod2usage(
    -input   => $INC{'App/FargateStack/Pod.pm'},
    -exitval => 1,
    -verbose => 99,
    -width   => 80,
    $section ? ( -sections => $section // 'USAGE' ) : ()
  );
}

########################################################################
sub main {
########################################################################

  my @extra_options = qw(
    account
    alb
    config_name
    cloudtrail
    ec2
    ecs
    ecr
    efs
    elbv2
    events
    existing_resources
    http
    iam
    global_options
    logs
    log_groups
    logger
    profile_source
    required_resources
    route53
    sts
    secrets
    subnets
    taskdef_status
  );

  my @option_specs = qw(
    help|h
    config|c=s
    color!
    confirm-all!
    create-alb|C
    dryrun|d
    force|f
    history|H!
    log-level=s
    log-time!
    log-wait!
    log-poll-time=s
    max-events|m=i
    output=s
    profile|p=s
    purge_config
    region|r=s
    route53-profile|R=s
    skip-register|s
    subnet-id=s
    task-timeout|t
    update|u!
    unlink|U!
    cache!
    version|v
    wait|w!
  );

  my %default_options = (
    wait            => $TRUE,
    unlink          => $TRUE,
    color           => $TRUE,
    cache           => $TRUE,
    'log-time'      => $TRUE,
    'log-wait'      => $TRUE,
    'log-poll-time' => $DEFAULT_LOG_POLL_TIME,
    'task-timeout'  => $DEFAULT_ECS_POLL_LIMIT,
    history         => $TRUE,
    update          => $TRUE,
    'max-events'    => $DEFAULT_MAX_EVENTS,
    output          => 'text',
  );

  my %commands = (
    'add-scaling-policy'      => [ \&cmd_add_scaling_policy,   'error', { skip_init => $TRUE, skip_config => $FALSE } ],
    'add-scheduled-action'    => [ \&cmd_add_scheduled_action, 'error', { skip_init => $TRUE, skip_config => $FALSE } ],
    'create-stack'            => [ \&cmd_create_stack,         'error', { skip_init => $TRUE, skip_config => $TRUE } ],
    'delete-daemon'           => \&cmd_delete_daemon,
    'delete-http-service'     => \&cmd_delete_http_service,
    'delete-scaling-policy'   => [ \&cmd_delete_autoscaling_policy, 'error', { skip_init => $TRUE, skip_config => $FALSE } ],
    'delete-schedule'         => \&cmd_delete_schedule,
    'delete-scheduled-action' => [ \&cmd_delete_scheduled_action, 'error', { skip_init => $TRUE, skip_config => $FALSE } ],
    'delete-service'          => [ \&cmd_remove_service, 'info' ],
    'delete-task'             => \&cmd_delete_task,
    'deploy-service'          => [ \&cmd_deploy_service, 'info' ],
    'disable-scheduled-task'  => \&cmd_disable_scheduled_task,
    'enable-scheduled-task'   => \&cmd_enable_scheduled_task,
    'list-tasks'              => [ \&cmd_list_tasks, 'error' ],
    'list-zones'              => [ \&cmd_list_zones, 'error' ],
    'register-task'           => \&cmd_register_task_definition,
    'remove-service'          => [ \&cmd_remove_service, 'info' ],
    'reset-history'           => [ \&cmd_reset_history,  'info', { skip_init => $TRUE, skip_config => $TRUE } ],
    'run-task'                => \&cmd_run_task,
    'start-service'           => [ \&cmd_start_stop_service, 'info' ],
    'stop-service'            => [ \&cmd_start_stop_service, 'error' ],
    'stop-task'               => [ \&cmd_stop_task,          'error' ],
    'update-policy'           => \&cmd_update_policy,
    'update-service'          => [ \&cmd_update_service, 'error', { skip_init => $TRUE } ],
    'update-target'           => \&cmd_update_target,
    apply                     => \&cmd_apply,
    default                   => [ \&cmd_explain, 'error' ],
    destroy                   => [ \&cmd_destroy, 'error', { skip_init => $TRUE, skip_config => $FALSE } ],
    help                      => [ \&help,        'error', { skip_init => $TRUE, skip_config => $TRUE } ],
    logs                      => [ \&cmd_logs,    'error' ],
    plan                      => \&cmd_plan,
    redeploy                  => \&cmd_redeploy,
    show                      => [ \&cmd_show,           'error', { skip_init => $TRUE, skip_config => $TRUE } ],
    state                     => [ \&cmd_state,          'error', { skip_init => $TRUE, skip_config => $TRUE } ],
    status                    => [ \&cmd_service_status, 'error' ],
    tasks                     => [ \&cmd_tasks,          'error', { skip_init => $TRUE, skip_config => $FALSE } ],
    version                   => [ \&cmd_version,        'error', { skip_init => $TRUE, skip_config => $TRUE } ],
  );

  my $fargate_stack = App::FargateStack->new(
    commands        => \%commands,
    default_options => \%default_options,
    extra_options   => \@extra_options,
    option_specs    => \@option_specs,
    abbreviations   => $TRUE,
    error_handler   => sub {
      print {*STDERR} shift;
      return $FALSE;
    },
    alias => {
      options  => { 'dns-profile' => 'route53-profile' },
      commands => {
        'create-service'            => 'deploy-service',
        'delete-autoscaling-policy' => 'delete-scaling-policy',
      },
    },
  );

  $fargate_stack->run();

  return 0;
}

1;

__END__


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