Repository: awslabs/generative-ai-cdk-constructs (ts)

Issue:
(utils): generatePhysicalName can't create shorter names
### Describe the bug

Some resources in #169 require physical names of 32 characters or less. The resulting string for a given prefix will always be the same and longer than 36 characters.

[AWS::OpenSearchServerless::Collection](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-opensearchserverless-collection.html#cfn-opensearchserverless-collection-name)
[AWS::OpenSearchServerless::AccessPolicy](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-opensearchserverless-accesspolicy.html#cfn-opensearchserverless-accesspolicy-name)
[AWS::OpenSearchServerless::SecurityPolicy](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-opensearchserverless-securitypolicy.html#cfn-opensearchserverless-securitypolicy-name)



### Expected Behavior

For `maxLength` < 36, `generatePhysicalName` will return a unique string of size `maxLength`.

### Current Behavior

Since the `stackIdGuidLength` is 36, the parts will be truncated to 0, and the resulting string will be the prefix plus the GUID. The resulting string for a given prefix will always be the same and longer than 36 characters.

### Reproduction Steps

n/a

### Possible Solution

Completely changing the algorithm will break existing stacks, so if `maxLength` is less than `prefixLength` + `stackIdGuidLength`, use an alternative algorithm.

Create a [truncated hash](https://github.com/aws/aws-cdk/blob/123112165e36b17f1d1c61d908324e1d9050d4cc/packages/aws-cdk-lib/core/lib/private/physical-name-generator.ts#L28-L34) of 8? characters. Use the existing algorithm to combine prefix, parts, and the short hash.

### Additional Information/Context

_No response_

### CDK CLI Version

n/a

### Framework Version

_No response_

### Node.js Version

n/a

### OS

n/a

### Language

Typescript

### Language Version

_No response_

### Region experiencing the issue

all

### Code modification

n/a

### Other information

_No response_

### Service quota

- [X] I have reviewed the service quotas for this construct

Pull request description:
fix(utils): generatephysicalname handles short names
Resources for #169 need physical names shorter than 32 characters with only lowercase letters, numbers, and hyphens. This change preserves existing behavior as much as possible.

When `maxLength` is greater than `prefixLength` + `stackIdGuidLength`
+ 3, the original algorithm is used. Otherwise, a new algorithm is used that requires an `IConstruct` for the stack name and node unique id.

An optional boolean parameter `lower` was added. When true, `allParts` ias also made lower case.

It throws errors if the resulting name is longer than `maxLength` or if `resource` is needed by not provided.

Fixes #195

---
By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of the project license.


Patch:
diff --git a/src/common/helpers/utils.ts b/src/common/helpers/utils.ts
index daa0f99..1508229 100644
--- a/src/common/helpers/utils.ts
+++ b/src/common/helpers/utils.ts
@@ -10,7 +10,9 @@
  *  OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions
  *  and limitations under the License.
  */
+import * as crypto from 'crypto';
 import * as cdk from 'aws-cdk-lib';
+import { IConstruct } from 'constructs';
 /**
  * The version of this package
  */
@@ -19,10 +21,14 @@ export const version = require('../../../package.json').version;
 /**
  * @internal This is an internal core function and should not be called directly by Solutions Constructs clients.
  *
- * @summary Creates a physical resource name in the style of the CDK (string+hash) - this value incorporates Stack ID,
- * so it will remain static in multiple updates of a single stack, but will be different in a separate stack instance
+ * @summary Creates a physical resource name in the style of the CDK (string+hash) - this value incorporates Stack ID or
+ * the Stack Name, so it will remain static in multiple updates of a single stack, but will be different in a separate
+ * stack instance.
+ * @param {string} prefix - the prefix to use for the name
  * @param {string[]} parts - the various string components of the name (eg - stackName, solutions construct ID, L2 construct ID)
  * @param {number} maxLength - the longest string that can be returned
+ * @param {boolean} lower - whether to return the name in lowercase or mixed case
+ * @param {IConstruct} resource - the resource that is calling this function (used to extract the stack Name and Node ID)
  * @returns {string} - a string with concatenated parts (truncated if necessary) + a hash of the full concatenated parts
  *
  */
@@ -30,6 +36,8 @@ export function generatePhysicalName(
   prefix: string,
   parts: string[],
   maxLength: number,
+  lower?: boolean,
+  resource?: IConstruct,
 ): string {
   // The result will consist of:
   //    -The prefix - unaltered
@@ -39,10 +47,26 @@ export function generatePhysicalName(
 
   const stackIdGuidLength = 36;
   const prefixLength = prefix.length;
-  const maxPartsLength = maxLength - prefixLength - 1 - stackIdGuidLength; // 1 is the hyphen
+  let maxPartsLength = maxLength - prefixLength - 1 - stackIdGuidLength; // 1 is the hyphen
 
-  // Extract the Stack ID Guid
-  const uniqueStackIdPart = cdk.Fn.select(2, cdk.Fn.split('/', `${cdk.Aws.STACK_ID}`));
+  let uniqueStackIdPart = '';
+  let uniqueStackIdPartLength = stackIdGuidLength;
+  if (maxPartsLength > 2) {
+    // Extract the Stack ID Guid
+    uniqueStackIdPart = cdk.Fn.select(2, cdk.Fn.split('/', `${cdk.Aws.STACK_ID}`));
+  } else if (resource) {
+    const stack = cdk.Stack.of(resource);
+
+    const hashLength = 8;
+    const sha256 = crypto.createHash('sha256')
+      .update(stack.stackName)
+      .update(cdk.Names.nodeUniqueId(resource.node));
+    uniqueStackIdPart = sha256.digest('hex').slice(0, hashLength);
+    uniqueStackIdPartLength = hashLength;
+    maxPartsLength = maxLength - prefixLength - 1 - hashLength; // 1 is the hyphen
+  } else {
+    throw new Error('The resource parameter is required for short names.');
+  }
 
   let allParts: string = '';
 
@@ -55,6 +79,9 @@ export function generatePhysicalName(
     allParts = allParts.substring(0, subStringLength) + allParts.substring(allParts.length - subStringLength);
   }
 
-  const finalName = prefix.toLowerCase() + allParts + '-' + uniqueStackIdPart;
-  return finalName;
+  if (prefix.length + allParts.length + uniqueStackIdPartLength + 1 /* hyphen */ > maxLength) {
+    throw new Error(`The generated name is longer than the maximum length of ${maxLength}`);
+  }
+
+  return prefix.toLowerCase() + (lower ? allParts.toLowerCase() : allParts) + '-' + uniqueStackIdPart;
 }
\ No newline at end of file


Test patch:
diff --git a/test/common/helpers/utils.test.ts b/test/common/helpers/utils.test.ts
new file mode 100644
index 0000000..52ddacd
--- /dev/null
+++ b/test/common/helpers/utils.test.ts
@@ -0,0 +1,108 @@
+/**
+ *  Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ *  Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance
+ *  with the License. A copy of the License is located at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES
+ *  OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions
+ *  and limitations under the License.
+ */
+import * as cdk from 'aws-cdk-lib';
+import { generatePhysicalName } from '../../../src/common/helpers/utils';
+
+
+describe('generatePhysicalName', () => {
+  let testResourceA: TestResource;
+  let testResourceB: TestResource;
+  let testStack: cdk.Stack;
+
+  afterAll(() => {
+    console.log('Test completed');
+  });
+
+  beforeAll(() => {
+    const app = new cdk.App();
+    testStack = new cdk.Stack(app, 'TestStack', { env: { account: '012345678912', region: 'bermuda-triangle-1' } });
+
+    testResourceA = new TestResource(testStack, 'A');
+    testResourceB = new TestResource(testResourceA, 'B');
+  });
+
+  test('short physical name', () => {
+    const nameA = generatePhysicalName(
+      'testPrefix',
+      ['Foo', 'Bar', 'Baz'],
+      32,
+      true,
+      testResourceA,
+    );
+    const nameB = generatePhysicalName(
+      'testPrefix',
+      ['Foo', 'Bar', 'Baz'],
+      32,
+      true,
+      testResourceB,
+    );
+    expect(nameA).toEqual('testprefixfoobarbaz-900740fe');
+    expect(nameB).toEqual('testprefixfoobarbaz-99f0d580');
+    expect(nameA).not.toEqual(nameB);
+    expect(nameA.length).toBeLessThanOrEqual(32);
+    expect(nameB.length).toBeLessThanOrEqual(32);
+  });
+
+  test('long physical name', () => {
+    const maxLogGroupNameLength = 255;
+    const logGroupPrefix = '/aws/vendedlogs/states/constructs/';
+    const maxGeneratedNameLength = maxLogGroupNameLength - logGroupPrefix.length;
+    const nameParts: string[] = [
+      testStack.stackName, // Name of the stack
+      'StateMachineLogRag', // Literal string for log group name portion
+    ];
+    const logGroupName = generatePhysicalName(logGroupPrefix, nameParts, maxGeneratedNameLength);
+    expect(logGroupName).toMatch(new RegExp('^/aws/vendedlogs/states/constructs/TestStackStateMachineLogRag-\\$\{Token\[TOKEN\.[0-9]+\]\}$'));
+    expect(logGroupName.length).toBeLessThanOrEqual(maxLogGroupNameLength);
+  });
+
+  test('lowercase long physical name', () => {
+    const maxLogGroupNameLength = 255;
+    const logGroupPrefix = '/aws/vendedlogs/states/constructs/';
+    const maxGeneratedNameLength = maxLogGroupNameLength - logGroupPrefix.length;
+    const nameParts: string[] = [
+      testStack.stackName, // Name of the stack
+      'StateMachineLogRag', // Literal string for log group name portion
+    ];
+    const logGroupName = generatePhysicalName(logGroupPrefix, nameParts, maxGeneratedNameLength, true);
+    expect(logGroupName).toMatch(new RegExp('^/aws/vendedlogs/states/constructs/teststackstatemachinelograg-\\$\{Token\[TOKEN\.[0-9]+\]\}$'));
+    expect(logGroupName.length).toBeLessThanOrEqual(maxLogGroupNameLength);
+  });
+
+  test('short name with no resource', () => {
+    expect(() => {
+      generatePhysicalName(
+        'test',
+        ['Foo', 'Bar', 'Baz'],
+        32,
+        true,
+      );
+    },
+    ).toThrow('The resource parameter is required for short names.');
+  });
+
+  test('name too long', () => {
+    expect(() => {
+      generatePhysicalName(
+        '/aws/vendedlogs/states/constructs/',
+        ['Foo', 'Bar', 'Baz'],
+        32,
+        true,
+        testResourceA,
+      );
+    }).toThrow('The generated name is longer than the maximum length of');
+  });
+});
+
+
+class TestResource extends cdk.Resource {}
\ No newline at end of file