Repository: cdklabs/cdk-enterprise-iac (language: python)

Issue:
feat: transformations for ECS Service and CloudWatch Alarms
### Description

I need the capability for the Resource Extractor construct to be able to transform ECS Service and CloudWatch Alarm ARNs. In addition, I need this to work with the ECS Auto Scaling construct that is also available in this repository.

### Use Case

Using the Auto scaling construct in combination with the Resource Extractor construct.

### Proposed Solution

Add the appropriate transforms

### Other information

_No response_

### Acknowledge

- [X] I may be able to implement this feature request
- [ ] This feature might incur a breaking change


Discussion / hints:

Fix patch:
diff --git a/src/constructs/ecsIsoServiceAutoscaler/ecsIsoServiceAutoscaler.ts b/src/constructs/ecsIsoServiceAutoscaler/ecsIsoServiceAutoscaler.ts
index bb32b54..6178267 100644
--- a/src/constructs/ecsIsoServiceAutoscaler/ecsIsoServiceAutoscaler.ts
+++ b/src/constructs/ecsIsoServiceAutoscaler/ecsIsoServiceAutoscaler.ts
@@ -183,7 +183,7 @@ export class EcsIsoServiceAutoscaler extends Construct {
           effect: Effect.ALLOW,
           resources: [props.ecsService.serviceArn],
           conditions: {
-            StringEquals: {
+            StringLike: {
               'ecs:cluster': props.ecsCluster.clusterArn,
             },
           },
diff --git a/src/patches/resource-extractor/resourceTransformer.ts b/src/patches/resource-extractor/resourceTransformer.ts
index 9d487db..1565e0b 100644
--- a/src/patches/resource-extractor/resourceTransformer.ts
+++ b/src/patches/resource-extractor/resourceTransformer.ts
@@ -4,8 +4,9 @@ SPDX-License-Identifier: Apache-2.0
 */
 import { Aws } from 'aws-cdk-lib';
 import { CfnApiKey } from 'aws-cdk-lib/aws-apigateway';
+import { CfnAlarm } from 'aws-cdk-lib/aws-cloudwatch';
 import { CfnTable } from 'aws-cdk-lib/aws-dynamodb';
-import { CfnCluster, CfnTaskDefinition } from 'aws-cdk-lib/aws-ecs';
+import { CfnCluster, CfnService, CfnTaskDefinition } from 'aws-cdk-lib/aws-ecs';
 import { CfnDomain as CfnDomainEss } from 'aws-cdk-lib/aws-elasticsearch';
 import { CfnFunction } from 'aws-cdk-lib/aws-lambda';
 import { CfnLogGroup } from 'aws-cdk-lib/aws-logs';
@@ -128,6 +129,15 @@ export class ResourceTransformer {
         const preamble = this.generateArnPreamble('ecs');
         return `${preamble}:cluster/${partial}`;
       },
+      [CfnService.CFN_RESOURCE_TYPE_NAME]: (
+        stackName,
+        logicalId,
+        _resourceProperties
+      ) => {
+        const partial = `*/${stackName}-${logicalId}*`;
+        const preamble = this.generateArnPreamble('ecs');
+        return `${preamble}:service/${partial}`;
+      },
       /** Colon-resource name grouping */
       [CfnLogGroup.CFN_RESOURCE_TYPE_NAME]: (
         stackName,
@@ -156,6 +166,15 @@ export class ResourceTransformer {
         const preamble = this.generateArnPreamble('states');
         return `${preamble}:stateMachine:${partial}`;
       },
+      [CfnAlarm.CFN_RESOURCE_TYPE_NAME]: (
+        stackName,
+        logicalId,
+        _resourceProperties
+      ) => {
+        const partial = `${stackName}-${logicalId}*`;
+        const preamble = this.generateArnPreamble('cloudwatch');
+        return `${preamble}:alarm:${partial}`;
+      },
       /** No resource name grouping */
       [CfnQueue.CFN_RESOURCE_TYPE_NAME]: (
         stackName,


Test patch:
diff --git a/test/constructs/ecsIsoServiceAutoscaler/ecsIsoServiceAutoscaler.test.ts b/test/constructs/ecsIsoServiceAutoscaler/ecsIsoServiceAutoscaler.test.ts
index 5a45f52..1b98d31 100644
--- a/test/constructs/ecsIsoServiceAutoscaler/ecsIsoServiceAutoscaler.test.ts
+++ b/test/constructs/ecsIsoServiceAutoscaler/ecsIsoServiceAutoscaler.test.ts
@@ -163,7 +163,7 @@ describe('EcsIsoServiceAutoscaler construct', () => {
                 Ref: stack.getLogicalId(serviceId as CfnElement).toString(),
               },
               Condition: {
-                StringEquals: {
+                StringLike: {
                   'ecs:cluster': {
                     'Fn::GetAtt': [
                       stack.getLogicalId(clusterId as CfnElement).toString(),
diff --git a/test/patches/resource-extractor/resourceTransformer.test.ts b/test/patches/resource-extractor/resourceTransformer.test.ts
index c148375..f80ee66 100644
--- a/test/patches/resource-extractor/resourceTransformer.test.ts
+++ b/test/patches/resource-extractor/resourceTransformer.test.ts
@@ -5,17 +5,26 @@ SPDX-License-Identifier: Apache-2.0
 import { App, Aspects, Aws, CfnElement, Duration, Stack } from 'aws-cdk-lib';
 import { Match, Template } from 'aws-cdk-lib/assertions';
 import { ApiKey, CfnApiKey } from 'aws-cdk-lib/aws-apigateway';
+import { Alarm, CfnAlarm } from 'aws-cdk-lib/aws-cloudwatch';
 import { UserPool } from 'aws-cdk-lib/aws-cognito';
 import { AttributeType, CfnTable, Table } from 'aws-cdk-lib/aws-dynamodb';
 import { InstanceType, Vpc } from 'aws-cdk-lib/aws-ec2';
 import {
+  AssetImage,
   CfnCluster,
+  CfnService,
   CfnTaskDefinition,
   Cluster,
   Compatibility,
+  FargateService,
   TaskDefinition,
 } from 'aws-cdk-lib/aws-ecs';
-import { Role, ServicePrincipal } from 'aws-cdk-lib/aws-iam';
+import {
+  Effect,
+  PolicyStatement,
+  Role,
+  ServicePrincipal,
+} from 'aws-cdk-lib/aws-iam';
 import { CfnFunction, Code, Function, Runtime } from 'aws-cdk-lib/aws-lambda';
 import { LogGroup } from 'aws-cdk-lib/aws-logs';
 import {
@@ -684,6 +693,64 @@ describe('toPartial scenarios', () => {
     });
   });
 
+  test('cloudwatch Alarm', () => {
+    const role = new Role(stack, 'TestRole', {
+      assumedBy: new ServicePrincipal('lambda.amazonaws.com'),
+    });
+    const queue = new Queue(stack, 'TestQueue');
+    const alarm = new Alarm(stack, 'TestAlarm', {
+      metric: queue.metricNumberOfMessagesReceived(),
+      evaluationPeriods: 1,
+      threshold: 10,
+    });
+
+    role.addToPolicy(
+      new PolicyStatement({
+        actions: ['cloudwatch:DescribeAlarms'],
+        effect: Effect.ALLOW,
+        resources: [alarm.alarmArn],
+      })
+    );
+
+    const synthedApp = app.synth();
+    Aspects.of(app).add(
+      new ResourceExtractor({
+        extractDestinationStack: extractedStack,
+        stackArtifacts: synthedApp.stacks,
+        valueShareMethod: ResourceExtractorShareMethod.CFN_OUTPUT,
+        resourceTypesToExtract: ['AWS::IAM::Role', 'AWS::IAM::Policy'],
+      })
+    );
+    app.synth({ force: true });
+
+    const alarmNode = alarm.node.defaultChild as CfnAlarm;
+    const alarmLogicalID = stack.resolve(alarmNode.logicalId);
+    const extractedTemplate = Template.fromStack(extractedStack);
+    extractedTemplate.resourceCountIs('AWS::IAM::Role', 1);
+    extractedTemplate.hasResourceProperties('AWS::IAM::Policy', {
+      PolicyDocument: {
+        Statement: [
+          {
+            Resource: {
+              'Fn::Join': [
+                '',
+                [
+                  'arn:',
+                  { Ref: 'AWS::Partition' },
+                  ':cloudwatch:',
+                  { Ref: 'AWS::Region' },
+                  ':',
+                  { Ref: 'AWS::AccountId' },
+                  `:alarm:${appStackName}-${alarmLogicalID}*`,
+                ],
+              ],
+            },
+          },
+        ],
+      },
+    });
+  });
+
   test('opensearch CfnDomain', () => {
     const role = new Role(stack, 'testRole', {
       assumedBy: new ServicePrincipal('es.amazonaws.com'),
@@ -841,6 +908,73 @@ describe('toPartial scenarios', () => {
     });
   });
 
+  test('ecs CfnService', () => {
+    const role = new Role(stack, 'TestRole', {
+      assumedBy: new ServicePrincipal('lambda.amazonaws.com'),
+    });
+    const cluster = new Cluster(stack, 'TestCluster');
+    const taskDefinition = new TaskDefinition(stack, 'TestTaskDef', {
+      compatibility: Compatibility.FARGATE,
+      cpu: '256',
+      memoryMiB: '512',
+    });
+    taskDefinition.addContainer('TestContainer', {
+      image: AssetImage.fromRegistry('alpine'),
+    });
+
+    const service = new FargateService(stack, 'TestService', {
+      cluster,
+      taskDefinition,
+    });
+
+    role.addToPolicy(
+      new PolicyStatement({
+        actions: ['ecs:DescribeServices', 'ecs:UpdateService'],
+        effect: Effect.ALLOW,
+        resources: [service.serviceArn],
+      })
+    );
+
+    const synthedApp = app.synth();
+    Aspects.of(app).add(
+      new ResourceExtractor({
+        extractDestinationStack: extractedStack,
+        stackArtifacts: synthedApp.stacks,
+        valueShareMethod: ResourceExtractorShareMethod.CFN_OUTPUT,
+        resourceTypesToExtract: ['AWS::IAM::Role', 'AWS::IAM::Policy'],
+      })
+    );
+    app.synth({ force: true });
+
+    const serviceNode = service.node.defaultChild as CfnService;
+    const serviceLogicalID = stack.resolve(serviceNode.logicalId);
+    const extractedTemplate = Template.fromStack(extractedStack);
+
+    extractedTemplate.resourceCountIs('AWS::IAM::Role', 2);
+    extractedTemplate.hasResourceProperties('AWS::IAM::Policy', {
+      PolicyDocument: {
+        Statement: Match.arrayWith([
+          Match.objectLike({
+            Resource: {
+              'Fn::Join': [
+                '',
+                [
+                  'arn:',
+                  { Ref: 'AWS::Partition' },
+                  ':ecs:',
+                  { Ref: 'AWS::Region' },
+                  ':',
+                  { Ref: 'AWS::AccountId' },
+                  `:service/*/${appStackName}-${serviceLogicalID}*`,
+                ],
+              ],
+            },
+          }),
+        ]),
+      },
+    });
+  });
+
   test('additional transforms', () => {
     const vpc = new Vpc(stack, 'TestVpc');
     const db = new DatabaseInstance(stack, 'TestDb', {