Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion docs/guide/scaling.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,20 @@ Start with CPU (it ships working). Add the request-count policy once you have a

Until you set it, CPU-based autoscaling is already active β€” you lose nothing by waiting for real data.

### Turning autoscaling off

Autoscaling is declarative β€” sync reconciles live state down to what the manifest asks for, so removing config tears the matching infrastructure back down on the next `yolo sync`:

| You remove… | Next sync… |
| --- | --- |
| `request-count-per-target` (keep the block) | Deletes the request-count policy and the scale-out / scale-in alarms AWS generated for it. The CPU policy stays. |
| The whole `autoscaling` block | Deregisters the scalable target, which cascades the delete to **every** policy and alarm on it. |

Deregistering doesn't drop tasks β€” the service reverts to a **fixed** task count frozen at its current live count. Bring it down with [`yolo scale`](#manual-scaling) if you no longer need the extra capacity.

### What isn't tagged

Application Auto Scaling targets and policies can't carry tags, so they don't show up in [`yolo audit`](/reference/commands#yolo-audit). Tearing an app down has to deregister the scalable target explicitly.
Application Auto Scaling targets and policies can't carry tags, so they don't show up in [`yolo audit`](/reference/commands#yolo-audit) β€” they're reconciled by config (above) rather than by the tag-driven audit.

## Manual scaling

Expand Down
37 changes: 37 additions & 0 deletions src/Aws/ApplicationAutoScaling.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,41 @@ public static function scalingPolicy(string $resourceId, string $policyName): ar

throw new ResourceDoesNotExistException("Could not find scaling policy $policyName");
}

/**
* Every scaling policy name registered on an ECS service resource id β€” an
* empty list when none are (or the target is gone). A sync step diffs this
* live set against the desired set to find policies to prune.
*
* @return array<int, string>
*/
public static function policyNames(string $resourceId): array
{
try {
$policies = Aws::applicationAutoScaling()->describeScalingPolicies([
'ServiceNamespace' => self::SERVICE_NAMESPACE,
'ResourceId' => $resourceId,
'ScalableDimension' => self::SCALABLE_DIMENSION,
])['ScalingPolicies'];
} catch (AwsException) {
return [];
}

return array_map(fn ($policy) => $policy['PolicyName'], $policies);
}

/**
* Delete a target-tracking scaling policy. Application Auto Scaling cascades
* the delete to the scale-out / scale-in CloudWatch alarms it generated for
* the policy, so this removes the policy and its alarms in one call.
*/
public static function deleteScalingPolicy(string $resourceId, string $policyName): void
{
Aws::applicationAutoScaling()->deleteScalingPolicy([
'ServiceNamespace' => self::SERVICE_NAMESPACE,
'ResourceId' => $resourceId,
'ScalableDimension' => self::SCALABLE_DIMENSION,
'PolicyName' => $policyName,
]);
}
}
13 changes: 6 additions & 7 deletions src/Commands/SyncAppCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -102,13 +102,12 @@ public function scopes(): array
Steps\Sync\App\SyncTaskDefinitionStep::class,
Steps\Sync\App\SyncEcsServiceStep::class,
// Autoscaling (web only) β€” registered after the service it
// scales, and only when a tasks.web.autoscaling block opts in.
...Manifest::has('tasks.web.autoscaling')
? [
Steps\Sync\App\SyncScalableTargetStep::class,
Steps\Sync\App\SyncScalingPoliciesStep::class,
]
: [],
// scales. Wired whenever the web task exists, not just when
// autoscaling is on, so removing the tasks.web.autoscaling
// block tears the scalable target, policies and their alarms
// back down. Both steps no-op when it was never enabled.
Steps\Sync\App\SyncScalableTargetStep::class,
Steps\Sync\App\SyncScalingPoliciesStep::class,
Steps\Sync\App\SyncAssetDistributionStep::class,
]
: [],
Expand Down
9 changes: 6 additions & 3 deletions src/Concerns/RunsSteppedCommands.php
Original file line number Diff line number Diff line change
Expand Up @@ -124,16 +124,17 @@ protected function runScopes(string $environment, array $scopes): int
/**
* Did the plan pass flag this step as having work for apply to do?
*
* A step has pending work when it would create or sync a resource, or when it
* recorded an attribute-level Change. Everything else β€” clean SYNCED, SKIPPED,
* CUSTOM_MANAGED β€” is dropped before apply.
* A step has pending work when it would create, sync or delete a resource, or
* when it recorded an attribute-level Change. Everything else β€” clean SYNCED,
* SKIPPED, CUSTOM_MANAGED β€” is dropped before apply.
*
* @param array{status: StepResult|string, changes: array<int, Change>} $entry
*/
protected static function planEntryHasWork(array $entry): bool
{
return $entry['status'] === StepResult::WOULD_CREATE
|| $entry['status'] === StepResult::WOULD_SYNC
|| $entry['status'] === StepResult::WOULD_DELETE
|| $entry['changes'] !== [];
}

