Repository: aws-cloudformation/cfn-lint

Issue:
E3019 failing for code module since update
### CloudFormation Lint Version

1.4.2

### What operating system are you using?

Windows/AWS Serverless

### Describe the bug

When we lint any templates with more than 1 entry for a registered code module, it triggers E3019: Primary identifiers {} should have unique values across the resources {'resource2', 'resource1'}. We can't seem to provide the custom schema with the primaryIdentifer value set via a .cfnlintrc file either as it appears to just be ignored. If we change the templates and schemas to use a custom resource type that isn't a code module the schema is picked up and properly lints E3019.

### Expected behavior

Schemas for code modules should be supported via registry_schemas like other custom resource types

This seems to be mentioned tangentally in #1811 but in this case I already have the schema files, they just don't seem to work for anything ending with ::MODULE

### Reproduction template

```
AWSTemplateFormatVersion: 2010-09-09
Resources:
  resource1:
    Type: CORP::Resource::MODULE
    Properties:
      UniqueName: resource1
      Description: Just a thing for things
      RepositoryName: Mathematics
      TargetTemplate: path/to/differentTemplate.yaml
      TargetAccount: !Sub ${AWS::AccountId}
  resource2:
    Type: CORP::Resource::MODULE
    Properties:
      UniqueName: resource2
      Description: Pipeline to maintain IAM permissions for code pipeline in ASRS-AWS-SR
      RepositoryName: Physics
      TargetTemplate: path/to/differentTemplate.yaml
      TargetAccount: !Sub ${AWS::AccountId}
```   
   
```
{
  "typeName": "CORP::Resource::MODULE",
  "description": "A fun little resource",
  "additionalProperties": false,
  "properties": {
    "UniqueName": {
      "type" : "string"
    },
    "Description": {
      "type" : "string"
    },
    "RepositoryName": {
      "type" : "string"
    },
    "TargetTemplate": {
      "type" : "string"
    },
    "TargetAccount": {
      "type" : "string"
    }
  },
  "primaryIdentifier": [ "/properties/UniqueName" ]
}
```
Note that this is a simplified schema as the code module has parameters rather than only properties.

```
registry_schemas:
  - .\
```     


Discussion:
Thanks for reporting this there are two things the PR will address.

1. When primary identifiers are empty don't do any primary identifier validation
2. When loading a schema for a resource type from the schema manager we will validate if the resource type has been loaded by the user before normalizing the resource type

We normalize the ::module resource type to do minimal validation and not create false positives.  In your use case you are providing that parameter so we should see if we have a type available before escaping to a generic module schema.



Patch:
diff --git a/src/cfnlint/rules/resources/PrimaryIdentifiers.py b/src/cfnlint/rules/resources/PrimaryIdentifiers.py
index 4dda66b073..bf3305c447 100644
--- a/src/cfnlint/rules/resources/PrimaryIdentifiers.py
+++ b/src/cfnlint/rules/resources/PrimaryIdentifiers.py
@@ -145,6 +145,9 @@ def match(self, cfn: Template) -> RuleMatches:
                 # by the customer so if any primary identifiers are read
                 # only we have to skip evaluation
                 primary_ids = schema.schema.get("primaryIdentifier", [])
+                if not primary_ids:
+                    continue
+
                 read_only_ids = schema.schema.get("readOnlyProperties", [])
 
                 if any(id in read_only_ids for id in primary_ids):
