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'); +});