Expand Down Expand Up @@ -604,12 +605,14 @@ protected static function renderStatus(StepResult|string $status): string
StepResult::CREATED => '<fg=green>CREATED</>',
StepResult::SUCCESS => '<fg=green>SUCCESS</>',
StepResult::SYNCED => '<fg=green>SYNCED</>',
StepResult::DELETED => '<fg=green>DELETED</>',

// yellow
StepResult::SKIPPED => '<fg=yellow>SKIPPED</>',
StepResult::CUSTOM_MANAGED => '<fg=yellow>CUSTOM MANAGED</>',
StepResult::WOULD_CREATE => '<fg=yellow>WOULD CREATE</>',
StepResult::WOULD_SYNC => '<fg=yellow>WOULD SYNC</>',
StepResult::WOULD_DELETE => '<fg=yellow>WOULD DELETE</>',

// red
StepResult::MANIFEST_INVALID => '<fg=red>MANIFEST INVALID</>',
Expand Down
3 changes: 3 additions & 0 deletions src/Enums/StepResult.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ enum StepResult
case SYNCED;
case WOULD_SYNC;

case DELETED;
case WOULD_DELETE;

case CUSTOM_MANAGED;
case TIMEOUT;
case SKIPPED;
Expand Down
28 changes: 26 additions & 2 deletions src/Steps/Sync/App/SyncScalableTargetStep.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

namespace Codinglabs\Yolo\Steps\Sync\App;