diff --git a/src/cfnlint/schema/manager.py b/src/cfnlint/schema/manager.py
index f3ab209dbd..9f8886a82c 100644
--- a/src/cfnlint/schema/manager.py
+++ b/src/cfnlint/schema/manager.py
@@ -152,7 +152,8 @@ def get_resource_schema(self, region: str, resource_type: str) -> Schema:
         Returns:
             dict: returns the schema
         """
-        resource_type = self._normalize_resource_type(resource_type)
+        if resource_type not in self._registry_schemas:
+            resource_type = self._normalize_resource_type(resource_type)
 
         if resource_type in self._removed_types:
             raise ResourceNotFoundError(resource_type, region)
@@ -160,39 +161,40 @@ def get_resource_schema(self, region: str, resource_type: str) -> Schema:
         reg = ToPy(region)
         rt = ToPy(resource_type)
 
-        schema = self._schemas[reg.name].get(resource_type)
-        if schema is None:
-            # dynamically import the modules as needed
-            self._provider_schema_modules[reg.name] = __import__(
-                f"{self._root.module}.{reg.py}", fromlist=[""]
-            )
-            # check cfn-lint provided schemas
-            if resource_type in self._registry_schemas:
-                self._schemas[reg.name][rt.name] = self._registry_schemas[rt.name]
-                return self._schemas[reg.name][rt.name]
-
-            # load the schema
-            if f"{rt.provider}.json" in self._provider_schema_modules[reg.name].cached:
-                schema_cached = copy(
-                    self.get_resource_schema(
-                        region=self._region_primary.name,
-                        resource_type=rt.name,
-                    )
+        schema = self._schemas[reg.name].get(rt.name)
+        if schema is not None:
+            return schema
+
+        # dynamically import the modules as needed
+        self._provider_schema_modules[reg.name] = __import__(
+            f"{self._root.module}.{reg.py}", fromlist=[""]
+        )
+        # check cfn-lint provided schemas
+        if rt.name in self._registry_schemas:
+            self._schemas[reg.name][rt.name] = self._registry_schemas[rt.name]
+            return self._schemas[reg.name][rt.name]
+
+        # load the schema
+        if f"{rt.provider}.json" in self._provider_schema_modules[reg.name].cached:
+            schema_cached = copy(
+                self.get_resource_schema(
+                    region=self._region_primary.name,
+                    resource_type=rt.name,
                 )
-                schema_cached.is_cached = True
-                self._schemas[reg.name][rt.name] = schema_cached
-                return self._schemas[reg.name][rt.name]
-            try:
-                self._schemas[reg.name][rt.name] = Schema(
-                    load_resource(
-                        self._provider_schema_modules[reg.name],
-                        filename=f"{rt.provider}.json",
-                    )
+            )
+            schema_cached.is_cached = True
+            self._schemas[reg.name][rt.name] = schema_cached
+            return self._schemas[reg.name][rt.name]
+        try:
+            self._schemas[reg.name][rt.name] = Schema(
+                load_resource(
+                    self._provider_schema_modules[reg.name],
+                    filename=f"{rt.provider}.json",
                 )
-            except Exception as e:
-                raise ResourceNotFoundError(rt.name, region) from e
+            )
             return self._schemas[reg.name][rt.name]
-        return schema
+        except Exception as e:
+            raise ResourceNotFoundError(rt.name, region) from e
 
     @lru_cache(maxsize=None)
     def get_resource_types(self, region: str) -> list[str]:
diff --git a/tox.ini b/tox.ini
index 115043f5c3..24c3f00e4a 100644
--- a/tox.ini
+++ b/tox.ini
@@ -5,9 +5,7 @@ isolated_build = true
 [testenv]
 skip_install = True
 commands =
-  pip install -e .
-  pip install -e .[sarif]
-  pip install -e .[junit]
+  pip install -e .[full]
   coverage run -m pytest {posargs:test}
   coverage xml
 deps =


Test patch:
diff --git a/test/fixtures/templates/bad/resources/primary_identifiers.yaml b/test/fixtures/templates/bad/resources/primary_identifiers.yaml
index 95be3b5f2b..3aea9828ca 100644
--- a/test/fixtures/templates/bad/resources/primary_identifiers.yaml
+++ b/test/fixtures/templates/bad/resources/primary_identifiers.yaml
@@ -154,3 +154,11 @@ Resources:
         - !Ref 'AWS::NoValue'
   BadType:
     Type: !Ref AWS::Region
+  Module1:
+    Type: MyCompany::MODULE
+    Properties:
+      Attribute1: test
+  Module2:
+    Type: MyCompany::MODULE
+    Properties:
+      Attribute2: test
diff --git a/test/unit/module/schema/test_manager.py b/test/unit/module/schema/test_manager.py
index 932ee5c825..dd50be42d9 100644
--- a/test/unit/module/schema/test_manager.py
+++ b/test/unit/module/schema/test_manager.py
@@ -271,3 +271,15 @@ def test_removed_types(self):
 
         with self.assertRaises(ResourceNotFoundError):
             self.manager.get_resource_schema(region, rt)
+
+    def test_type_normalization(self):
+
+        rt = "MyCompany::MODULE"
+        schema = self.manager.get_resource_schema("us-east-1", rt)
+
+        assert schema.schema.get("typeName") == "Module"
+
+        self.manager.get_resource_schema.cache_clear()
+        self.manager._registry_schemas[rt] = True
+        schema = self.manager.get_resource_schema("us-east-1", rt)
+        assert schema is True