From d314227a3b6247895a9be3fe91515b67631ea23f Mon Sep 17 00:00:00 2001 From: Steve Thomas Date: Wed, 3 Jun 2026 18:10:58 +1000 Subject: [PATCH] feat(sync): tear down de-configured autoscaling instead of orphaning it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Autoscaling is now fully declarative — sync converges live state down to the manifest, so changing config tidies up the scalable target, policies and their AWS-generated alarms rather than leaving them live and invisible to `yolo audit`. - Remove `request-count-per-target` → the request-count policy is pruned (cascading its scale-out/scale-in alarms); the CPU policy stays. - Remove the whole `autoscaling` block → the scalable target is deregistered, cascading every policy and alarm. The service reverts to a fixed task count frozen at its current live count (no tasks dropped). The two autoscaling steps are now wired whenever the web task exists (not only when autoscaling is on) so the teardown path can run after the block is removed; both no-op when it was never enabled. Pruning diffs live policies against the manifest's intent, never the resolvable-now set, so a merely-deferred request-count policy (ALB/TG not yet resolvable) is never mistaken for one that was removed. Adds DELETED / WOULD_DELETE to StepResult so teardown reports as a delete rather than a SYNCED — completing the create/update/delete reconcile verbs and making the destructive action legible at the plan -> confirm gate. Co-Authored-By: Claude Opus 4.8 --- docs/guide/scaling.md | 13 ++- src/Aws/ApplicationAutoScaling.php | 37 +++++++ src/Commands/SyncAppCommand.php | 13 ++- src/Concerns/RunsSteppedCommands.php | 9 +- src/Enums/StepResult.php | 3 + src/Steps/Sync/App/SyncScalableTargetStep.php | 28 ++++- .../Sync/App/SyncScalingPoliciesStep.php | 73 ++++++++++++- .../Sync/App/SyncScalableTargetStepTest.php | 54 ++++++++++ .../Sync/App/SyncScalingPoliciesStepTest.php | 102 ++++++++++++++++++ 9 files changed, 316 insertions(+), 16 deletions(-) diff --git a/docs/guide/scaling.md b/docs/guide/scaling.md index fe1e88c..7db88f8 100644 --- a/docs/guide/scaling.md +++ b/docs/guide/scaling.md @@ -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 diff --git a/src/Aws/ApplicationAutoScaling.php b/src/Aws/ApplicationAutoScaling.php index 9bc6024..446cb68 100644 --- a/src/Aws/ApplicationAutoScaling.php +++ b/src/Aws/ApplicationAutoScaling.php @@ -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 + */ + 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, + ]); + } } diff --git a/src/Commands/SyncAppCommand.php b/src/Commands/SyncAppCommand.php index 2cee77d..d050f03 100644 --- a/src/Commands/SyncAppCommand.php +++ b/src/Commands/SyncAppCommand.php @@ -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, ] : [], diff --git a/src/Concerns/RunsSteppedCommands.php b/src/Concerns/RunsSteppedCommands.php index 47c56b2..4f3422e 100644 --- a/src/Concerns/RunsSteppedCommands.php +++ b/src/Concerns/RunsSteppedCommands.php @@ -124,9 +124,9 @@ 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} $entry */ @@ -134,6 +134,7 @@ 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'] !== []; } @@ -604,12 +605,14 @@ protected static function renderStatus(StepResult|string $status): string StepResult::CREATED => 'CREATED', StepResult::SUCCESS => 'SUCCESS', StepResult::SYNCED => 'SYNCED', + StepResult::DELETED => 'DELETED', // yellow StepResult::SKIPPED => 'SKIPPED', StepResult::CUSTOM_MANAGED => 'CUSTOM MANAGED', StepResult::WOULD_CREATE => 'WOULD CREATE', StepResult::WOULD_SYNC => 'WOULD SYNC', + StepResult::WOULD_DELETE => 'WOULD DELETE', // red StepResult::MANIFEST_INVALID => 'MANIFEST INVALID', diff --git a/src/Enums/StepResult.php b/src/Enums/StepResult.php index a1c4a08..12529e1 100644 --- a/src/Enums/StepResult.php +++ b/src/Enums/StepResult.php @@ -11,6 +11,9 @@ enum StepResult case SYNCED; case WOULD_SYNC; + case DELETED; + case WOULD_DELETE; + case CUSTOM_MANAGED; case TIMEOUT; case SKIPPED; diff --git a/src/Steps/Sync/App/SyncScalableTargetStep.php b/src/Steps/Sync/App/SyncScalableTargetStep.php index df3eebd..8aa5fcb 100644 --- a/src/Steps/Sync/App/SyncScalableTargetStep.php +++ b/src/Steps/Sync/App/SyncScalableTargetStep.php @@ -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; @@ -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 @@ -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 @@ -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.', diff --git a/src/Steps/Sync/App/SyncScalingPoliciesStep.php b/src/Steps/Sync/App/SyncScalingPoliciesStep.php index 28794b6..221ddaa 100644 --- a/src/Steps/Sync/App/SyncScalingPoliciesStep.php +++ b/src/Steps/Sync/App/SyncScalingPoliciesStep.php @@ -2,6 +2,7 @@ namespace Codinglabs\Yolo\Steps\Sync\App; +use Codinglabs\Yolo\Change; use Illuminate\Support\Arr; use Codinglabs\Yolo\Helpers; use Codinglabs\Yolo\Manifest; @@ -9,11 +10,13 @@ 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 @@ -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; } @@ -41,6 +60,7 @@ public function __invoke(array $options): StepResult $created = false; $synced = false; + $deleted = false; foreach (static::policies() as $policy) { $existed = $policy->exists(); @@ -55,10 +75,24 @@ 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; } @@ -66,6 +100,39 @@ public function __invoke(array $options): StepResult 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 + */ + 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 + */ + 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. @@ -76,7 +143,7 @@ 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), ), @@ -84,7 +151,7 @@ public static function policies(): array 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, diff --git a/tests/Unit/Steps/Sync/App/SyncScalableTargetStepTest.php b/tests/Unit/Steps/Sync/App/SyncScalableTargetStepTest.php index a6d4232..4a96691 100644 --- a/tests/Unit/Steps/Sync/App/SyncScalableTargetStepTest.php +++ b/tests/Unit/Steps/Sync/App/SyncScalableTargetStepTest.php @@ -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'); +}); diff --git a/tests/Unit/Steps/Sync/App/SyncScalingPoliciesStepTest.php b/tests/Unit/Steps/Sync/App/SyncScalingPoliciesStepTest.php index def9648..2777fc1 100644 --- a/tests/Unit/Steps/Sync/App/SyncScalingPoliciesStepTest.php +++ b/tests/Unit/Steps/Sync/App/SyncScalingPoliciesStepTest.php @@ -1,9 +1,30 @@ + */ +function matchingCpuPolicy(string $name): array +{ + return [ + 'PolicyName' => $name, + 'TargetTrackingScalingPolicyConfiguration' => [ + 'TargetValue' => 65.0, + 'PredefinedMetricSpecification' => ['PredefinedMetricType' => 'ECSServiceAverageCPUUtilization'], + 'ScaleOutCooldown' => 60, + 'ScaleInCooldown' => 300, + ], + ]; +} + beforeEach(function () { writeManifest([ 'account-id' => '111111111111', @@ -123,3 +144,84 @@ expect($requestCount['args']['TargetTrackingScalingPolicyConfiguration']['PredefinedMetricSpecification']['ResourceLabel']) ->toBe('app/yolo-testing/abc123/targetgroup/yolo-testing-my-app/def456'); }); + +it('skips entirely when autoscaling is removed from the manifest', function () { + writeManifest([ + 'account-id' => '111111111111', + 'region' => 'ap-southeast-2', + 'tasks' => ['web' => []], + ]); + + $aa = []; + bindMockApplicationAutoScalingClient([], $aa); + + expect((new SyncScalingPoliciesStep())([]))->toBe(StepResult::SKIPPED); + expect($aa)->toBeEmpty(); +}); + +it('prunes a live policy the manifest no longer wants (request-count removed)', function () { + // beforeEach manifest has the autoscaling block but no request-count → CPU is + // the only desired policy, so a live request-count policy is an orphan. + $cpu = Helpers::keyedResourceName('cpu-scaling-policy'); + $requestCount = Helpers::keyedResourceName('request-count-scaling-policy'); + + $ecs = []; + $aa = []; + bindRoutedEcsClient(['DescribeServices' => new Result(['services' => [['status' => 'ACTIVE', 'serviceArn' => 'arn']]])], $ecs); + bindMockApplicationAutoScalingClient([ + 'DescribeScalingPolicies' => new Result(['ScalingPolicies' => [matchingCpuPolicy($cpu), ['PolicyName' => $requestCount]]]), + 'DeleteScalingPolicy' => new Result([]), + ], $aa); + + expect((new SyncScalingPoliciesStep())([]))->toBe(StepResult::DELETED); + + $delete = collect($aa)->firstWhere('name', 'DeleteScalingPolicy'); + expect($delete)->not->toBeNull(); + expect($delete['args']['PolicyName'])->toBe($requestCount); +}); + +it('would-prune on a dry-run without deleting', function () { + $cpu = Helpers::keyedResourceName('cpu-scaling-policy'); + $requestCount = Helpers::keyedResourceName('request-count-scaling-policy'); + + $ecs = []; + $aa = []; + bindRoutedEcsClient(['DescribeServices' => new Result(['services' => [['status' => 'ACTIVE', 'serviceArn' => 'arn']]])], $ecs); + bindMockApplicationAutoScalingClient([ + 'DescribeScalingPolicies' => new Result(['ScalingPolicies' => [matchingCpuPolicy($cpu), ['PolicyName' => $requestCount]]]), + 'DeleteScalingPolicy' => new Result([]), + ], $aa); + + expect((new SyncScalingPoliciesStep())(['dry-run' => true]))->toBe(StepResult::WOULD_DELETE); + expect(collect($aa)->pluck('name'))->not->toContain('DeleteScalingPolicy'); +}); + +it('does not prune the request-count policy when it is only deferred (ALB/TG unresolved)', function () { + writeManifest([ + 'account-id' => '111111111111', + 'region' => 'ap-southeast-2', + 'tasks' => ['web' => ['autoscaling' => ['cpu-utilization' => 65, 'request-count-per-target' => 1000]]], + ]); + + $cpu = Helpers::keyedResourceName('cpu-scaling-policy'); + $requestCount = Helpers::keyedResourceName('request-count-scaling-policy'); + + $ecs = []; + $aa = []; + $elb = []; + bindRoutedEcsClient(['DescribeServices' => new Result(['services' => [['status' => 'ACTIVE', 'serviceArn' => 'arn']]])], $ecs); + // ALB / target group don't resolve → request-count is deferred, not removed: + // desiredPolicyNames() still wants it, so it must NOT be pruned. + bindRoutedElbV2Client([ + 'DescribeLoadBalancers' => new Result(['LoadBalancers' => []]), + 'DescribeTargetGroups' => new Result(['TargetGroups' => []]), + ], $elb); + bindMockApplicationAutoScalingClient([ + 'DescribeScalingPolicies' => new Result(['ScalingPolicies' => [matchingCpuPolicy($cpu), ['PolicyName' => $requestCount]]]), + 'DeleteScalingPolicy' => new Result([]), + ], $aa); + + (new SyncScalingPoliciesStep())([]); + + expect(collect($aa)->pluck('name'))->not->toContain('DeleteScalingPolicy'); +});