use Codinglabs\Yolo\Change;
use Illuminate\Support\Arr;
use Codinglabs\Yolo\Helpers;
use Codinglabs\Yolo\Manifest;
use Codinglabs\Yolo\Contracts\Step;
use Codinglabs\Yolo\Enums\StepResult;
use Codinglabs\Yolo\Concerns\RecordsChanges;
Expand All @@ -16,8 +18,10 @@
* Registers and reconciles the web service's Application Auto Scaling scalable
* target β€” the min/max desired-count bounds the policies move within. The
* manifest is the source of truth (tasks.web.autoscaling.min/max), reconciled
* to live on every sync. Only wired into sync:app when a tasks.web.autoscaling
* block is present, so a fixed app keeps its single create-only task.
* to live on every sync. Wired into sync:app whenever the web task exists (not
* only when autoscaling is on) so removing the block can tear the target down;
* with no block and nothing registered it no-ops, leaving a fixed app's single
* create-only task untouched.
*
* Reductions are surfaced as Changes and gated by the normal plan β†’ confirm flow,
* EXCEPT under --force / non-interactive: there the step refuses to lower a live
Expand All @@ -26,6 +30,12 @@
* `yolo sync` (the operator sees the reduction in the plan and confirms) or
* `yolo scale`. Raises always apply.
*
* When the autoscaling block is removed from the manifest the step deregisters
* the scalable target β€” Application Auto Scaling cascades the delete to every
* scaling policy on it and the alarms those policies generated. The ECS service
* reverts to a fixed task count, frozen at its current live count (deregister
* doesn't drop tasks); lower it with `yolo scale` if needed.
*
* Skips on a greenfield first sync when the ECS service doesn't exist yet.
*/
class SyncScalableTargetStep implements Step
Expand All @@ -42,6 +52,20 @@ public function __invoke(array $options): StepResult
$target = new ScalableTarget();
$live = $target->current();

if (! Manifest::has('tasks.web.autoscaling')) {
if ($live === null) {
return StepResult::SKIPPED;
}

$this->recordChanges([Change::make('web autoscaling', sprintf('%d-%d', $live['min'], $live['max']), null)]);

if (! $dryRun) {
$target->deregister();
}

return $dryRun ? StepResult::WOULD_DELETE : StepResult::DELETED;
}

if (! $dryRun && static::wouldReduce($target, $live) && static::unattended($options)) {
warning(sprintf(
'Skipped the web autoscaling reduction: manifest bounds (%d–%d) are below live (%d–%d). Lower capacity with an interactive `yolo sync` or `yolo scale` β€” never unattended.',
Expand Down
73 changes: 70 additions & 3 deletions src/Steps/Sync/App/SyncScalingPoliciesStep.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,21 @@

namespace Codinglabs\Yolo\Steps\Sync\App;

use Codinglabs\Yolo\Change;
use Illuminate\Support\Arr;
use Codinglabs\Yolo\Helpers;
use Codinglabs\Yolo\Manifest;
use Codinglabs\Yolo\Contracts\Step;
use Codinglabs\Yolo\Enums\StepResult;
use Codinglabs\Yolo\Concerns\RecordsChanges;
use Codinglabs\Yolo\Resources\Ecs\EcsService;
use Codinglabs\Yolo\Aws\ApplicationAutoScaling;
use Codinglabs\Yolo\Resources\ElbV2\TargetGroup;
use Codinglabs\Yolo\Resources\ElbV2\LoadBalancer;
use Codinglabs\Yolo\Resources\CloudWatch\Dashboard;
use Codinglabs\Yolo\Exceptions\ResourceDoesNotExistException;
use Codinglabs\Yolo\Resources\ApplicationAutoScaling\ScalingPolicy;
use Codinglabs\Yolo\Resources\ApplicationAutoScaling\ScalableTarget;

/**
* Reconciles the web service's target-tracking scaling policies onto its scalable
Expand All @@ -23,16 +26,32 @@
* tasks.web.autoscaling.request-count-per-target value is set, since its target
* is the per-target request plateau that has to come from a load test.
*
* Reconciles desired against live: it upserts the policies the manifest wants and
* prunes any live policy it no longer does β€” removing request-count-per-target
* deletes that policy (and the alarms AWS generated for it) on the next sync.
*
* Skips on a greenfield first sync when the ECS service doesn't exist yet, and
* silently drops the request-count policy when the ALB / target group aren't
* resolvable yet (it lands on the next sync once they are).
* resolvable yet (it lands on the next sync once they are). When the whole
* autoscaling block is removed this step no-ops β€” SyncScalableTargetStep
* deregisters the scalable target, which cascades every policy and alarm.
*/
class SyncScalingPoliciesStep implements Step
{
use RecordsChanges;

protected const CPU_POLICY = 'cpu-scaling-policy';

protected const REQUEST_COUNT_POLICY = 'request-count-scaling-policy';

public function __invoke(array $options): StepResult
{
// Autoscaling removed entirely β†’ the scalable target is deregistered by
// SyncScalableTargetStep, cascading every policy and alarm. Nothing to do.
if (! Manifest::has('tasks.web.autoscaling')) {
return StepResult::SKIPPED;
}

if (! (new EcsService())->exists()) {
return StepResult::SKIPPED;
}
Expand All @@ -41,6 +60,7 @@ public function __invoke(array $options): StepResult

$created = false;
$synced = false;
$deleted = false;

foreach (static::policies() as $policy) {
$existed = $policy->exists();
Expand All @@ -55,17 +75,64 @@ public function __invoke(array $options): StepResult
}
}

foreach (static::orphans() as $orphan) {
$this->recordChanges([Change::make($orphan, 'present', null)]);

if (! $dryRun) {
ApplicationAutoScaling::deleteScalingPolicy(ScalableTarget::resourceId(), $orphan);
}

$deleted = true;
}

if ($created) {
return $dryRun ? StepResult::WOULD_CREATE : StepResult::CREATED;
}

if ($deleted) {
return $dryRun ? StepResult::WOULD_DELETE : StepResult::DELETED;
}

if ($synced) {
return $dryRun ? StepResult::WOULD_SYNC : StepResult::SYNCED;
}

return StepResult::SYNCED;
}

/**
* Live policies on the scalable target that the manifest no longer wants.
* Diffed against desiredPolicyNames() β€” the manifest's intent β€” NOT policies(),
* so a request-count policy that's merely deferred (its ResourceLabel not
* resolvable on a greenfield sync) is never mistaken for one to prune.
*
* @return array<int, string>
*/
public static function orphans(): array
{
return array_values(array_diff(
ApplicationAutoScaling::policyNames(ScalableTarget::resourceId()),
static::desiredPolicyNames(),
));
}

/**
* The policy names the manifest intends to exist, independent of whether the
* ALB / target group resolve right now β€” the prune set's source of truth.
*
* @return array<int, string>
*/
public static function desiredPolicyNames(): array
{
$names = [Helpers::keyedResourceName(static::CPU_POLICY)];

if (Manifest::has('tasks.web.autoscaling.request-count-per-target')) {
$names[] = Helpers::keyedResourceName(static::REQUEST_COUNT_POLICY);
}

return $names;
}

/**
* The scaling policies for the app: CPU always, plus request-count once its
* target value is configured and the ALB + target group are resolvable.
Expand All @@ -76,15 +143,15 @@ public static function policies(): array
{
$policies = [
new ScalingPolicy(
policyName: Helpers::keyedResourceName('cpu-scaling-policy'),
policyName: Helpers::keyedResourceName(static::CPU_POLICY),
metricType: 'ECSServiceAverageCPUUtilization',
targetValue: (float) Manifest::get('tasks.web.autoscaling.cpu-utilization', 65),
),
];

if (Manifest::has('tasks.web.autoscaling.request-count-per-target') && ($resourceLabel = static::resourceLabel()) !== null) {
$policies[] = new ScalingPolicy(
policyName: Helpers::keyedResourceName('request-count-scaling-policy'),
policyName: Helpers::keyedResourceName(static::REQUEST_COUNT_POLICY),
metricType: 'ALBRequestCountPerTarget',
targetValue: (float) Manifest::get('tasks.web.autoscaling.request-count-per-target'),
resourceLabel: $resourceLabel,
Expand Down
54 changes: 54 additions & 0 deletions tests/Unit/Steps/Sync/App/SyncScalableTargetStepTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,57 @@
expect((new SyncScalableTargetStep())([]))->toBe(StepResult::SYNCED);
expect(collect($aa)->firstWhere('name', 'RegisterScalableTarget')['args'])->toMatchArray(['MinCapacity' => 2, 'MaxCapacity' => 6]);
});

it('deregisters the target when the autoscaling block is removed', function () {
writeManifest([
'account-id' => '111111111111',
'region' => 'ap-southeast-2',
'tasks' => ['web' => []],
]);

$ecs = [];
$aa = [];
bindRoutedEcsClient(['DescribeServices' => new Result(['services' => [['status' => 'ACTIVE', 'serviceArn' => 'arn']]])], $ecs);
bindMockApplicationAutoScalingClient([
'DescribeScalableTargets' => new Result(['ScalableTargets' => [['MinCapacity' => 1, 'MaxCapacity' => 4]]]),
'DeregisterScalableTarget' => new Result([]),
], $aa);

expect((new SyncScalableTargetStep())([]))->toBe(StepResult::DELETED);
expect(collect($aa)->pluck('name'))->toContain('DeregisterScalableTarget');
});

it('would-deregister on a dry-run without deregistering', function () {
writeManifest([
'account-id' => '111111111111',
'region' => 'ap-southeast-2',
'tasks' => ['web' => []],
]);

$ecs = [];
$aa = [];
bindRoutedEcsClient(['DescribeServices' => new Result(['services' => [['status' => 'ACTIVE', 'serviceArn' => 'arn']]])], $ecs);
bindMockApplicationAutoScalingClient([
'DescribeScalableTargets' => new Result(['ScalableTargets' => [['MinCapacity' => 1, 'MaxCapacity' => 4]]]),
'DeregisterScalableTarget' => new Result([]),
], $aa);

expect((new SyncScalableTargetStep())(['dry-run' => true]))->toBe(StepResult::WOULD_DELETE);
expect(collect($aa)->pluck('name'))->not->toContain('DeregisterScalableTarget');
});

it('skips when autoscaling is removed and no target is registered', function () {
writeManifest([
'account-id' => '111111111111',
'region' => 'ap-southeast-2',
'tasks' => ['web' => []],
]);

$ecs = [];
$aa = [];
bindRoutedEcsClient(['DescribeServices' => new Result(['services' => [['status' => 'ACTIVE', 'serviceArn' => 'arn']]])], $ecs);
bindMockApplicationAutoScalingClient(['DescribeScalableTargets' => new Result(['ScalableTargets' => []])], $aa);

expect((new SyncScalableTargetStep())([]))->toBe(StepResult::SKIPPED);
expect(collect($aa)->pluck('name'))->not->toContain('DeregisterScalableTarget');
});
Loading