Repository: aws-cloudformation/rain
Pull request #556 (2024-10-16 17:55:22)

Convert web app sample to Pkl


Patch:
diff --git a/cft/format/pkl.go b/cft/format/pkl.go
index af4ef63c..565e3ffb 100644
--- a/cft/format/pkl.go
+++ b/cft/format/pkl.go
@@ -14,6 +14,33 @@ import (
 	"gopkg.in/yaml.v3"
 )
 
+const (
+	SUB       = "Fn::Sub"
+	REF       = "Ref"
+	GETATT    = "Fn::GetAtt"
+	EQUALS    = "Fn::Equals"
+	CONTAINS  = "Fn::Contains"
+	FINDINMAP = "Fn::FindInMap"
+	GETAZS    = "Fn::GetAZs"
+	SELECT    = "Fn::Select"
+	SPLIT     = "Fn::Split"
+)
+
+func isIntrinsic(name string) bool {
+	intrinsics := []string{
+		SUB,
+		REF,
+		GETATT,
+		EQUALS,
+		CONTAINS,
+		FINDINMAP,
+		GETAZS,
+		SELECT,
+		SPLIT,
+	}
+	return slices.Contains(intrinsics, name)
+}
+
 func getClassName(resource *yaml.Node) string {
 	typeName := getTypeName(resource)
 	tokens := strings.Split(typeName, "::")
@@ -115,8 +142,12 @@ func writeResource(sb *strings.Builder, name string, resource *yaml.Node, basic
 			}
 
 		} else {
+			if attrName == "Type" && !basic {
+				// Don't bother outputting type, since it's in the module
+				continue
+			}
 			// Basic rendering, without using module classes
-			w(sb, "    %s%s", indent, attrName)
+			w(sb, "    %s[\"%s\"]", indent, attrName)
 			switch attr.Kind {
 			case yaml.ScalarNode:
 				sb.WriteString(" = ")
@@ -234,50 +265,26 @@ func w(sb *strings.Builder, f string, args ...any) {
 // writeSequence writes a generic sequence
 func writeSequence(sb *strings.Builder, n *yaml.Node, indent string, basic bool) error {
 	for i := 0; i < len(n.Content); i++ {
-		switch n.Content[i].Kind {
+		item := n.Content[i]
+		switch item.Kind {
 		case yaml.ScalarNode:
-			writeNode(sb, n.Content[i], indent, basic)
+			writeNode(sb, item, indent, basic)
 		case yaml.MappingNode:
-			if !basic && isIntrinsic(n.Content[i].Content[0].Value) {
+			if !basic && isIntrinsic(item.Content[0].Value) {
 				w(sb, indent)
-				writeNode(sb, n.Content[i], indent+"        ", basic)
+				writeNode(sb, item, indent+"        ", basic)
 			} else {
 				sb.WriteString(indent + " new {\n")
-				writeNode(sb, n.Content[i], indent+"    ", basic)
+				writeNode(sb, item, indent+"    ", basic)
 				sb.WriteString(indent + "}\n")
 			}
 		case yaml.SequenceNode:
-			writeNode(sb, n.Content[i], indent, basic)
+			writeNode(sb, item, indent, basic)
 		}
 	}
 	return nil
 }
 
-const (
-	SUB       = "Fn::Sub"
-	REF       = "Ref"
-	GETATT    = "Fn::GetAtt"
-	EQUALS    = "Fn::Equals"
-	CONTAINS  = "Fn::Contains"
-	FINDINMAP = "Fn::FindInMap"
-	GETAZS    = "Fn::GetAZs"
-	SELECT    = "Fn::Select"
-)
-
-func isIntrinsic(name string) bool {
-	intrinsics := []string{
-		SUB,
-		REF,
-		GETATT,
-		EQUALS,
-		CONTAINS,
-		FINDINMAP,
-		GETAZS,
-		SELECT,
-	}
-	return slices.Contains(intrinsics, name)
-}
-
 // writeMap writes out a generic Mapping
 func writeMap(sb *strings.Builder, n *yaml.Node, indent string, basic bool) error {
 	if n.Kind != yaml.MappingNode {
@@ -293,6 +300,9 @@ func writeMap(sb *strings.Builder, n *yaml.Node, indent string, basic bool) erro
 		if strings.Contains(name, ":") || strings.Contains(name, "/") {
 			hasSpecialChars = true
 		}
+		if isIntrinsic(name) {
+			hasSpecialChars = false
+		}
 		if basic || hasSpecialChars {
 			w(sb, "%s[\"%s\"]", indent, name)
 			if val.Kind == yaml.ScalarNode {
@@ -324,15 +334,20 @@ func writeMap(sb *strings.Builder, n *yaml.Node, indent string, basic bool) erro
 			case GETAZS:
 				w(sb, "cfn.GetAZs(\"%s\")\n", val.Value)
 			case SELECT:
-				w(sb, "cfn.Select(\"%s\", \"%s\")\n",
-					val.Content[0].Value, val.Content[1].Value)
+				w(sb, "cfn.Select(\"%s\", ", val.Content[0].Value)
+				writeNode(sb, val.Content[1], indent, basic)
+				w(sb, "%s)\n", indent)
+			case SPLIT:
+				w(sb, "cfn.Split(\"%s\", ", val.Content[0].Value)
+				writeNode(sb, val.Content[1], indent, basic)
+				w(sb, "%s)\n", indent)
 			default:
 				w(sb, "%s%s", indent, name)
 				if val.Kind == yaml.ScalarNode {
 					sb.WriteString(" = ")
 					writeNode(sb, val, "", basic)
 				} else {
-					if isIntrinsic(val.Content[0].Value) {
+					if len(val.Content) > 0 && isIntrinsic(val.Content[0].Value) {
 						w(sb, " = ")
 						writeNode(sb, val, indent+"        ", basic)
 					} else {
diff --git a/internal/cmd/fmt/fmt.go b/internal/cmd/fmt/fmt.go
index d6f48864..5c736c23 100644
--- a/internal/cmd/fmt/fmt.go
+++ b/internal/cmd/fmt/fmt.go
@@ -47,6 +47,8 @@ func formatString(input string, res *result) {
 		return
 	}
 
+	config.Debugf("%s", node.ToSJson(source.Node))
+
 	if dataModel {
 		res.output = node.ToJson(source.Node)
 	} else if pklFlag {
@@ -91,16 +93,7 @@ func formatReader(name string, r io.Reader) result {
 
 func formatFile(filename string) result {
 
-	// TODO: Read pkl files
-	//
-	// Had to remove this since pkl doesn' support all of rain's platforms
-	// We would need to shell out to pkl instead (which is what pkl-go does)
-	//
-	// Fixed? https://github.com/apple/pkl-go/pull/32
-	//
 	if strings.HasSuffix(filename, ".pkl") {
-		// Don't need this?
-		//cfg, err := template.LoadFromPath(context.Background(), filename)
 
 		res := result{
 			name: filename,


Test patch:
diff --git a/cft/format/format_test.go b/cft/format/format_test.go
index d02bfe73..9b23f391 100644
--- a/cft/format/format_test.go
+++ b/cft/format/format_test.go
@@ -786,7 +786,6 @@ import "@cfn/aws/s3/bucket.pkl"
 
 Resources {
     ["Bucket"] = new bucket.Bucket {
-        Type = "AWS::S3::Bucket"
     }
 
 }
diff --git a/cft/format/pkl_test.go b/cft/format/pkl_test.go
index aff0f3bd..537c3b3c 100644
--- a/cft/format/pkl_test.go
+++ b/cft/format/pkl_test.go
@@ -38,7 +38,6 @@ Parameters {
 
 Resources {
     ["MyBucket"] = new bucket.Bucket {
-        Type = "AWS::S3::Bucket"
         BucketName = cfn.Ref("Name")
 
     }
@@ -93,8 +92,8 @@ Parameters {
 
 Resources {
     ["MyBucket"] {
-        Type = "AWS::S3::Bucket"
-        Properties {
+        ["Type"] = "AWS::S3::Bucket"
+        ["Properties"] {
             ["BucketName"] {
                 ["Ref"] = "Name"
             }
diff --git a/test/modules/static-site.yaml b/test/modules/static-site.yaml
index a0a157ef..be884002 100644
--- a/test/modules/static-site.yaml
+++ b/test/modules/static-site.yaml
@@ -93,6 +93,7 @@ Resources:
     Type: !Rain::Module "bucket.yaml"
     Properties:
       AppName: !Sub ${AppName}-content
+      EmptyOnDelete: true
     Overrides:
       BucketAccessPolicy:
         Properties:
@@ -133,6 +134,7 @@ Resources:
     Type: !Rain::Module "bucket.yaml"
     Properties:
       AppName: !Sub ${AppName}-cflogs
+      EmptyOnDelete: true
     Overrides:
       Bucket:
         Properties:
diff --git a/test/pkl/PklProject b/test/pkl/PklProject
index 244e123e..301b143e 100644
--- a/test/pkl/PklProject
+++ b/test/pkl/PklProject
@@ -2,7 +2,7 @@ amends "pkl:Project"
 
 dependencies {
     ["cfn"] {
-        uri = "package://github.com/aws-cloudformation/cloudformation-pkl/releases/download/cloudformation@0.1.2/cloudformation@0.1.2"
+        uri = "package://github.com/aws-cloudformation/cloudformation-pkl/releases/download/cloudformation@0.1.4/cloudformation@0.1.4"
     }
 }
 
diff --git a/test/pkl/PklProject.deps.json b/test/pkl/PklProject.deps.json
index f5ddeba8..d1150356 100644
--- a/test/pkl/PklProject.deps.json
+++ b/test/pkl/PklProject.deps.json
@@ -1,11 +1,11 @@
 {
   "schemaVersion": 1,
   "resolvedDependencies": {
-    "package://github.com/aws-cloudformation/cloudformation-pkl/releases/download/cloudformation@0.1.2/cloudformation@0": {
+    "package://github.com/aws-cloudformation/cloudformation-pkl/releases/download/cloudformation@0.1.4/cloudformation@0": {
       "type": "remote",
-      "uri": "projectpackage://github.com/aws-cloudformation/cloudformation-pkl/releases/download/cloudformation@0.1.2/cloudformation@0.1.2",
+      "uri": "projectpackage://github.com/aws-cloudformation/cloudformation-pkl/releases/download/cloudformation@0.1.4/cloudformation@0.1.4",
       "checksums": {
-        "sha256": "3a897ebb1ad7a1f6116d8972cf8f249bd25dbb7c4f5dfb3874e236da5e5186b0"
+        "sha256": "d235477833d6b941ad255f5a94e061c7b987742ab1e703abaaf6d1980694f3b5"
       }
     }
   }
diff --git a/test/pkl/bucket.pkl b/test/pkl/bucket.pkl
new file mode 100644
index 00000000..721c3351
--- /dev/null
+++ b/test/pkl/bucket.pkl
@@ -0,0 +1,26 @@
+amends "@cfn/template.pkl"
+import "@cfn/cloudformation.pkl" as cfn
+import "@cfn/aws/s3/bucket.pkl"
+
+Parameters {
+    ["AppName"] = new cfn.Parameter {
+        Type = "String"
+    }
+}
+
+local a = "a"
+
+Resources {
+    [a + "Bucket"] = new bucket.Bucket {
+        BucketName = cfn.Sub("${AppName}-content")
+    }
+    ["Bucket2"]  = new cfn.Resource {
+        Type = "AWS::S3::Bucket"
+        Properties  {
+            ["BucketName"]  {
+                ["Sub"] = "${AppName}-content2"
+            }
+        }
+    }
+}
+
diff --git a/test/pkl/directive-basic.pkl b/test/pkl/directive-basic.pkl
new file mode 100644
index 00000000..add9592f
--- /dev/null
+++ b/test/pkl/directive-basic.pkl
@@ -0,0 +1,18 @@
+amends "@cfn/template.pkl"
+import "@cfn/cloudformation.pkl" as cfn
+
+Resources {
+    ["Func"] {
+        ["Type"] = "AWS::Lambda::Function"
+        ["Properties"] {
+            ["Code"] {
+                ["Rain::S3"] {
+                    ["Path"] = "."
+                    ["KeyProperty"] = "S3Key"
+                    ["BucketProperty"] = "S3Bucket"
+                }
+            }
+        }
+    }
+
+}
diff --git a/test/pkl/directive.json b/test/pkl/directive.json
new file mode 100644
index 00000000..43b87b4e
--- /dev/null
+++ b/test/pkl/directive.json
@@ -0,0 +1,16 @@
+{
+    "Resources": {
+        "Func": {
+            "Type": "AWS::Lambda::Function",
+            "Properties": {
+                "Code": {
+                    "Rain::S3": {
+                        "Path": ".",
+                        "KeyProperty": "S3Key",
+                        "BucketProperty": "S3Bucket"
+                    }
+                }
+            }
+        }
+    }
+}
diff --git a/test/pkl/directive.pkl b/test/pkl/directive.pkl
new file mode 100644
index 00000000..ab7917dc
--- /dev/null
+++ b/test/pkl/directive.pkl
@@ -0,0 +1,24 @@
+amends "@cfn/template.pkl"
+import "@cfn/cloudformation.pkl" as cfn
+import "./function.pkl" as func
+
+Parameters {
+    ["RoleArn"] {
+        Type = "String"
+    }
+}
+
+Resources {
+    ["Func"] = new func.Function { 
+        Role = cfn.Ref("RoleArn")
+        Runtime = "nodejs20x"
+        Code {
+            Rain__S3 {
+                Path = "."
+                KeyProperty = "S3Key"
+                BucketProperty = "S3Bucket"
+            }
+        }
+    }
+
+}
diff --git a/test/pkl/directive.yaml b/test/pkl/directive.yaml
new file mode 100644
index 00000000..6767d215
--- /dev/null
+++ b/test/pkl/directive.yaml
@@ -0,0 +1,9 @@
+Resources:
+  Func:
+    Type: AWS::Lambda::Function
+    Properties:
+      Code: !Rain::S3
+        Path: "."
+        KeyProperty: "S3Key"
+        BucketProperty: "S3Bucket"
+
diff --git a/test/pkl/function.pkl b/test/pkl/function.pkl
new file mode 100644
index 00000000..5b6dc1ed
--- /dev/null
+++ b/test/pkl/function.pkl
@@ -0,0 +1,247 @@
+/// AWS::Lambda::Function
+///
+/// Generated by rain build --pkl-class AWS::Lambda::Function
+module aws.lambda.function_
+
+import "@cfn/cloudformation.pkl"
+
+/// The [deployment package](https://docs.aws.amazon.com/lambda/latest/dg/gettingstarted-package.html) for a Lambda function. To deploy a function defined as a container image, you specify the location of a container image in the Amazon ECR registry. For a .zip file deployment package, you can specify the location of an object in Amazon S3. For Node.js and Python functions, you can specify the function code inline in the template.
+///
+///  Changes to a deployment package in Amazon S3 or a container image in ECR are not detected automatically during stack updates. To update the function code, change the object key or version in the template.
+open class FunctionCode {
+    ImageUri: (String|Mapping)?
+    S3Bucket: (String(matches(Regex(#"^[0-9A-Za-z\.\-_]*(?<!\.)$"#)))|Mapping)?
+    S3Key: (String|Mapping)?
+    S3ObjectVersion: (String|Mapping)?
+    SourceKMSKeyArn: (String(matches(Regex(#"^(arn:(aws[a-zA-Z-]*)?:[a-z0-9-.]+:.*)|()$"#)))|Mapping)?
+    ZipFile: (String|Mapping)?
+    Rain__S3: Dynamic?
+}
+
+/// The [dead-letter queue](https://docs.aws.amazon.com/lambda/latest/dg/invocation-async.html#dlq) for failed asynchronous invocations.
+open class FunctionDeadLetterConfig {
+    TargetArn: (String(matches(Regex(#"^(arn:(aws[a-zA-Z-]*)?:[a-z0-9-.]+:.*)|()$"#)))|Mapping)?
+}
+
+typealias FunctionEnvironmentVariables = Mapping<String, Any>
+
+/// A function's environment variable settings. You can use environment variables to adjust your function's behavior without updating code. An environment variable is a pair of strings that are stored in a function's version-specific configuration.
+open class FunctionEnvironment {
+    Variables: (FunctionEnvironmentVariables)?
+}
+
+/// The size of the function's ``/tmp`` directory in MB. The default value is 512, but it can be any whole number between 512 and 10,240 MB.
+open class FunctionEphemeralStorage {
+    Size: Int|Mapping
+}
+
+/// Details about the connection between a Lambda function and an [Amazon EFS file system](https://docs.aws.amazon.com/lambda/latest/dg/configuration-filesystem.html).
+open class FunctionFileSystemConfig {
+    Arn: String(matches(Regex(#"^arn:aws[a-zA-Z-]*:elasticfilesystem:[a-z]{2}((-gov)|(-iso(b?)))?-[a-z]+-\d{1}:\d{12}:access-point/fsap-[a-f0-9]{17}$"#)))|Mapping
+    LocalMountPath: String(matches(Regex(#"^/mnt/[a-zA-Z0-9-_.]+$"#)))|Mapping
+}
+
+/// Configuration values that override the container image Dockerfile settings. For more information, see [Container image settings](https://docs.aws.amazon.com/lambda/latest/dg/images-create.html#images-parms).
+open class FunctionImageConfig {
+    Command: (Listing<String|Mapping>)?
+    EntryPoint: (Listing<String|Mapping>)?
+    WorkingDirectory: (String|Mapping)?
+}
+
+typealias LoggingConfigApplicationLogLevel = "TRACE"|"DEBUG"|"INFO"|"WARN"|"ERROR"|"FATAL"
+
+typealias LoggingConfigLogFormat = "Text"|"JSON"
+
+typealias LoggingConfigSystemLogLevel = "DEBUG"|"INFO"|"WARN"
+
+/// The function's Amazon CloudWatch Logs configuration settings.
+open class FunctionLoggingConfig {
+    ApplicationLogLevel: (LoggingConfigApplicationLogLevel|Mapping)?
+    LogFormat: (LoggingConfigLogFormat|Mapping)?
+    LogGroup: (String(matches(Regex(#"[\.\-_/#A-Za-z0-9]+"#)))|Mapping)?
+    SystemLogLevel: (LoggingConfigSystemLogLevel|Mapping)?
+}
+
+typealias FunctionRecursiveLoop = String|Mapping
+
+
+typealias RuntimeManagementConfigUpdateRuntimeOn = "Auto"|"FunctionUpdate"|"Manual"
+
+/// Sets the runtime management configuration for a function's version. For more information, see [Runtime updates](https://docs.aws.amazon.com/lambda/latest/dg/runtimes-update.html).
+open class FunctionRuntimeManagementConfig {
+    RuntimeVersionArn: (String|Mapping)?
+    UpdateRuntimeOn: RuntimeManagementConfigUpdateRuntimeOn|Mapping
+}
+
+typealias SnapStartApplyOn = "PublishedVersions"|"None"
+
+/// The function's [SnapStart](https://docs.aws.amazon.com/lambda/latest/dg/snapstart.html) setting.
+open class FunctionSnapStart {
+    ApplyOn: SnapStartApplyOn|Mapping
+}
+
+typealias SnapStartResponseApplyOn = "PublishedVersions"|"None"
+
+typealias SnapStartResponseOptimizationStatus = "On"|"Off"
+
+/// The function's [SnapStart](https://docs.aws.amazon.com/lambda/latest/dg/snapstart.html) setting.
+open class FunctionSnapStartResponse {
+    ApplyOn: (SnapStartResponseApplyOn|Mapping)?
+    OptimizationStatus: (SnapStartResponseOptimizationStatus|Mapping)?
+}
+
+/// 
+open class FunctionTag {
+    Key: String|Mapping
+    Value: (String|Mapping)?
+}
+
+typealias TracingConfigMode = "Active"|"PassThrough"
+
+/// The function's [](https://docs.aws.amazon.com/lambda/latest/dg/services-xray.html) tracing configuration. To sample and record incoming requests, set ``Mode`` to ``Active``.
+open class FunctionTracingConfig {
+    Mode: (TracingConfigMode|Mapping)?
+}
+
+/// The VPC security groups and subnets that are attached to a Lambda function. When you connect a function to a VPC, Lambda creates an elastic network interface for each combination of security group and subnet in the function's VPC configuration. The function can only access resources and the internet through that VPC. For more information, see [VPC Settings](https://docs.aws.amazon.com/lambda/latest/dg/configuration-vpc.html).
+///
+///   When you delete a function, CFN monitors the state of its network interfaces and waits for Lambda to delete them before proceeding. If the VPC is defined in the same stack, the network interfaces need to be deleted by Lambda before CFN can delete the VPC's resources.
+///  To monitor network interfaces, CFN needs the ``ec2:DescribeNetworkInterfaces`` permission. It obtains this from the user or role that modifies the stack. If you don't provide this permission, CFN does not wait for network interfaces to be deleted.
+open class FunctionVpcConfig {
+    Ipv6AllowedForDualStack: (Boolean|Mapping)?
+    SecurityGroupIds: (Listing<String|Mapping>)?
+    SubnetIds: (Listing<String|Mapping>)?
+}
+
+/// The ``AWS::Lambda::Function`` resource creates a Lambda function. To create a function, you need a [deployment package](https://docs.aws.amazon.com/lambda/latest/dg/gettingstarted-package.html) and an [execution role](https://docs.aws.amazon.com/lambda/latest/dg/lambda-intro-execution-role.html). The deployment package is a .zip file archive or container image that contains your function code. The execution role grants the function permission to use AWS services, such as Amazon CloudWatch Logs for log streaming and AWS X-Ray for request tracing.
+///
+///  You set the package type to ``Image`` if the deployment package is a [container image](https://docs.aws.amazon.com/lambda/latest/dg/lambda-images.html). For a container image, the code property must include the URI of a container image in the Amazon ECR registry. You do not need to specify the handler and runtime properties. 
+///  You set the package type to ``Zip`` if the deployment package is a [.zip file archive](https://docs.aws.amazon.com/lambda/latest/dg/gettingstarted-package.html#gettingstarted-package-zip). For a .zip file archive, the code property specifies the location of the .zip file. You must also specify the handler and runtime properties. For a Python example, see [Deploy Python Lambda functions with .zip file archives](https://docs.aws.amazon.com/lambda/latest/dg/python-package.html).
+///  You can use [code signing](https://docs.aws.amazon.com/lambda/latest/dg/configuration-codesigning.html) if your deployment package is a .zip file archive. To enable code signing for this function, specify the ARN of a code-signing configuration. When a user attempts to deploy a code package with ``UpdateFunctionCode``, Lambda checks that the code package has a valid signature from a trusted publisher. The code-signing configuration includes a set of signing profiles, which define the trusted publishers for this function.
+///  Note that you configure [provisioned concurrency](https://docs.aws.amazon.com/lambda/latest/dg/provisioned-concurrency.html) on a ``AWS::Lambda::Version`` or a ``AWS::Lambda::Alias``.
+///  For a complete introduction to Lambda functions, see [What is Lambda?](https://docs.aws.amazon.com/lambda/latest/dg/lambda-welcome.html) in the *Lambda developer guide.*
+open class Function extends cloudformation.Resource {
+
+    Type = "AWS::Lambda::Function"
+
+
+    /// The instruction set architecture that the function supports. Enter a string array with one of the valid values (arm64 or x86_64). The default value is ``x86_64``.
+    hidden Architectures: (Listing<FunctionArchitectures|Mapping>)?
+
+    /// The code for the function.
+    hidden Code: FunctionCode
+
+    /// To enable code signing for this function, specify the ARN of a code-signing configuration. A code-signing configuration includes a set of signing profiles, which define the trusted publishers for this function.
+    hidden CodeSigningConfigArn: (String(matches(Regex(#"arn:(aws[a-zA-Z-]*)?:lambda:[a-z]{2}((-gov)|(-iso(b?)))?-[a-z]+-\d{1}:\d{12}:code-signing-config:csc-[a-z0-9]{17}"#)))|Mapping)?
+
+    /// A dead-letter queue configuration that specifies the queue or topic where Lambda sends asynchronous events when they fail processing. For more information, see [Dead-letter queues](https://docs.aws.amazon.com/lambda/latest/dg/invocation-async.html#invocation-dlq).
+    hidden DeadLetterConfig: (FunctionDeadLetterConfig)?
+
+    /// A description of the function.
+    hidden Description: (String|Mapping)?
+
+    /// Environment variables that are accessible from function code during execution.
+    hidden Environment: (FunctionEnvironment)?
+
+    /// The size of the function's ``/tmp`` directory in MB. The default value is 512, but it can be any whole number between 512 and 10,240 MB.
+    hidden EphemeralStorage: (FunctionEphemeralStorage)?
+
+    /// Connection settings for an Amazon EFS file system. To connect a function to a file system, a mount target must be available in every Availability Zone that your function connects to. If your template contains an [AWS::EFS::MountTarget](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-efs-mounttarget.html) resource, you must also specify a ``DependsOn`` attribute to ensure that the mount target is created or updated before the function.
+    ///
+    ///  For more information about using the ``DependsOn`` attribute, see [DependsOn Attribute](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-attribute-dependson.html).
+    hidden FileSystemConfigs: (Listing<FunctionFileSystemConfig>)?
+
+    /// The name of the Lambda function, up to 64 characters in length. If you don't specify a name, CFN generates one.
+    ///
+    ///  If you specify a name, you cannot perform updates that require replacement of this resource. You can perform updates that require no or some interruption. If you must replace the resource, specify a new name.
+    hidden FunctionName: (String|Mapping)?
+
+    /// The name of the method within your code that Lambda calls to run your function. Handler is required if the deployment package is a .zip file archive. The format includes the file name. It can also include namespaces and other qualifiers, depending on the runtime. For more information, see [Lambda programming model](https://docs.aws.amazon.com/lambda/latest/dg/foundation-progmodel.html).
+    hidden Handler: (String(matches(Regex(#"^[^\s]+$"#)))|Mapping)?
+
+    /// Configuration values that override the container image Dockerfile settings. For more information, see [Container image settings](https://docs.aws.amazon.com/lambda/latest/dg/images-create.html#images-parms).
+    hidden ImageConfig: (FunctionImageConfig)?
+
+    /// The ARN of the KMSlong (KMS) customer managed key that's used to encrypt your function's [environment variables](https://docs.aws.amazon.com/lambda/latest/dg/configuration-envvars.html#configuration-envvars-encryption). When [Lambda SnapStart](https://docs.aws.amazon.com/lambda/latest/dg/snapstart-security.html) is activated, Lambda also uses this key is to encrypt your function's snapshot. If you deploy your function using a container image, Lambda also uses this key to encrypt your function when it's deployed. Note that this is not the same key that's used to protect your container image in the Amazon Elastic Container Registry (Amazon ECR). If you don't provide a customer managed key, Lambda uses a default service key.
+    hidden KmsKeyArn: (String(matches(Regex(#"^(arn:(aws[a-zA-Z-]*)?:[a-z0-9-.]+:.*)|()$"#)))|Mapping)?
+
+    /// A list of [function layers](https://docs.aws.amazon.com/lambda/latest/dg/configuration-layers.html) to add to the function's execution environment. Specify each layer by its ARN, including the version.
+    hidden Layers: (Listing<String|Mapping>)?
+
+    /// The function's Amazon CloudWatch Logs configuration settings.
+    hidden LoggingConfig: (FunctionLoggingConfig)?
+
+    /// The amount of [memory available to the function](https://docs.aws.amazon.com/lambda/latest/dg/configuration-function-common.html#configuration-memory-console) at runtime. Increasing the function memory also increases its CPU allocation. The default value is 128 MB. The value can be any multiple of 1 MB. Note that new AWS accounts have reduced concurrency and memory quotas. AWS raises these quotas automatically based on your usage. You can also request a quota increase.
+    hidden MemorySize: (Int|Mapping)?
+
+    /// The type of deployment package. Set to ``Image`` for container image and set ``Zip`` for .zip file archive.
+    hidden PackageType: (FunctionPackageType|Mapping)?
+
+    /// 
+    hidden RecursiveLoop: (FunctionRecursiveLoop)?
+
+    /// The number of simultaneous executions to reserve for the function.
+    hidden ReservedConcurrentExecutions: (Int|Mapping)?
+
+    /// The Amazon Resource Name (ARN) of the function's execution role.
+    hidden Role: String(matches(Regex(#"^arn:(aws[a-zA-Z-]*)?:iam::\d{12}:role/?[a-zA-Z_0-9+=,.@\-_/]+$"#)))|Mapping
+
+    /// The identifier of the function's [runtime](https://docs.aws.amazon.com/lambda/latest/dg/lambda-runtimes.html). Runtime is required if the deployment package is a .zip file archive. Specifying a runtime results in an error if you're deploying a function using a container image.
+    ///
+    ///  The following list includes deprecated runtimes. Lambda blocks creating new functions and updating existing functions shortly after each runtime is deprecated. For more information, see [Runtime use after deprecation](https://docs.aws.amazon.com/lambda/latest/dg/lambda-runtimes.html#runtime-deprecation-levels).
+    ///  For a list of all currently supported runtimes, see [Supported runtimes](https://docs.aws.amazon.com/lambda/latest/dg/lambda-runtimes.html#runtimes-supported).
+    hidden Runtime: (String|Mapping)?
+
+    /// Sets the runtime management configuration for a function's version. For more information, see [Runtime updates](https://docs.aws.amazon.com/lambda/latest/dg/runtimes-update.html).
+    hidden RuntimeManagementConfig: (FunctionRuntimeManagementConfig)?
+
+    /// The function's [SnapStart](https://docs.aws.amazon.com/lambda/latest/dg/snapstart.html) setting.
+    hidden SnapStart: (FunctionSnapStart)?
+
+    /// A list of [tags](https://docs.aws.amazon.com/lambda/latest/dg/tagging.html) to apply to the function.
+    hidden Tags: (Listing<FunctionTag>)?
+
+    /// The amount of time (in seconds) that Lambda allows a function to run before stopping it. The default is 3 seconds. The maximum allowed value is 900 seconds. For more information, see [Lambda execution environment](https://docs.aws.amazon.com/lambda/latest/dg/runtimes-context.html).
+    hidden Timeout: (Int|Mapping)?
+
+    /// Set ``Mode`` to ``Active`` to sample and trace a subset of incoming requests with [X-Ray](https://docs.aws.amazon.com/lambda/latest/dg/services-xray.html).
+    hidden TracingConfig: (FunctionTracingConfig)?
+
+    /// For network connectivity to AWS resources in a VPC, specify a list of security groups and subnets in the VPC. When you connect a function to a VPC, it can access resources and the internet only through that VPC. For more information, see [Configuring a Lambda function to access resources in a VPC](https://docs.aws.amazon.com/lambda/latest/dg/configuration-vpc.html).
+    hidden VpcConfig: (FunctionVpcConfig)?
+
+    Properties {
+        ["Architectures"] = if (Architectures == null) null else Architectures
+        ["Code"] = if (Code == null) null else Code
+        ["CodeSigningConfigArn"] = if (CodeSigningConfigArn == null) null else CodeSigningConfigArn
+        ["DeadLetterConfig"] = if (DeadLetterConfig == null) null else DeadLetterConfig
+        ["Description"] = if (Description == null) null else Description
+        ["Environment"] = if (Environment == null) null else Environment
+        ["EphemeralStorage"] = if (EphemeralStorage == null) null else EphemeralStorage
+        ["FileSystemConfigs"] = if (FileSystemConfigs == null) null else FileSystemConfigs
+        ["FunctionName"] = if (FunctionName == null) null else FunctionName
+        ["Handler"] = if (Handler == null) null else Handler
+        ["ImageConfig"] = if (ImageConfig == null) null else ImageConfig
+        ["KmsKeyArn"] = if (KmsKeyArn == null) null else KmsKeyArn
+        ["Layers"] = if (Layers == null) null else Layers
+        ["LoggingConfig"] = if (LoggingConfig == null) null else LoggingConfig
+        ["MemorySize"] = if (MemorySize == null) null else MemorySize
+        ["PackageType"] = if (PackageType == null) null else PackageType
+        ["RecursiveLoop"] = if (RecursiveLoop == null) null else RecursiveLoop
+        ["ReservedConcurrentExecutions"] = if (ReservedConcurrentExecutions == null) null else ReservedConcurrentExecutions
+        ["Role"] = if (Role == null) null else Role
+        ["Runtime"] = if (Runtime == null) null else Runtime
+        ["RuntimeManagementConfig"] = if (RuntimeManagementConfig == null) null else RuntimeManagementConfig
+        ["SnapStart"] = if (SnapStart == null) null else SnapStart
+        ["Tags"] = if (Tags == null) null else Tags
+        ["Timeout"] = if (Timeout == null) null else Timeout
+        ["TracingConfig"] = if (TracingConfig == null) null else TracingConfig
+        ["VpcConfig"] = if (VpcConfig == null) null else VpcConfig
+    }
+
+}
+
+
+typealias FunctionArchitectures = "x86_64"|"arm64"
+
+typealias FunctionPackageType = "Image"|"Zip"
diff --git a/test/pkl/modules.pkl b/test/pkl/modules.pkl
new file mode 100644
index 00000000..23f911ba
--- /dev/null
+++ b/test/pkl/modules.pkl
@@ -0,0 +1,15 @@
+import "@cfn/cloudformation.pkl" as cfn
+import "./modules/bucket.pkl" as bucket
+
+b = new bucket {
+    AppName = "test"
+    Content = "."
+    EmptyOnDelete = true
+    LogicalId = "Content"
+}
+
+Resources {
+    ...b.Resources
+}
+
+
diff --git a/test/pkl/modules/PklProject b/test/pkl/modules/PklProject
new file mode 100644
index 00000000..301b143e
--- /dev/null
+++ b/test/pkl/modules/PklProject
@@ -0,0 +1,8 @@
+amends "pkl:Project"
+
+dependencies {
+    ["cfn"] {
+        uri = "package://github.com/aws-cloudformation/cloudformation-pkl/releases/download/cloudformation@0.1.4/cloudformation@0.1.4"
+    }
+}
+
diff --git a/test/pkl/modules/PklProject.deps.json b/test/pkl/modules/PklProject.deps.json
new file mode 100644
index 00000000..d1150356
--- /dev/null
+++ b/test/pkl/modules/PklProject.deps.json
@@ -0,0 +1,12 @@
+{
+  "schemaVersion": 1,
+  "resolvedDependencies": {
+    "package://github.com/aws-cloudformation/cloudformation-pkl/releases/download/cloudformation@0.1.4/cloudformation@0": {
+      "type": "remote",
+      "uri": "projectpackage://github.com/aws-cloudformation/cloudformation-pkl/releases/download/cloudformation@0.1.4/cloudformation@0.1.4",
+      "checksums": {
+        "sha256": "d235477833d6b941ad255f5a94e061c7b987742ab1e703abaaf6d1980694f3b5"
+      }
+    }
+  }
+}
\ No newline at end of file
diff --git a/test/pkl/modules/bucket-policy.pkl b/test/pkl/modules/bucket-policy.pkl
new file mode 100644
index 00000000..8c9ae4aa
--- /dev/null
+++ b/test/pkl/modules/bucket-policy.pkl
@@ -0,0 +1,55 @@
+import "@cfn/aws/s3/bucketpolicy.pkl"
+import "@cfn/cloudformation.pkl" as cfn
+
+PolicyBucketName: cfn.RefString
+LogicalId: String
+
+fixed Resources: Mapping<String, cfn.Resource> = new Mapping {
+  [LogicalId + "Policy"] = new bucketpolicy.BucketPolicy {
+    Bucket = PolicyBucketName
+
+    PolicyDocument {
+      Statement {
+        new {
+          Action = "s3:*"
+          Condition {
+            Bool {
+              ["aws:SecureTransport"] = false
+            }
+          }
+          Effect = "Deny"
+          Principal {
+            AWS = "*"
+          }
+          Resource {
+            cfn.Sub("arn:${AWS::Partition}:s3:::\(PolicyBucketName)")
+            cfn.Sub("arn:${AWS::Partition}:s3:::\(PolicyBucketName)/*")
+          }
+        }
+        new {
+          Action = "s3:PutObject"
+          Condition {
+            ArnLike {
+              ["aws:SourceArn"] {
+                cfn.Sub("arn:${AWS::Partition}:s3:::\(PolicyBucketName)")
+              }
+            }
+            StringEquals {
+              ["aws:SourceAccount"] {
+                cfn.Ref("AWS::AccountId")
+              }
+            }
+          }
+          Effect = "Allow"
+          Principal {
+            Service = "logging.s3.amazonaws.com"
+          }
+          Resource {
+            cfn.Sub("arn:${AWS::Partition}:s3:::\(PolicyBucketName)/*")
+          }
+        }
+      }
+      Version = "2012-10-17"
+    }
+  }
+}
diff --git a/test/pkl/modules/bucket.pkl b/test/pkl/modules/bucket.pkl
new file mode 100644
index 00000000..fc2eb2c4
--- /dev/null
+++ b/test/pkl/modules/bucket.pkl
@@ -0,0 +1,253 @@
+import "@cfn/cloudformation.pkl" as cfn
+import "@cfn/aws/s3/bucket.pkl"
+import "@cfn/aws/iam/rolepolicy.pkl"
+import "@cfn/aws/iam/role.pkl"
+import "./bucket-policy.pkl" as bucketpolicy
+
+Description = "This module creates an S3 bucket that will pass common compliance checks by default. It also creates an associated log bucket and replica bucket."
+
+// This string will serve as a prefix for all resource names, which have 
+// the general form of AppName-ResourceName-Region-Account. 
+AppName: String
+
+/// A local path to a directory that will be uploaded to the bucket
+Content: String
+
+/// If true, the contents of all buckets will be permanently deleted when the stack is deleted.
+EmptyOnDelete: Boolean
+
+// The base logical id to use to name resources in this module
+LogicalId: String
+
+logBucketPolicy = new bucketpolicy {
+    PolicyBucketName = cfn.Sub("\(AppName)-logs-${AWS::Region}-${AWS::AccountId}")
+    LogicalId = outer.LogicalId + "LogBucket"
+}
+
+bucketPolicy = new bucketpolicy {
+    PolicyBucketName = cfn.Sub("\(AppName)-${AWS::Region}-${AWS::AccountId}")
+    LogicalId = outer.LogicalId + "Bucket"
+}
+
+replicaBucketPolicy = new bucketpolicy {
+    PolicyBucketName = cfn.Sub("\(AppName)-replicas-${AWS::Region}-${AWS::AccountId}")
+    LogicalId = outer.LogicalId + "ReplicaBucket" 
+}
+
+fixed Resources: Mapping<String, cfn.Resource> = new Mapping {
+
+    // Add resources from the bucket-policy module
+    ...bucketPolicy.Resources
+    ...logBucketPolicy.Resources
+    ...replicaBucketPolicy.Resources
+
+    [LogicalId + "LogBucket"] = new bucket.Bucket {
+        Metadata {
+            Comment = "This bucket records access logs for the main bucket"
+            checkov {
+                    skip {
+                             new {
+                                comment = "This is the log bucket"
+                                id = "CKV_AWS_18"
+                            }
+                        }
+                }
+            guard {
+                    SuppressedRules {
+                            "S3_BUCKET_LOGGING_ENABLED"
+                            "S3_BUCKET_REPLICATION_ENABLED"
+                        }
+                }
+            Rain {
+                    Content = "RAIN_NO_CONTENT"
+                    EmptyOnDelete = cfn.Ref("EmptyOnDelete")
+                }
+        }
+        BucketEncryption {
+            ServerSideEncryptionConfiguration {
+                     new {
+                        ServerSideEncryptionByDefault {
+                                SSEAlgorithm = "AES256"
+                            }
+                    }
+                }
+        }
+        BucketName = cfn.Sub("${AppName}-logs-${AWS::Region}-${AWS::AccountId}")
+
+        ObjectLockConfiguration {
+            ObjectLockEnabled = "Enabled"
+            Rule {
+                    DefaultRetention {
+                            Mode = "COMPLIANCE"
+                            Years = 1
+                        }
+                }
+        }
+        ObjectLockEnabled = true
+        PublicAccessBlockConfiguration {
+            BlockPublicAcls = true
+            BlockPublicPolicy = true
+            IgnorePublicAcls = true
+            RestrictPublicBuckets = true
+        }
+        VersioningConfiguration {
+            Status = "Enabled"
+        }
+    }
+
+    [LogicalId + "Bucket"] = new bucket.Bucket {
+        Metadata {
+            guard {
+                    SuppressedRules {
+                            "S3_BUCKET_DEFAULT_LOCK_ENABLED"
+                        }
+                }
+            Rain {
+                    Content = cfn.Ref("Content")
+                    EmptyOnDelete = cfn.Ref("EmptyOnDelete")
+                    DistributionLogicalId = "NONE"
+                }
+        }
+        BucketEncryption {
+            ServerSideEncryptionConfiguration {
+                     new {
+                        ServerSideEncryptionByDefault {
+                                SSEAlgorithm = "AES256"
+                            }
+                    }
+                }
+        }
+        BucketName = cfn.Sub("${AppName}-${AWS::Region}-${AWS::AccountId}")
+
+        LoggingConfiguration {
+            DestinationBucketName = cfn.Ref("LogBucket")
+        }
+        ObjectLockEnabled = false
+        PublicAccessBlockConfiguration {
+            BlockPublicAcls = true
+            BlockPublicPolicy = true
+            IgnorePublicAcls = true
+            RestrictPublicBuckets = true
+        }
+        ReplicationConfiguration {
+            Role = cfn.GetAtt("ReplicationRole", "Arn")
+            Rules {
+                     new {
+                        Destination {
+                                Bucket = cfn.GetAtt("ReplicaBucket", "Arn")
+                            }
+                        Status = "Enabled"
+                    }
+                }
+        }
+        VersioningConfiguration {
+            Status = "Enabled"
+        }
+    }
+
+
+    [LogicalId + "ReplicaBucket"] = new bucket.Bucket {
+        Metadata {
+            Comment = "This bucket is used as a target for replicas from the main bucket"
+            checkov {
+                    skip {
+                             new {
+                                comment = "This is the replica bucket"
+                                id = "CKV_AWS_18"
+                            }
+                        }
+                }
+            guard {
+                    SuppressedRules {
+                            "S3_BUCKET_DEFAULT_LOCK_ENABLED"
+                            "S3_BUCKET_REPLICATION_ENABLED"
+                            "S3_BUCKET_LOGGING_ENABLED"
+                        }
+                }
+            Rain {
+                    Content = "RAIN_NO_CONTENT"
+                    EmptyOnDelete = cfn.Ref("EmptyOnDelete")
+                }
+        }
+        BucketEncryption {
+            ServerSideEncryptionConfiguration {
+                     new {
+                        ServerSideEncryptionByDefault {
+                                SSEAlgorithm = "AES256"
+                            }
+                    }
+                }
+        }
+        BucketName = cfn.Sub("${AppName}-replicas-${AWS::Region}-${AWS::AccountId}")
+
+        ObjectLockEnabled = false
+        PublicAccessBlockConfiguration {
+            BlockPublicAcls = true
+            BlockPublicPolicy = true
+            IgnorePublicAcls = true
+            RestrictPublicBuckets = true
+        }
+        VersioningConfiguration {
+            Status = "Enabled"
+        }
+    }
+
+
+    [LogicalId + "ReplicationPolicy"] = new rolepolicy.RolePolicy {
+        PolicyDocument {
+            Statement {
+                     new {
+                        Action {
+                                "s3:GetReplicationConfiguration"
+                                "s3:ListBucket"
+                            }
+                        Effect = "Allow"
+                        Resource = cfn.Sub("arn:${AWS::Partition}:s3:::${AppName}-${AWS::Region}-${AWS::AccountId}")
+                    }
+                     new {
+                        Action {
+                                "s3:GetObjectVersionForReplication"
+                                "s3:GetObjectVersionAcl"
+                                "s3:GetObjectVersionTagging"
+                            }
+                        Effect = "Allow"
+                        Resource = cfn.Sub("arn:${AWS::Partition}:s3:::${AppName}-${AWS::Region}-${AWS::AccountId}/*")
+                    }
+                     new {
+                        Action {
+                                "s3:ReplicateObject"
+                                "s3:ReplicateDelete"
+                                "s3:ReplicationTags"
+                            }
+                        Effect = "Allow"
+                        Resource = cfn.Sub("arn:${AWS::Partition}:s3:::${AppName}-replicas-${AWS::Region}-${AWS::AccountId}/*")
+                    }
+                }
+            Version = "2012-10-17"
+        }
+        PolicyName = "bucket-replication-policy"
+        RoleName = cfn.Ref("ReplicationRole")
+
+    }
+
+    [LogicalId + "ReplicationRole"] = new role.Role {
+        AssumeRolePolicyDocument {
+            Statement {
+                     new {
+                        Action {
+                                "sts:AssumeRole"
+                            }
+                        Effect = "Allow"
+                        Principal {
+                                Service {
+                                        "s3.amazonaws.com"
+                                    }
+                            }
+                    }
+                }
+            Version = "2012-10-17"
+        }
+        Path = "/"
+    }
+
+}
diff --git a/test/pkl/modules/static-site.pkl b/test/pkl/modules/static-site.pkl
new file mode 100644
index 00000000..de2143ba
--- /dev/null
+++ b/test/pkl/modules/static-site.pkl
@@ -0,0 +1,233 @@
+import "@cfn/aws/cloudfront/distribution.pkl"
+import "@cfn/aws/cloudfront/originaccesscontrol.pkl"
+import "@cfn/aws/wafv2/webacl.pkl"
+import "@cfn/cloudformation.pkl" as cfn
+import "./bucket.pkl"
+
+Description = "This module creates a static website consisting of an S3 bucket and a CloudFront distribution."
+
+AppName: String
+LogicalId: String
+
+local contentBucket = new bucket {
+  AppName = outer.AppName
+  LogicalId = outer.LogicalId + "Content"
+}
+
+local cloudfrontLogBucket = new bucket {
+  AppName = outer.AppName + "-cflogs"
+  LogicalId = outer.LogicalId + "CloudFrontLogs"
+}
+
+fixed Resources: Mapping<String, cfn.Resource> = new Mapping {
+
+  [LogicalId + "OriginAccessControl"] = new originaccesscontrol.OriginAccessControl {
+    OriginAccessControlConfig {
+      Name {
+        ["Fn::Join"] {
+          ""
+          cfn.Ref("AppName")
+          cfn.Select(2, cfn.Split("/", cfn.Ref("AWS::StackId")))
+        }
+      }
+      OriginAccessControlOriginType = "s3"
+      SigningBehavior = "always"
+      SigningProtocol = "sigv4"
+    }
+  }
+
+
+  [LogicalId + "Distribution"] = new distribution.Distribution {
+    Metadata {
+      checkov {
+        skip {
+          new {
+            id = "CKV_AWS_174"
+            comment = "Using the default cloudfront certificate with no aliases"
+          }
+        }
+      }
+      guard {
+        SuppressedRules {
+          "CLOUDFRONT_CUSTOM_SSL_CERTIFICATE"
+          "CLOUDFRONT_ORIGIN_FAILOVER_ENABLED"
+          "CLOUDFRONT_SNI_ENABLED"
+        }
+      }
+    }
+    DistributionConfig {
+      DefaultCacheBehavior {
+        CachePolicyId = "658327ea-f89d-4fab-a63d-7e88639e58f6"
+        Compress = true
+        TargetOriginId = cfn.Sub("${AppName}-origin-1")
+        ViewerProtocolPolicy = "redirect-to-https"
+      }
+      DefaultRootObject = "index.html"
+      Enabled = true
+      HttpVersion = "http2"
+      IPV6Enabled = true
+      Logging {
+        Bucket = cfn.GetAtt("CloudFrontLogsBucket", "RegionalDomainName")
+      }
+      Origins {
+        new {
+          DomainName = cfn.GetAtt("ContentBucket", "RegionalDomainName")
+          Id = cfn.Sub("${AppName}-origin-1")
+          OriginAccessControlId = cfn.GetAtt("OriginAccessControl", "Id")
+          S3OriginConfig {
+            OriginAccessIdentity = ""
+          }
+        }
+      }
+      ViewerCertificate {
+        CloudFrontDefaultCertificate = true
+      }
+      WebACLId = cfn.GetAtt("WebACL", "Arn")
+    }
+  }
+
+  [LogicalId + "WebACL"] = new webacl.WebACL {
+    Name = "WebACLWithAMR"
+    Scope = "CLOUDFRONT"
+    Description = "Web ACL with AWS Managed Rules"
+    DefaultAction {
+      Allow {
+      }
+    }
+    VisibilityConfig {
+      SampledRequestsEnabled = true
+      CloudWatchMetricsEnabled = true
+      MetricName = "MetricForWebACLWithAMR"
+    }
+    Tags {
+      new {
+        Key = "Name"
+        Value = cfn.Ref("AppName")
+      }
+    }
+    Rules {
+      new {
+        Name = "AWS-AWSManagedRulesCommonRuleSet"
+        Priority = 0
+        OverrideAction {
+          None {
+          }
+        }
+        VisibilityConfig {
+          SampledRequestsEnabled = true
+          CloudWatchMetricsEnabled = true
+          MetricName = "MetricForAMRCRS"
+        }
+        Statement {
+          ManagedRuleGroupStatement {
+            VendorName = "AWS"
+            Name = "AWSManagedRulesCommonRuleSet"
+            ExcludedRules {
+              new {
+                Name = "NoUserAgent_HEADER"
+              }
+            }
+          }
+        }
+      }
+    }
+  }
+
+  // Add resources defined by the bucket module, and override
+  // BucketAccessPolicy to add statements
+  for (logicalId, resource in contentBucket.Resources) {
+    when (logicalId == "BucketAccessPolicy") {
+      [logicalId] = (resource) {
+        ["Properties"] {
+          ["PolicyDocument"] {
+            ["Statement"] {
+              new {
+                ["Action"] = "s3:GetObject"
+                ["Effect"] = "Allow"
+                ["Resource"] {
+                  ["Fn::Sub"] = "arn:${AWS::Partition}:s3:::${AppName}-${AWS::Region}-${AWS::AccountId}/*"
+                }
+                ["Principal"] {
+                  ["Service"] = "cloudfront.amazonaws.com"
+                }
+                ["Condition"] {
+                  ["StringEquals"] {
+                    ["AWS:SourceArn"] {
+                      ["Fn::Sub"] = "arn:aws:cloudfront::${AWS::AccountId}:distribution/${Distribution.Id}"
+                    }
+                  }
+                }
+              }
+              new {
+                ["Action"] = "s3:*"
+                ["Condition"] {
+                  ["Bool"] {
+                    ["aws:SecureTransport"] = false
+                  }
+                }
+                ["Effect"] = "Deny"
+                ["Principal"] {
+                  ["AWS"] = "*"
+                }
+                ["Resource"] {
+                  new {
+                    ["Fn::Sub"] = "arn:${AWS::Partition}:s3:::${AppName}-${AWS::Region}-${AWS::AccountId}"
+                  }
+                  new {
+                    ["Fn::Sub"] = "arn:${AWS::Partition}:s3:::${AppName}-${AWS::Region}-${AWS::AccountId}/*"
+                  }
+                }
+              }
+              new {
+                ["Action"] = "s3:PutObject"
+                ["Condition"] {
+                  ["ArnLike"] {
+                    ["aws:SourceArn"] {
+                      ["Fn::Sub"] = "arn:${AWS::Partition}:s3:::${AppName}-${AWS::Region}-${AWS::AccountId}"
+                    }
+                  }
+                  ["StringEquals"] {
+                    ["aws:SourceAccount"] {
+                      ["Ref"] = "AWS::AccountId"
+                    }
+                  }
+                }
+                ["Effect"] = "Allow"
+                ["Principal"] {
+                  ["Service"] = "logging.s3.amazonaws.com"
+                }
+                ["Resource"] {
+                  new {
+                    ["Fn::Sub"] = "arn:${AWS::Partition}:s3:::${AppName}-${AWS::Region}-${AWS::AccountId}/*"
+                  }
+                }
+              }
+            }
+            ["Version"] = "2012-10-17"
+          }
+        }
+      }
+    } else {
+      [logicalId] = resource
+    }
+  }
+
+  // Add resources in the CloudFront log bucket module, overriding object ownership
+  for (logicalId, resource in cloudfrontLogBucket.Resources) {
+    when (logicalId == "CloudFrontLogBucket") {
+        ["Properties"] {
+          ["OwnershipControls"] {
+            ["Rules"] {
+              new {
+                ["ObjectOwnership"] = "BucketOwnerPreferred"
+              }
+            }
+          }
+        }
+    } else {
+        [logicalId] = resource
+    }
+  }
+
+
+}
diff --git a/test/pkl/override.pkl b/test/pkl/override.pkl
new file mode 100644
index 00000000..c4479c8c
--- /dev/null
+++ b/test/pkl/override.pkl
@@ -0,0 +1,22 @@
+open class a {
+  Metadata: Mapping
+}
+
+aa = new a {
+  Metadata {
+    ["Foo"] {
+        ["Key1"] = "bar"
+    }
+  }
+}
+
+bb = (aa) {
+  Metadata {
+    ["Foo"] {
+        ["Key1"] = "baz"
+        ["Key2"] = "baz2"
+    }
+  }
+}
+
+
diff --git a/test/webapp/PklProject b/test/webapp/PklProject
new file mode 100644
index 00000000..301b143e
--- /dev/null
+++ b/test/webapp/PklProject
@@ -0,0 +1,8 @@
+amends "pkl:Project"
+
+dependencies {
+    ["cfn"] {
+        uri = "package://github.com/aws-cloudformation/cloudformation-pkl/releases/download/cloudformation@0.1.4/cloudformation@0.1.4"
+    }
+}
+
diff --git a/test/webapp/PklProject.deps.json b/test/webapp/PklProject.deps.json
new file mode 100644
index 00000000..d1150356
--- /dev/null
+++ b/test/webapp/PklProject.deps.json
@@ -0,0 +1,12 @@
+{
+  "schemaVersion": 1,
+  "resolvedDependencies": {
+    "package://github.com/aws-cloudformation/cloudformation-pkl/releases/download/cloudformation@0.1.4/cloudformation@0": {
+      "type": "remote",
+      "uri": "projectpackage://github.com/aws-cloudformation/cloudformation-pkl/releases/download/cloudformation@0.1.4/cloudformation@0.1.4",
+      "checksums": {
+        "sha256": "d235477833d6b941ad255f5a94e061c7b987742ab1e703abaaf6d1980694f3b5"
+      }
+    }
+  }
+}
\ No newline at end of file
diff --git a/test/webapp/pkl/api-resource.pkl b/test/webapp/pkl/api-resource.pkl
new file mode 100644
index 00000000..e2d230eb
--- /dev/null
+++ b/test/webapp/pkl/api-resource.pkl
@@ -0,0 +1,139 @@
+import "@cfn/cloudformation.pkl" as cfn
+import "@cfn/aws/lambda/function.pkl" as func
+import "@cfn/aws/iam/role.pkl"
+import "@cfn/aws/apigateway/resource.pkl"
+import "@cfn/aws/lambda/permission.pkl"
+import "@cfn/aws/apigateway/method.pkl"
+
+Description = "This module contains a lambda handler and the API Gateway resource to proxy requests to the lambda. It is assumed that the lambda function can handle all HTTPMethods sent to the specified path, including the OPTIONS pre-flight request. The lambda function must also return the approppriate CORS headers with each response."
+
+// The name of the resource. This will be plugged into cfn.Sub functions
+Name: String
+
+// The name of the rest api (a Sub string)
+RestApi: String
+
+// The id of the root resource
+RootResourceId: String
+
+// The id of the deployment (a Sub string)
+RestApiDeployment: String
+
+// The name of the script to run before uploading the lambda handler to S3
+BuildScript: String
+
+// The path of the packaged lambda function created by BuildScript
+CodePath: String
+
+// The URI path name for the resource, for example, "user" or "order"
+ResourcePath: String
+
+// The Id of the APIGateway Authorizer
+AuthorizerId: String
+
+// The prefix to use for the Logical Id for all resources
+LogicalId: String
+
+// The S3 bucket that has the lambda handler code
+HandlerBucket: String
+
+// The S3 key for the lambda handler code
+HandlerKey: String
+
+local restApiLogicalId = LogicalId + "RestApi"
+local handlerLogicalId = LogicalId + "Handler"
+
+local authorizerId = cfn.Sub("\(AuthorizerId)")
+
+fixed Resources: Mapping<String, cfn.Resource> = new Mapping {
+    [handlerLogicalId] = new func.Function {
+        Handler = "bootstrap"
+        FunctionName = cfn.Sub("\(Name)-handler")
+        Runtime = "provided.al2023"
+        Code {
+            S3Key = cfn.Sub("\(HandlerKey)")
+            S3Bucket = cfn.Sub("\(HandlerBucket)")
+        }
+        Role = cfn.GetAtt(LogicalId + "HandlerRole", "Arn")
+    }
+
+    [LogicalId + "HandlerRole"] = new role.Role {
+        AssumeRolePolicyDocument {
+            Version = "2012-10-17"
+            Statement {
+                     new {
+                        Effect = "Allow"
+                        Principal {
+                                Service {
+                                        "lambda.amazonaws.com"
+                                    }
+                            }
+                        Action {
+                                "sts:AssumeRole"
+                            }
+                    }
+                }
+        }
+        ManagedPolicyArns {
+            "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
+        }
+    }
+
+    [LogicalId + "Resource"] = new resource.Resource {
+        ParentId = cfn.Sub("\(RootResourceId)")
+
+        PathPart = cfn.Sub("\(ResourcePath)")
+
+        RestApiId = cfn.Sub("\(RestApi)")
+
+    }
+
+    [LogicalId + "Permission"] = new permission.Permission {
+        Action = "lambda:InvokeFunction"
+        FunctionName = cfn.GetAtt(LogicalId + "Handler", "Arn")
+
+        Principal = "apigateway.amazonaws.com"
+        SourceArn = cfn.Sub("arn:${AWS::Partition}:execute-api:${AWS::Region}:${AWS::AccountId}:\(restApiLogicalId)/*/*/*")
+
+    }
+
+    [LogicalId + "RootPermission"] = new permission.Permission {
+        Action = "lambda:InvokeFunction"
+        FunctionName = cfn.GetAtt(LogicalId + "Handler", "Arn")
+
+        Principal = "apigateway.amazonaws.com"
+        SourceArn = cfn.Sub("arn:${AWS::Partition}:execute-api:${AWS::Region}:${AWS::AccountId}:\(restApiLogicalId)/*/*/")
+
+    }
+
+    [LogicalId + "Options"] = new method.Method {
+        HttpMethod = "OPTIONS"
+        ResourceId = cfn.Ref(LogicalId + "Resource")
+
+        RestApiId = cfn.Sub("\(RestApi)")
+
+        AuthorizationType = "NONE"
+        Integration {
+            IntegrationHttpMethod = "POST"
+            Type = "AWS_PROXY"
+            Uri = cfn.Sub("arn:${AWS::Partition}:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${\(handlerLogicalId).Arn}/invocations")
+        }
+    }
+
+    [LogicalId + "Get"] = new method.Method {
+        HttpMethod = "GET"
+        ResourceId = cfn.Ref(LogicalId + "Resource")
+
+        RestApiId = cfn.Sub("\(RestApi)")
+
+        AuthorizationType = "COGNITO_USER_POOLS"
+        AuthorizerId = authorizerId 
+
+        Integration {
+            IntegrationHttpMethod = "POST"
+            Type = "AWS_PROXY"
+            Uri = cfn.Sub("arn:${AWS::Partition}:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${\(handlerLogicalId).Arn}/invocations")
+        }
+    }
+
+}
diff --git a/test/webapp/pkl/bucket-policy.pkl b/test/webapp/pkl/bucket-policy.pkl
new file mode 100644
index 00000000..edeeedd5
--- /dev/null
+++ b/test/webapp/pkl/bucket-policy.pkl
@@ -0,0 +1,55 @@
+import "@cfn/aws/s3/bucketpolicy.pkl"
+import "@cfn/cloudformation.pkl" as cfn
+
+PolicyBucketName: String
+LogicalId: String
+
+fixed Resources: Mapping<String, cfn.Resource> = new Mapping {
+  [LogicalId + "AccessPolicy"] = new bucketpolicy.BucketPolicy {
+    Bucket = cfn.Sub("\(PolicyBucketName)")
+
+    PolicyDocument {
+      Statement {
+        new {
+          Action = "s3:*"
+          Condition {
+            Bool {
+              ["aws:SecureTransport"] = false
+            }
+          }
+          Effect = "Deny"
+          Principal {
+            AWS = "*"
+          }
+          Resource {
+            cfn.Sub("arn:${AWS::Partition}:s3:::\(PolicyBucketName)")
+            cfn.Sub("arn:${AWS::Partition}:s3:::\(PolicyBucketName)/*")
+          }
+        }
+        new {
+          Action = "s3:PutObject"
+          Condition {
+            ArnLike {
+              ["aws:SourceArn"] {
+                cfn.Sub("arn:${AWS::Partition}:s3:::\(PolicyBucketName)")
+              }
+            }
+            StringEquals {
+              ["aws:SourceAccount"] {
+                cfn.Ref("AWS::AccountId")
+              }
+            }
+          }
+          Effect = "Allow"
+          Principal {
+            Service = "logging.s3.amazonaws.com"
+          }
+          Resource {
+            cfn.Sub("arn:${AWS::Partition}:s3:::\(PolicyBucketName)/*")
+          }
+        }
+      }
+      Version = "2012-10-17"
+    }
+  }
+}
diff --git a/test/webapp/pkl/bucket.pkl b/test/webapp/pkl/bucket.pkl
new file mode 100644
index 00000000..ba91546d
--- /dev/null
+++ b/test/webapp/pkl/bucket.pkl
@@ -0,0 +1,241 @@
+import "@cfn/cloudformation.pkl" as cfn
+import "@cfn/aws/s3/bucket.pkl"
+import "@cfn/aws/iam/rolepolicy.pkl"
+import "@cfn/aws/iam/role.pkl"
+import "./bucket-policy.pkl" as bucketpolicy
+
+Description = "This module creates an S3 bucket that will pass common compliance checks by default. It also creates an associated log bucket and replica bucket."
+
+// This string will serve as a prefix for all resource names, which have 
+// the general form of AppName-ResourceName-Region-Account. 
+// The string is assumed to be a Sub string that can be plugged into cfn.Sub
+AppName: String
+
+/// A local path to a directory that will be uploaded to the bucket
+Content: String
+
+/// If true, the contents of all buckets will be permanently deleted when the stack is deleted.
+EmptyOnDelete: Boolean
+
+// The base logical id to use to name resources in this module
+LogicalId: String
+
+logBucketPolicy = new bucketpolicy {
+    PolicyBucketName = "\(AppName)-logs-${AWS::Region}-${AWS::AccountId}"
+    LogicalId = outer.LogicalId + "LogBucket"
+}
+
+bucketPolicy = new bucketpolicy {
+    PolicyBucketName = "\(AppName)-${AWS::Region}-${AWS::AccountId}"
+    LogicalId = outer.LogicalId + "Bucket"
+}
+
+replicaBucketPolicy = new bucketpolicy {
+    PolicyBucketName = "\(AppName)-replicas-${AWS::Region}-${AWS::AccountId}"
+    LogicalId = outer.LogicalId + "ReplicaBucket" 
+}
+
+fixed Resources: Mapping<String, cfn.Resource> = new Mapping {
+
+    // Add resources from the bucket-policy module
+    ...bucketPolicy.Resources
+    ...logBucketPolicy.Resources
+    ...replicaBucketPolicy.Resources
+
+    [LogicalId + "LogBucket"] = new bucket.Bucket {
+        Metadata {
+            Comment = "This bucket records access logs for the main bucket"
+            checkov {
+                    skip {
+                             new {
+                                comment = "This is the log bucket"
+                                id = "CKV_AWS_18"
+                            }
+                        }
+                }
+            guard {
+                    SuppressedRules {
+                            "S3_BUCKET_LOGGING_ENABLED"
+                            "S3_BUCKET_REPLICATION_ENABLED"
+                        }
+                }
+        }
+        BucketEncryption {
+            ServerSideEncryptionConfiguration {
+                     new {
+                        ServerSideEncryptionByDefault {
+                                SSEAlgorithm = "AES256"
+                            }
+                    }
+                }
+        }
+        BucketName = cfn.Sub("\(AppName)-logs-${AWS::Region}-${AWS::AccountId}")
+
+        ObjectLockConfiguration {
+            ObjectLockEnabled = "Enabled"
+            Rule {
+                    DefaultRetention {
+                            Mode = "COMPLIANCE"
+                            Years = 1
+                        }
+                }
+        }
+        ObjectLockEnabled = true
+        PublicAccessBlockConfiguration {
+            BlockPublicAcls = true
+            BlockPublicPolicy = true
+            IgnorePublicAcls = true
+            RestrictPublicBuckets = true
+        }
+        VersioningConfiguration {
+            Status = "Enabled"
+        }
+    }
+
+    [LogicalId + "Bucket"] = new bucket.Bucket {
+        Metadata {
+            ["guard"] {
+                    ["SuppressedRules"] {
+                            "S3_BUCKET_DEFAULT_LOCK_ENABLED"
+                        }
+                }
+        }
+        BucketEncryption {
+            ServerSideEncryptionConfiguration {
+                     new {
+                        ServerSideEncryptionByDefault {
+                                SSEAlgorithm = "AES256"
+                            }
+                    }
+                }
+        }
+        BucketName = cfn.Sub("\(AppName)-${AWS::Region}-${AWS::AccountId}")
+
+        LoggingConfiguration {
+            DestinationBucketName = cfn.Ref(LogicalId + "LogBucket")
+        }
+        ObjectLockEnabled = false
+        PublicAccessBlockConfiguration {
+            BlockPublicAcls = true
+            BlockPublicPolicy = true
+            IgnorePublicAcls = true
+            RestrictPublicBuckets = true
+        }
+        ReplicationConfiguration {
+            Role = cfn.GetAtt(LogicalId + "ReplicationRole", "Arn")
+            Rules {
+                     new {
+                        Destination {
+                                Bucket = cfn.GetAtt(LogicalId + "ReplicaBucket", "Arn")
+                            }
+                        Status = "Enabled"
+                    }
+                }
+        }
+        VersioningConfiguration {
+            Status = "Enabled"
+        }
+    }
+
+
+    [LogicalId + "ReplicaBucket"] = new bucket.Bucket {
+        Metadata {
+            Comment = "This bucket is used as a target for replicas from the main bucket"
+            checkov {
+                    skip {
+                             new {
+                                comment = "This is the replica bucket"
+                                id = "CKV_AWS_18"
+                            }
+                        }
+                }
+            guard {
+                    SuppressedRules {
+                            "S3_BUCKET_DEFAULT_LOCK_ENABLED"
+                            "S3_BUCKET_REPLICATION_ENABLED"
+                            "S3_BUCKET_LOGGING_ENABLED"
+                        }
+                }
+        }
+        BucketEncryption {
+            ServerSideEncryptionConfiguration {
+                     new {
+                        ServerSideEncryptionByDefault {
+                                SSEAlgorithm = "AES256"
+                            }
+                    }
+                }
+        }
+        BucketName = cfn.Sub("\(AppName)-replicas-${AWS::Region}-${AWS::AccountId}")
+
+        ObjectLockEnabled = false
+        PublicAccessBlockConfiguration {
+            BlockPublicAcls = true
+            BlockPublicPolicy = true
+            IgnorePublicAcls = true
+            RestrictPublicBuckets = true
+        }
+        VersioningConfiguration {
+            Status = "Enabled"
+        }
+    }
+
+
+    [LogicalId + "ReplicationPolicy"] = new rolepolicy.RolePolicy {
+        PolicyDocument {
+            Statement {
+                     new {
+                        Action {
+                                "s3:GetReplicationConfiguration"
+                                "s3:ListBucket"
+                            }
+                        Effect = "Allow"
+                        Resource = cfn.Sub("arn:${AWS::Partition}:s3:::\(AppName)-${AWS::Region}-${AWS::AccountId}")
+                    }
+                     new {
+                        Action {
+                                "s3:GetObjectVersionForReplication"
+                                "s3:GetObjectVersionAcl"
+                                "s3:GetObjectVersionTagging"
+                            }
+                        Effect = "Allow"
+                        Resource = cfn.Sub("arn:${AWS::Partition}:s3:::\(AppName)-${AWS::Region}-${AWS::AccountId}/*")
+                    }
+                     new {
+                        Action {
+                                "s3:ReplicateObject"
+                                "s3:ReplicateDelete"
+                                "s3:ReplicationTags"
+                            }
+                        Effect = "Allow"
+                        Resource = cfn.Sub("arn:${AWS::Partition}:s3:::\(AppName)-replicas-${AWS::Region}-${AWS::AccountId}/*")
+                    }
+                }
+            Version = "2012-10-17"
+        }
+        PolicyName = "bucket-replication-policy"
+        RoleName = cfn.Ref(LogicalId + "ReplicationRole")
+
+    }
+
+    [LogicalId + "ReplicationRole"] = new role.Role {
+        AssumeRolePolicyDocument {
+            Statement {
+                     new {
+                        Action {
+                                "sts:AssumeRole"
+                            }
+                        Effect = "Allow"
+                        Principal {
+                                Service {
+                                        "s3.amazonaws.com"
+                                    }
+                            }
+                    }
+                }
+            Version = "2012-10-17"
+        }
+        Path = "/"
+    }
+
+}
diff --git a/test/webapp/pkl/cognito.pkl b/test/webapp/pkl/cognito.pkl
new file mode 100644
index 00000000..4e3fad5d
--- /dev/null
+++ b/test/webapp/pkl/cognito.pkl
@@ -0,0 +1,71 @@
+import "@cfn/cloudformation.pkl" as cfn
+import "@cfn/aws/cognito/userpool.pkl"
+import "@cfn/aws/cognito/userpooldomain.pkl"
+import "@cfn/aws/cognito/userpoolclient.pkl"
+
+Description = "This module creates a simple Cognito User Pool, Domain, and App Client."
+
+// The base name for resource ids and tags. Assumed to be a Sub string.
+AppName: String
+
+LogicalId: String
+
+CallbackURL: String
+
+fixed Resources: Mapping<String, cfn.Resource> = new Mapping {
+    [LogicalId + "UserPool"] = new userpool.UserPool {
+        UserPoolName = cfn.Sub("\(AppName)")
+
+        AdminCreateUserConfig {
+            AllowAdminCreateUserOnly = true
+        }
+        AutoVerifiedAttributes {
+            "email"
+        }
+        Schema {
+             new {
+                Name = "email"
+                Required = true
+            }
+             new {
+                Name = "given_name"
+                Required = true
+            }
+             new {
+                Name = "family_name"
+                Required = true
+            }
+        }
+    }
+
+    [LogicalId + "Domain"] = new userpooldomain.UserPoolDomain {
+        Domain = cfn.Sub("\(AppName)")
+
+        UserPoolId = cfn.Ref(LogicalId + "UserPool")
+
+    }
+
+    [LogicalId + "Client"] = new userpoolclient.UserPoolClient {
+        ClientName = cfn.Sub("\(AppName)")
+
+        GenerateSecret = false
+        UserPoolId = cfn.Ref(LogicalId + "UserPool")
+
+        CallbackURLs {
+            cfn.Sub("\(CallbackURL)")
+        }
+        AllowedOAuthFlows {
+            "code"
+        }
+        AllowedOAuthFlowsUserPoolClient = true
+        AllowedOAuthScopes {
+            "phone"
+            "email"
+            "openid"
+        }
+        SupportedIdentityProviders {
+            "COGNITO"
+        }
+    }
+
+}
diff --git a/test/webapp/pkl/rest-api.pkl b/test/webapp/pkl/rest-api.pkl
new file mode 100644
index 00000000..9bc28e40
--- /dev/null
+++ b/test/webapp/pkl/rest-api.pkl
@@ -0,0 +1,39 @@
+import "@cfn/cloudformation.pkl" as cfn
+import "@cfn/aws/apigateway/restapi.pkl"
+import "@cfn/aws/apigateway/deployment.pkl"
+import "@cfn/aws/apigateway/stage.pkl"
+import "@cfn/aws/apigateway/authorizer.pkl"
+
+AppName: String
+LogicalId: String
+UserPoolArn: String
+StageName: String
+
+local stageName = cfn.Sub("\(StageName)")
+
+fixed Resources: Mapping<String, cfn.Resource> = new Mapping {
+    [LogicalId + "Api"] = new restapi.RestApi {
+        Name = cfn.Sub("AppName")
+    }
+
+    [LogicalId + "ApiDeployment"] = new deployment.Deployment {
+        RestApiId = cfn.Ref(LogicalId + "Api")
+    }
+
+    [LogicalId + "ApiStage"] = new stage.Stage {
+        RestApiId = cfn.Ref(LogicalId + "Api")
+        DeploymentId = cfn.Ref(LogicalId + "ApiDeployment")
+        StageName = stageName
+    }
+
+    [LogicalId + "ApiAuthorizer"] = new authorizer.Authorizer {
+        IdentitySource = "method.request.header.authorization"
+        Name = "CognitoApiAuthorizer"
+        ProviderARNs {
+            cfn.Sub("\(UserPoolArn)")
+        }
+        RestApiId = cfn.Ref(LogicalId + "Api")
+        TypeProperty = "COGNITO_USER_POOLS"
+    }
+
+}
diff --git a/test/webapp/pkl/static-site.pkl b/test/webapp/pkl/static-site.pkl
new file mode 100644
index 00000000..907f7719
--- /dev/null
+++ b/test/webapp/pkl/static-site.pkl
@@ -0,0 +1,197 @@
+import "@cfn/aws/cloudfront/distribution.pkl"
+import "@cfn/aws/cloudfront/originaccesscontrol.pkl"
+import "@cfn/aws/wafv2/webacl.pkl"
+import "@cfn/cloudformation.pkl" as cfn
+import "./bucket.pkl"
+
+Description = "This module creates a static website consisting of an S3 bucket and a CloudFront distribution."
+
+// This is the name of the app that will be used for resource and tag names.
+// It is assumed to be a Sub string that can be plugged in to cfn.Sub
+AppName: String
+
+LogicalId: String
+
+local contentBucket = new bucket {
+  AppName = "\(outer.AppName)"
+  LogicalId = outer.LogicalId + "Content"
+}
+
+local cloudfrontLogBucket = new bucket {
+  AppName = "\(outer.AppName)-cflogs"
+  LogicalId = outer.LogicalId + "CloudFrontLogs"
+}
+
+local cflogBucketName = LogicalId + "CloudFrontLogsBucket"
+
+local distributionLogicalId = LogicalId + "Distribution"
+
+fixed Resources: Mapping<String, cfn.Resource> = new Mapping {
+
+  [LogicalId + "OriginAccessControl"] = new originaccesscontrol.OriginAccessControl {
+    OriginAccessControlConfig {
+      Name = new Mapping {
+        ["Fn::Join"] {
+          ""
+          new {
+            cfn.Sub("\(AppName)")
+            cfn.Select(2, cfn.Split("/", cfn.Ref("AWS::StackId")))
+          }
+        }
+      }
+      OriginAccessControlOriginType = "s3"
+      SigningBehavior = "always"
+      SigningProtocol = "sigv4"
+    }
+  }
+
+
+  [distributionLogicalId] = new distribution.Distribution {
+    Metadata {
+      checkov {
+        skip {
+          new {
+            id = "CKV_AWS_174"
+            comment = "Using the default cloudfront certificate with no aliases"
+          }
+        }
+      }
+      guard {
+        SuppressedRules {
+          "CLOUDFRONT_CUSTOM_SSL_CERTIFICATE"
+          "CLOUDFRONT_ORIGIN_FAILOVER_ENABLED"
+          "CLOUDFRONT_SNI_ENABLED"
+        }
+      }
+    }
+    DistributionConfig {
+      DefaultCacheBehavior {
+        CachePolicyId = "658327ea-f89d-4fab-a63d-7e88639e58f6"
+        Compress = true
+        TargetOriginId = cfn.Sub("\(AppName)-origin-1")
+        ViewerProtocolPolicy = "redirect-to-https"
+      }
+      DefaultRootObject = "index.html"
+      Enabled = true
+      HttpVersion = "http2"
+      IPV6Enabled = true
+      Logging {
+        Bucket = cfn.GetAtt(LogicalId + "CloudFrontLogsBucket", "RegionalDomainName")
+      }
+      Origins {
+        new {
+          DomainName = cfn.GetAtt(LogicalId + "ContentBucket", "RegionalDomainName")
+          Id = cfn.Sub("\(AppName)-origin-1")
+          OriginAccessControlId = cfn.GetAtt(LogicalId + "OriginAccessControl", "Id")
+          S3OriginConfig {
+            OriginAccessIdentity = ""
+          }
+        }
+      }
+      ViewerCertificate {
+        CloudFrontDefaultCertificate = true
+      }
+      WebACLId = cfn.GetAtt(LogicalId + "WebACL", "Arn")
+    }
+  }
+
+  [LogicalId + "WebACL"] = new webacl.WebACL {
+    Name = "WebACLWithAMR"
+    Scope = "CLOUDFRONT"
+    Description = "Web ACL with AWS Managed Rules"
+    DefaultAction {
+      Allow {
+      }
+    }
+    VisibilityConfig {
+      SampledRequestsEnabled = true
+      CloudWatchMetricsEnabled = true
+      MetricName = "MetricForWebACLWithAMR"
+    }
+    Tags {
+      new {
+        Key = "Name"
+        Value = cfn.Sub("\(AppName)")
+      }
+    }
+    Rules {
+      new {
+        Name = "AWS-AWSManagedRulesCommonRuleSet"
+        Priority = 0
+        OverrideAction {
+          None {
+          }
+        }
+        VisibilityConfig {
+          SampledRequestsEnabled = true
+          CloudWatchMetricsEnabled = true
+          MetricName = "MetricForAMRCRS"
+        }
+        Statement {
+          ManagedRuleGroupStatement {
+            VendorName = "AWS"
+            Name = "AWSManagedRulesCommonRuleSet"
+            ExcludedRules {
+              new {
+                Name = "NoUserAgent_HEADER"
+              }
+            }
+          }
+        }
+      }
+    }
+  }
+
+  // Add resources defined by the bucket module, and override
+  // the policy to add statements
+  for (logicalId, resource in contentBucket.Resources) {
+    when (logicalId == "SiteContentBucketAccessPolicy") {
+      [logicalId] = (resource) {
+          PolicyDocument {
+            Statement {
+              new {
+                ["Action"] = "s3:GetObject"
+                ["Effect"] = "Allow"
+                ["Resource"] {
+                  ["Fn::Sub"] = "arn:${AWS::Partition}:s3:::\(AppName)-${AWS::Region}-${AWS::AccountId}/*"
+                }
+                ["Principal"] {
+                  ["Service"] = "cloudfront.amazonaws.com"
+                }
+                ["Condition"] {
+                  ["StringEquals"] {
+                    ["AWS:SourceArn"] {
+                      ["Fn::Sub"] = "arn:aws:cloudfront::${AWS::AccountId}:distribution/${\(distributionLogicalId).Id}"
+                    }
+                  }
+                }
+              }
+            }
+          }
+      }
+    } else {
+      [logicalId] = resource
+    }
+  }
+
+  // Add resources in the CloudFront log bucket module, overriding object ownership
+  for (logicalId, resource in cloudfrontLogBucket.Resources) {
+    when (logicalId == cflogBucketName) {
+        [logicalId] = (resource) {
+            Properties {
+              OwnershipControls {
+                Rules {
+                  new {
+                    ObjectOwnership = "BucketOwnerPreferred"
+                  }
+                }
+              }
+            }
+          }
+    } else {
+        [logicalId] = resource
+    }
+  }
+
+
+}
diff --git a/test/webapp/webapp-pkg.yaml b/test/webapp/webapp-pkg.yaml
index 3db553a2..0d361e25 100644
--- a/test/webapp/webapp-pkg.yaml
+++ b/test/webapp/webapp-pkg.yaml
@@ -154,7 +154,7 @@ Resources:
           - S3_BUCKET_REPLICATION_ENABLED
       Rain:
         Content: RAIN_NO_CONTENT
-        EmptyOnDelete: false
+        EmptyOnDelete: true
 
   SiteContentBucket:
     Type: AWS::S3::Bucket
@@ -228,7 +228,7 @@ Resources:
           - S3_BUCKET_LOGGING_ENABLED
       Rain:
         Content: RAIN_NO_CONTENT
-        EmptyOnDelete: false
+        EmptyOnDelete: true
 
   SiteContentReplicationPolicy:
     Type: AWS::IAM::RolePolicy
@@ -399,7 +399,7 @@ Resources:
           - S3_BUCKET_REPLICATION_ENABLED
       Rain:
         Content: RAIN_NO_CONTENT
-        EmptyOnDelete: false
+        EmptyOnDelete: true
 
   SiteCloudFrontLogsBucket:
     Type: AWS::S3::Bucket
@@ -434,7 +434,7 @@ Resources:
           - S3_BUCKET_DEFAULT_LOCK_ENABLED
       Rain:
         Content: RAIN_NO_CONTENT
-        EmptyOnDelete: false
+        EmptyOnDelete: true
         DistributionLogicalId: NONE
 
   SiteCloudFrontLogsReplicaBucket:
@@ -466,7 +466,7 @@ Resources:
           - S3_BUCKET_LOGGING_ENABLED
       Rain:
         Content: RAIN_NO_CONTENT
-        EmptyOnDelete: false
+        EmptyOnDelete: true
 
   SiteCloudFrontLogsReplicationPolicy:
     Type: AWS::IAM::RolePolicy
@@ -626,7 +626,7 @@ Resources:
       GenerateSecret: false
       UserPoolId: !Ref CognitoUserPool
       CallbackURLs:
-        - !Sub "https://${SiteDistribution.DomainName}/index.html"
+        - !Sub https://${SiteDistribution.DomainName}/index.html
       AllowedOAuthFlows:
         - code
       AllowedOAuthFlowsUserPoolClient: true
@@ -645,7 +645,7 @@ Resources:
       Runtime: provided.al2023
       Code:
         S3Bucket: rain-artifacts-207567786752-us-east-1
-        S3Key: 2dacc5feee339788e8b7fe0687d6759cdbabcc0677748f3ac40bae065eb480be
+        S3Key: db3706b9e9ec0046b308635fbc9ecdb3a4ad31e1069210e881e5442f546cc285
       Role: !GetAtt TestResourceHandlerRole.Arn
       Environment:
         Variables:
@@ -722,7 +722,7 @@ Resources:
       Runtime: provided.al2023
       Code:
         S3Bucket: rain-artifacts-207567786752-us-east-1
-        S3Key: 834cd2e00250152c53e317fc210c6262be8cc4ea28d7e23e9574454a018921f7
+        S3Key: 7b300d8fa211e93b14974d3e699a8c479470a1b31a6202176441cf11e5ad93f3
       Role: !GetAtt JwtResourceHandlerRole.Arn
       Environment:
         Variables:
@@ -831,10 +831,10 @@ Resources:
 
 Outputs:
   SiteURL:
-    Value: !Sub "https://${SiteDistribution.DomainName}"
+    Value: !Sub https://${SiteDistribution.DomainName}
 
   RedirectURI:
-    Value: !Sub "https://${SiteDistribution.DomainName}/index.html"
+    Value: !Sub https://${SiteDistribution.DomainName}/index.html
 
   AppName:
     Value: !Ref AppName
diff --git a/test/webapp/webapp-pkl-eval.yaml b/test/webapp/webapp-pkl-eval.yaml
new file mode 100644
index 00000000..153b93e1
--- /dev/null
+++ b/test/webapp/webapp-pkl-eval.yaml
@@ -0,0 +1,937 @@
+Description: 'Creates a web application with a static website using S3 and CloudFront, an API Gateway REST API, and a DynamoDB table, with Cognito authentication. This sample uses Rain Metadata commands to upload content to the static  site bucket, and uses a Run property on the S3 directive to build the  lambda function, which for this example is written in Go. Apache-2.0 License. Adapt this template to your needs and thoruoughly test it before introducing it in a production environment. **WARNING** This template will create resources in your account that may incur billing charges. '
+AWSTemplateFormatVersion: 2010-09-09
+Metadata:
+  cfn-lint:
+    config:
+      ignore_checks:
+      - W1020
+Parameters:
+  AppName:
+    Type: String
+    Default: rain-webapp-pkl
+    Description: This name is used as a prefix for resource names
+  LambdaCodeBucket:
+    Type: String
+    Default: rain-artifacts-207567786752-us-east-1
+  TestHandlerKey:
+    Type: String
+    Default: db3706b9e9ec0046b308635fbc9ecdb3a4ad31e1069210e881e5442f546cc285
+  JwtHandlerKey:
+    Type: String
+    Default: 7b300d8fa211e93b14974d3e699a8c479470a1b31a6202176441cf11e5ad93f3
+Resources:
+  TestHandler:
+    Type: AWS::Lambda::Function
+    Properties:
+      Code:
+        S3Bucket:
+          Fn::Sub: ${LambdaCodeBucket}
+        S3Key:
+          Fn::Sub: ${TestHandlerKey}
+      Environment:
+        Variables:
+          TABLE_NAME:
+            Ref: TestTable
+      FunctionName:
+        Fn::Sub: ${AppName}-test-handler
+      Handler: bootstrap
+      Role:
+        Fn::GetAtt:
+        - TestHandlerRole
+        - Arn
+      Runtime: provided.al2023
+  TestHandlerRole:
+    Type: AWS::IAM::Role
+    Properties:
+      AssumeRolePolicyDocument:
+        Version: 2012-10-17
+        Statement:
+        - Effect: Allow
+          Principal:
+            Service:
+            - lambda.amazonaws.com
+          Action:
+          - sts:AssumeRole
+      ManagedPolicyArns:
+      - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
+  TestResource:
+    Type: AWS::ApiGateway::Resource
+    Properties:
+      ParentId:
+        Fn::Sub: ${RestApi.RootResourceId}
+      PathPart:
+        Fn::Sub: test
+      RestApiId:
+        Fn::Sub: ${RestApi}
+  TestPermission:
+    Type: AWS::Lambda::Permission
+    Properties:
+      Action: lambda:InvokeFunction
+      FunctionName:
+        Fn::GetAtt:
+        - TestHandler
+        - Arn
+      Principal: apigateway.amazonaws.com
+      SourceArn:
+        Fn::Sub: arn:${AWS::Partition}:execute-api:${AWS::Region}:${AWS::AccountId}:TestRestApi/*/*/*
+  TestRootPermission:
+    Type: AWS::Lambda::Permission
+    Properties:
+      Action: lambda:InvokeFunction
+      FunctionName:
+        Fn::GetAtt:
+        - TestHandler
+        - Arn
+      Principal: apigateway.amazonaws.com
+      SourceArn:
+        Fn::Sub: arn:${AWS::Partition}:execute-api:${AWS::Region}:${AWS::AccountId}:TestRestApi/*/*/
+  TestOptions:
+    Type: AWS::ApiGateway::Method
+    Properties:
+      AuthorizationType: NONE
+      HttpMethod: OPTIONS
+      Integration:
+        IntegrationHttpMethod: POST
+        Type: AWS_PROXY
+        Uri:
+          Fn::Sub: arn:${AWS::Partition}:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${TestHandler.Arn}/invocations
+      ResourceId:
+        Ref: TestResource
+      RestApiId:
+        Fn::Sub: ${RestApi}
+  TestGet:
+    Type: AWS::ApiGateway::Method
+    Properties:
+      AuthorizationType: COGNITO_USER_POOLS
+      AuthorizerId:
+        Fn::Sub: ${RestApiAuthorizer}
+      HttpMethod: GET
+      Integration:
+        IntegrationHttpMethod: POST
+        Type: AWS_PROXY
+        Uri:
+          Fn::Sub: arn:${AWS::Partition}:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${TestHandler.Arn}/invocations
+      ResourceId:
+        Ref: TestResource
+      RestApiId:
+        Fn::Sub: ${RestApi}
+  JwtHandler:
+    Type: AWS::Lambda::Function
+    Properties:
+      Code:
+        S3Bucket:
+          Fn::Sub: ${LambdaCodeBucket}
+        S3Key:
+          Fn::Sub: ${JwtHandlerKey}
+      Environment:
+        Variables:
+          COGNITO_REGION: us-east-1
+          COGNITO_POOL_ID:
+            Ref: CognitoUserPool
+          COGNITO_REDIRECT_URI:
+            Fn::Sub: https://${SiteDistribution.DomainName}/index.html
+          COGNITO_DOMAIN_PREFIX:
+            Ref: AppName
+          COGNITO_APP_CLIENT_ID:
+            Ref: CognitoClient
+      FunctionName:
+        Fn::Sub: ${AppName}-jwt-handler
+      Handler: bootstrap
+      Role:
+        Fn::GetAtt:
+        - JwtHandlerRole
+        - Arn
+      Runtime: provided.al2023
+  JwtHandlerRole:
+    Type: AWS::IAM::Role
+    Properties:
+      AssumeRolePolicyDocument:
+        Version: 2012-10-17
+        Statement:
+        - Effect: Allow
+          Principal:
+            Service:
+            - lambda.amazonaws.com
+          Action:
+          - sts:AssumeRole
+      ManagedPolicyArns:
+      - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
+  JwtResource:
+    Type: AWS::ApiGateway::Resource
+    Properties:
+      ParentId:
+        Fn::Sub: ${RestApi.RootResourceId}
+      PathPart:
+        Fn::Sub: jwt
+      RestApiId:
+        Fn::Sub: ${RestApi}
+  JwtPermission:
+    Type: AWS::Lambda::Permission
+    Properties:
+      Action: lambda:InvokeFunction
+      FunctionName:
+        Fn::GetAtt:
+        - JwtHandler
+        - Arn
+      Principal: apigateway.amazonaws.com
+      SourceArn:
+        Fn::Sub: arn:${AWS::Partition}:execute-api:${AWS::Region}:${AWS::AccountId}:JwtRestApi/*/*/*
+  JwtRootPermission:
+    Type: AWS::Lambda::Permission
+    Properties:
+      Action: lambda:InvokeFunction
+      FunctionName:
+        Fn::GetAtt:
+        - JwtHandler
+        - Arn
+      Principal: apigateway.amazonaws.com
+      SourceArn:
+        Fn::Sub: arn:${AWS::Partition}:execute-api:${AWS::Region}:${AWS::AccountId}:JwtRestApi/*/*/
+  JwtOptions:
+    Type: AWS::ApiGateway::Method
+    Properties:
+      AuthorizationType: NONE
+      HttpMethod: OPTIONS
+      Integration:
+        IntegrationHttpMethod: POST
+        Type: AWS_PROXY
+        Uri:
+          Fn::Sub: arn:${AWS::Partition}:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${JwtHandler.Arn}/invocations
+      ResourceId:
+        Ref: JwtResource
+      RestApiId:
+        Fn::Sub: ${RestApi}
+  JwtGet:
+    Type: AWS::ApiGateway::Method
+    Properties:
+      AuthorizationType: NONE
+      AuthorizerId:
+        Fn::Sub: AWS::NoValue
+      HttpMethod: GET
+      Integration:
+        IntegrationHttpMethod: POST
+        Type: AWS_PROXY
+        Uri:
+          Fn::Sub: arn:${AWS::Partition}:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${JwtHandler.Arn}/invocations
+      ResourceId:
+        Ref: JwtResource
+      RestApiId:
+        Fn::Sub: ${RestApi}
+  TestResourceHandlerPolicy:
+    Type: AWS::IAM::RolePolicy
+    Properties:
+      PolicyDocument:
+        Statement:
+        - Action:
+          - dynamodb:BatchGetItem
+          - dynamodb:GetItem
+          - dynamodb:Query
+          - dynamodb:Scan
+          - dynamodb:BatchWriteItem
+          - dynamodb:PutItem
+          - dynamodb:UpdateItem
+          Effect: Allow
+          Resource:
+          - Fn::GetAtt:
+            - TestTable
+            - Arn
+      PolicyName: handler-policy
+      RoleName:
+        Ref: TestHandlerRole
+  RestApi:
+    Type: AWS::ApiGateway::RestApi
+    Properties:
+      Name:
+        Fn::Sub: AppName
+  RestApiDeployment:
+    Type: AWS::ApiGateway::Deployment
+    Properties:
+      RestApiId:
+        Ref: RestApi
+    DependsOn:
+    - TestGet
+    - TestOptions
+    - JwtGet
+    - JwtOptions
+    Metadata:
+      Version: 2
+  RestApiStage:
+    Type: AWS::ApiGateway::Stage
+    Properties:
+      DeploymentId:
+        Ref: RestApiDeployment
+      RestApiId:
+        Ref: RestApi
+      StageName:
+        Fn::Sub: prod
+  RestApiAuthorizer:
+    Type: AWS::ApiGateway::Authorizer
+    Properties:
+      IdentitySource: method.request.header.authorization
+      Name: CognitoApiAuthorizer
+      ProviderARNs:
+      - Fn::Sub: ${CognitoUserPool.Arn}
+      RestApiId:
+        Ref: RestApi
+      Type: COGNITO_USER_POOLS
+  CognitoUserPool:
+    Type: AWS::Cognito::UserPool
+    Properties:
+      AdminCreateUserConfig:
+        AllowAdminCreateUserOnly: true
+      AutoVerifiedAttributes:
+      - email
+      Schema:
+      - Name: email
+        Required: true
+      - Name: given_name
+        Required: true
+      - Name: family_name
+        Required: true
+      UserPoolName:
+        Fn::Sub: ${AppName}
+    DependsOn:
+    - SiteDistribution
+  CognitoDomain:
+    Type: AWS::Cognito::UserPoolDomain
+    Properties:
+      Domain:
+        Fn::Sub: ${AppName}
+      UserPoolId:
+        Ref: CognitoUserPool
+  CognitoClient:
+    Type: AWS::Cognito::UserPoolClient
+    Properties:
+      AllowedOAuthFlows:
+      - code
+      AllowedOAuthFlowsUserPoolClient: true
+      AllowedOAuthScopes:
+      - phone
+      - email
+      - openid
+      CallbackURLs:
+      - Fn::Sub: https://${SiteDistribution.DomainName}/index.html
+      ClientName:
+        Fn::Sub: ${AppName}
+      GenerateSecret: false
+      SupportedIdentityProviders:
+      - COGNITO
+      UserPoolId:
+        Ref: CognitoUserPool
+  SiteOriginAccessControl:
+    Type: AWS::CloudFront::OriginAccessControl
+    Properties:
+      OriginAccessControlConfig:
+        Name:
+          Fn::Join:
+          - ''
+          - - Fn::Sub: ${AppName}
+            - Fn::Select:
+              - 2
+              - Fn::Split:
+                - /
+                - Ref: AWS::StackId
+        OriginAccessControlOriginType: s3
+        SigningBehavior: always
+        SigningProtocol: sigv4
+  SiteDistribution:
+    Type: AWS::CloudFront::Distribution
+    Properties:
+      DistributionConfig:
+        DefaultCacheBehavior:
+          CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6
+          Compress: true
+          TargetOriginId:
+            Fn::Sub: ${AppName}-origin-1
+          ViewerProtocolPolicy: redirect-to-https
+        DefaultRootObject: index.html
+        Enabled: true
+        HttpVersion: http2
+        IPV6Enabled: true
+        Logging:
+          Bucket:
+            Fn::GetAtt:
+            - SiteCloudFrontLogsBucket
+            - RegionalDomainName
+        Origins:
+        - DomainName:
+            Fn::GetAtt:
+            - SiteContentBucket
+            - RegionalDomainName
+          Id:
+            Fn::Sub: ${AppName}-origin-1
+          OriginAccessControlId:
+            Fn::GetAtt:
+            - SiteOriginAccessControl
+            - Id
+          S3OriginConfig:
+            OriginAccessIdentity: ''
+        ViewerCertificate:
+          CloudFrontDefaultCertificate: true
+        WebACLId:
+          Fn::GetAtt:
+          - SiteWebACL
+          - Arn
+    Metadata:
+      checkov:
+        skip:
+        - id: CKV_AWS_174
+          comment: Using the default cloudfront certificate with no aliases
+      guard:
+        SuppressedRules:
+        - CLOUDFRONT_CUSTOM_SSL_CERTIFICATE
+        - CLOUDFRONT_ORIGIN_FAILOVER_ENABLED
+        - CLOUDFRONT_SNI_ENABLED
+  SiteWebACL:
+    Type: AWS::WAFv2::WebACL
+    Properties:
+      DefaultAction:
+        Allow: {}
+      Description: Web ACL with AWS Managed Rules
+      Name: WebACLWithAMR
+      Rules:
+      - Name: AWS-AWSManagedRulesCommonRuleSet
+        OverrideAction:
+          None: {}
+        Priority: 0
+        Statement:
+          ManagedRuleGroupStatement:
+            ExcludedRules:
+            - Name: NoUserAgent_HEADER
+            Name: AWSManagedRulesCommonRuleSet
+            VendorName: AWS
+        VisibilityConfig:
+          CloudWatchMetricsEnabled: true
+          MetricName: MetricForAMRCRS
+          SampledRequestsEnabled: true
+      Scope: CLOUDFRONT
+      Tags:
+      - Key: Name
+        Value:
+          Fn::Sub: ${AppName}
+      VisibilityConfig:
+        CloudWatchMetricsEnabled: true
+        MetricName: MetricForWebACLWithAMR
+        SampledRequestsEnabled: true
+  SiteContentBucketAccessPolicy:
+    Type: AWS::S3::BucketPolicy
+    Properties:
+      Bucket:
+        Fn::Sub: ${AppName}-${AWS::Region}-${AWS::AccountId}
+      PolicyDocument:
+        Statement:
+        - Action: s3:*
+          Condition:
+            Bool:
+              aws:SecureTransport: false
+          Effect: Deny
+          Principal:
+            AWS: '*'
+          Resource:
+          - Fn::Sub: arn:${AWS::Partition}:s3:::${AppName}-${AWS::Region}-${AWS::AccountId}
+          - Fn::Sub: arn:${AWS::Partition}:s3:::${AppName}-${AWS::Region}-${AWS::AccountId}/*
+        - Action: s3:PutObject
+          Condition:
+            ArnLike:
+              aws:SourceArn:
+              - Fn::Sub: arn:${AWS::Partition}:s3:::${AppName}-${AWS::Region}-${AWS::AccountId}
+            StringEquals:
+              aws:SourceAccount:
+              - Ref: AWS::AccountId
+          Effect: Allow
+          Principal:
+            Service: logging.s3.amazonaws.com
+          Resource:
+          - Fn::Sub: arn:${AWS::Partition}:s3:::${AppName}-${AWS::Region}-${AWS::AccountId}/*
+        - Action: s3:GetObject
+          Effect: Allow
+          Resource:
+            Fn::Sub: arn:${AWS::Partition}:s3:::${AppName}-${AWS::Region}-${AWS::AccountId}/*
+          Principal:
+            Service: cloudfront.amazonaws.com
+          Condition:
+            StringEquals:
+              AWS:SourceArn:
+                Fn::Sub: arn:aws:cloudfront::${AWS::AccountId}:distribution/${SiteDistribution.Id}
+        Version: 2012-10-17
+  SiteContentLogBucketAccessPolicy:
+    Type: AWS::S3::BucketPolicy
+    Properties:
+      Bucket:
+        Fn::Sub: ${AppName}-logs-${AWS::Region}-${AWS::AccountId}
+      PolicyDocument:
+        Statement:
+        - Action: s3:*
+          Condition:
+            Bool:
+              aws:SecureTransport: false
+          Effect: Deny
+          Principal:
+            AWS: '*'
+          Resource:
+          - Fn::Sub: arn:${AWS::Partition}:s3:::${AppName}-logs-${AWS::Region}-${AWS::AccountId}
+          - Fn::Sub: arn:${AWS::Partition}:s3:::${AppName}-logs-${AWS::Region}-${AWS::AccountId}/*
+        - Action: s3:PutObject
+          Condition:
+            ArnLike:
+              aws:SourceArn:
+              - Fn::Sub: arn:${AWS::Partition}:s3:::${AppName}-logs-${AWS::Region}-${AWS::AccountId}
+            StringEquals:
+              aws:SourceAccount:
+              - Ref: AWS::AccountId
+          Effect: Allow
+          Principal:
+            Service: logging.s3.amazonaws.com
+          Resource:
+          - Fn::Sub: arn:${AWS::Partition}:s3:::${AppName}-logs-${AWS::Region}-${AWS::AccountId}/*
+        Version: 2012-10-17
+  SiteContentReplicaBucketAccessPolicy:
+    Type: AWS::S3::BucketPolicy
+    Properties:
+      Bucket:
+        Fn::Sub: ${AppName}-replicas-${AWS::Region}-${AWS::AccountId}
+      PolicyDocument:
+        Statement:
+        - Action: s3:*
+          Condition:
+            Bool:
+              aws:SecureTransport: false
+          Effect: Deny
+          Principal:
+            AWS: '*'
+          Resource:
+          - Fn::Sub: arn:${AWS::Partition}:s3:::${AppName}-replicas-${AWS::Region}-${AWS::AccountId}
+          - Fn::Sub: arn:${AWS::Partition}:s3:::${AppName}-replicas-${AWS::Region}-${AWS::AccountId}/*
+        - Action: s3:PutObject
+          Condition:
+            ArnLike:
+              aws:SourceArn:
+              - Fn::Sub: arn:${AWS::Partition}:s3:::${AppName}-replicas-${AWS::Region}-${AWS::AccountId}
+            StringEquals:
+              aws:SourceAccount:
+              - Ref: AWS::AccountId
+          Effect: Allow
+          Principal:
+            Service: logging.s3.amazonaws.com
+          Resource:
+          - Fn::Sub: arn:${AWS::Partition}:s3:::${AppName}-replicas-${AWS::Region}-${AWS::AccountId}/*
+        Version: 2012-10-17
+  SiteContentLogBucket:
+    Type: AWS::S3::Bucket
+    Properties:
+      BucketEncryption:
+        ServerSideEncryptionConfiguration:
+        - ServerSideEncryptionByDefault:
+            SSEAlgorithm: AES256
+      BucketName:
+        Fn::Sub: ${AppName}-logs-${AWS::Region}-${AWS::AccountId}
+      ObjectLockConfiguration:
+        ObjectLockEnabled: Enabled
+        Rule:
+          DefaultRetention:
+            Mode: COMPLIANCE
+            Years: 1
+      ObjectLockEnabled: true
+      PublicAccessBlockConfiguration:
+        BlockPublicAcls: true
+        BlockPublicPolicy: true
+        IgnorePublicAcls: true
+        RestrictPublicBuckets: true
+      VersioningConfiguration:
+        Status: Enabled
+    Metadata:
+      Comment: This bucket records access logs for the main bucket
+      checkov:
+        skip:
+        - comment: This is the log bucket
+          id: CKV_AWS_18
+      guard:
+        SuppressedRules:
+        - S3_BUCKET_LOGGING_ENABLED
+        - S3_BUCKET_REPLICATION_ENABLED
+  SiteContentBucket:
+    Type: AWS::S3::Bucket
+    Properties:
+      BucketEncryption:
+        ServerSideEncryptionConfiguration:
+        - ServerSideEncryptionByDefault:
+            SSEAlgorithm: AES256
+      BucketName:
+        Fn::Sub: ${AppName}-${AWS::Region}-${AWS::AccountId}
+      LoggingConfiguration:
+        DestinationBucketName:
+          Ref: SiteContentLogBucket
+      ObjectLockEnabled: false
+      PublicAccessBlockConfiguration:
+        BlockPublicAcls: true
+        BlockPublicPolicy: true
+        IgnorePublicAcls: true
+        RestrictPublicBuckets: true
+      ReplicationConfiguration:
+        Role:
+          Fn::GetAtt:
+          - SiteContentReplicationRole
+          - Arn
+        Rules:
+        - Destination:
+            Bucket:
+              Fn::GetAtt:
+              - SiteContentReplicaBucket
+              - Arn
+          Status: Enabled
+      VersioningConfiguration:
+        Status: Enabled
+    Metadata:
+      guard:
+        SuppressedRules:
+        - S3_BUCKET_DEFAULT_LOCK_ENABLED
+      Rain:
+        EmptyOnDelete: true
+        Content: site/dist
+        Version: 2
+        DistributionLogicalId: SiteDistribution
+        RunBefore:
+          Command: buildsite.sh
+        RunAfter:
+          Command: buildsite.sh
+          Args:
+          - Rain::OutputValue RestApiInvokeURL
+          - Rain::OutputValue RedirectURI
+          - Rain::OutputValue AppName
+          - Rain::OutputValue AppClientId
+  SiteContentReplicaBucket:
+    Type: AWS::S3::Bucket
+    Properties:
+      BucketEncryption:
+        ServerSideEncryptionConfiguration:
+        - ServerSideEncryptionByDefault:
+            SSEAlgorithm: AES256
+      BucketName:
+        Fn::Sub: ${AppName}-replicas-${AWS::Region}-${AWS::AccountId}
+      ObjectLockEnabled: false
+      PublicAccessBlockConfiguration:
+        BlockPublicAcls: true
+        BlockPublicPolicy: true
+        IgnorePublicAcls: true
+        RestrictPublicBuckets: true
+      VersioningConfiguration:
+        Status: Enabled
+    Metadata:
+      Comment: This bucket is used as a target for replicas from the main bucket
+      checkov:
+        skip:
+        - comment: This is the replica bucket
+          id: CKV_AWS_18
+      guard:
+        SuppressedRules:
+        - S3_BUCKET_DEFAULT_LOCK_ENABLED
+        - S3_BUCKET_REPLICATION_ENABLED
+        - S3_BUCKET_LOGGING_ENABLED
+  SiteContentReplicationPolicy:
+    Type: AWS::IAM::RolePolicy
+    Properties:
+      PolicyDocument:
+        Statement:
+        - Action:
+          - s3:GetReplicationConfiguration
+          - s3:ListBucket
+          Effect: Allow
+          Resource:
+            Fn::Sub: arn:${AWS::Partition}:s3:::${AppName}-${AWS::Region}-${AWS::AccountId}
+        - Action:
+          - s3:GetObjectVersionForReplication
+          - s3:GetObjectVersionAcl
+          - s3:GetObjectVersionTagging
+          Effect: Allow
+          Resource:
+            Fn::Sub: arn:${AWS::Partition}:s3:::${AppName}-${AWS::Region}-${AWS::AccountId}/*
+        - Action:
+          - s3:ReplicateObject
+          - s3:ReplicateDelete
+          - s3:ReplicationTags
+          Effect: Allow
+          Resource:
+            Fn::Sub: arn:${AWS::Partition}:s3:::${AppName}-replicas-${AWS::Region}-${AWS::AccountId}/*
+        Version: 2012-10-17
+      PolicyName: bucket-replication-policy
+      RoleName:
+        Ref: SiteContentReplicationRole
+  SiteContentReplicationRole:
+    Type: AWS::IAM::Role
+    Properties:
+      AssumeRolePolicyDocument:
+        Statement:
+        - Action:
+          - sts:AssumeRole
+          Effect: Allow
+          Principal:
+            Service:
+            - s3.amazonaws.com
+        Version: 2012-10-17
+      Path: /
+  SiteCloudFrontLogsBucketAccessPolicy:
+    Type: AWS::S3::BucketPolicy
+    Properties:
+      Bucket:
+        Fn::Sub: ${AppName}-cflogs-${AWS::Region}-${AWS::AccountId}
+      PolicyDocument:
+        Statement:
+        - Action: s3:*
+          Condition:
+            Bool:
+              aws:SecureTransport: false
+          Effect: Deny
+          Principal:
+            AWS: '*'
+          Resource:
+          - Fn::Sub: arn:${AWS::Partition}:s3:::${AppName}-cflogs-${AWS::Region}-${AWS::AccountId}
+          - Fn::Sub: arn:${AWS::Partition}:s3:::${AppName}-cflogs-${AWS::Region}-${AWS::AccountId}/*
+        - Action: s3:PutObject
+          Condition:
+            ArnLike:
+              aws:SourceArn:
+              - Fn::Sub: arn:${AWS::Partition}:s3:::${AppName}-cflogs-${AWS::Region}-${AWS::AccountId}
+            StringEquals:
+              aws:SourceAccount:
+              - Ref: AWS::AccountId
+          Effect: Allow
+          Principal:
+            Service: logging.s3.amazonaws.com
+          Resource:
+          - Fn::Sub: arn:${AWS::Partition}:s3:::${AppName}-cflogs-${AWS::Region}-${AWS::AccountId}/*
+        Version: 2012-10-17
+  SiteCloudFrontLogsLogBucketAccessPolicy:
+    Type: AWS::S3::BucketPolicy
+    Properties:
+      Bucket:
+        Fn::Sub: ${AppName}-cflogs-logs-${AWS::Region}-${AWS::AccountId}
+      PolicyDocument:
+        Statement:
+        - Action: s3:*
+          Condition:
+            Bool:
+              aws:SecureTransport: false
+          Effect: Deny
+          Principal:
+            AWS: '*'
+          Resource:
+          - Fn::Sub: arn:${AWS::Partition}:s3:::${AppName}-cflogs-logs-${AWS::Region}-${AWS::AccountId}
+          - Fn::Sub: arn:${AWS::Partition}:s3:::${AppName}-cflogs-logs-${AWS::Region}-${AWS::AccountId}/*
+        - Action: s3:PutObject
+          Condition:
+            ArnLike:
+              aws:SourceArn:
+              - Fn::Sub: arn:${AWS::Partition}:s3:::${AppName}-cflogs-logs-${AWS::Region}-${AWS::AccountId}
+            StringEquals:
+              aws:SourceAccount:
+              - Ref: AWS::AccountId
+          Effect: Allow
+          Principal:
+            Service: logging.s3.amazonaws.com
+          Resource:
+          - Fn::Sub: arn:${AWS::Partition}:s3:::${AppName}-cflogs-logs-${AWS::Region}-${AWS::AccountId}/*
+        Version: 2012-10-17
+  SiteCloudFrontLogsReplicaBucketAccessPolicy:
+    Type: AWS::S3::BucketPolicy
+    Properties:
+      Bucket:
+        Fn::Sub: ${AppName}-cflogs-replicas-${AWS::Region}-${AWS::AccountId}
+      PolicyDocument:
+        Statement:
+        - Action: s3:*
+          Condition:
+            Bool:
+              aws:SecureTransport: false
+          Effect: Deny
+          Principal:
+            AWS: '*'
+          Resource:
+          - Fn::Sub: arn:${AWS::Partition}:s3:::${AppName}-cflogs-replicas-${AWS::Region}-${AWS::AccountId}
+          - Fn::Sub: arn:${AWS::Partition}:s3:::${AppName}-cflogs-replicas-${AWS::Region}-${AWS::AccountId}/*
+        - Action: s3:PutObject
+          Condition:
+            ArnLike:
+              aws:SourceArn:
+              - Fn::Sub: arn:${AWS::Partition}:s3:::${AppName}-cflogs-replicas-${AWS::Region}-${AWS::AccountId}
+            StringEquals:
+              aws:SourceAccount:
+              - Ref: AWS::AccountId
+          Effect: Allow
+          Principal:
+            Service: logging.s3.amazonaws.com
+          Resource:
+          - Fn::Sub: arn:${AWS::Partition}:s3:::${AppName}-cflogs-replicas-${AWS::Region}-${AWS::AccountId}/*
+        Version: 2012-10-17
+  SiteCloudFrontLogsLogBucket:
+    Type: AWS::S3::Bucket
+    Properties:
+      BucketEncryption:
+        ServerSideEncryptionConfiguration:
+        - ServerSideEncryptionByDefault:
+            SSEAlgorithm: AES256
+      BucketName:
+        Fn::Sub: ${AppName}-cflogs-logs-${AWS::Region}-${AWS::AccountId}
+      ObjectLockConfiguration:
+        ObjectLockEnabled: Enabled
+        Rule:
+          DefaultRetention:
+            Mode: COMPLIANCE
+            Years: 1
+      ObjectLockEnabled: true
+      PublicAccessBlockConfiguration:
+        BlockPublicAcls: true
+        BlockPublicPolicy: true
+        IgnorePublicAcls: true
+        RestrictPublicBuckets: true
+      VersioningConfiguration:
+        Status: Enabled
+    Metadata:
+      Comment: This bucket records access logs for the main bucket
+      checkov:
+        skip:
+        - comment: This is the log bucket
+          id: CKV_AWS_18
+      guard:
+        SuppressedRules:
+        - S3_BUCKET_LOGGING_ENABLED
+        - S3_BUCKET_REPLICATION_ENABLED
+  SiteCloudFrontLogsBucket:
+    Type: AWS::S3::Bucket
+    Properties:
+      BucketEncryption:
+        ServerSideEncryptionConfiguration:
+        - ServerSideEncryptionByDefault:
+            SSEAlgorithm: AES256
+      BucketName:
+        Fn::Sub: ${AppName}-cflogs-${AWS::Region}-${AWS::AccountId}
+      LoggingConfiguration:
+        DestinationBucketName:
+          Ref: SiteCloudFrontLogsLogBucket
+      ObjectLockEnabled: false
+      PublicAccessBlockConfiguration:
+        BlockPublicAcls: true
+        BlockPublicPolicy: true
+        IgnorePublicAcls: true
+        RestrictPublicBuckets: true
+      ReplicationConfiguration:
+        Role:
+          Fn::GetAtt:
+          - SiteCloudFrontLogsReplicationRole
+          - Arn
+        Rules:
+        - Destination:
+            Bucket:
+              Fn::GetAtt:
+              - SiteCloudFrontLogsReplicaBucket
+              - Arn
+          Status: Enabled
+      VersioningConfiguration:
+        Status: Enabled
+      OwnershipControls:
+        Rules:
+        - ObjectOwnership: BucketOwnerPreferred
+    Metadata:
+      guard:
+        SuppressedRules:
+        - S3_BUCKET_DEFAULT_LOCK_ENABLED
+  SiteCloudFrontLogsReplicaBucket:
+    Type: AWS::S3::Bucket
+    Properties:
+      BucketEncryption:
+        ServerSideEncryptionConfiguration:
+        - ServerSideEncryptionByDefault:
+            SSEAlgorithm: AES256
+      BucketName:
+        Fn::Sub: ${AppName}-cflogs-replicas-${AWS::Region}-${AWS::AccountId}
+      ObjectLockEnabled: false
+      PublicAccessBlockConfiguration:
+        BlockPublicAcls: true
+        BlockPublicPolicy: true
+        IgnorePublicAcls: true
+        RestrictPublicBuckets: true
+      VersioningConfiguration:
+        Status: Enabled
+    Metadata:
+      Comment: This bucket is used as a target for replicas from the main bucket
+      checkov:
+        skip:
+        - comment: This is the replica bucket
+          id: CKV_AWS_18
+      guard:
+        SuppressedRules:
+        - S3_BUCKET_DEFAULT_LOCK_ENABLED
+        - S3_BUCKET_REPLICATION_ENABLED
+        - S3_BUCKET_LOGGING_ENABLED
+  SiteCloudFrontLogsReplicationPolicy:
+    Type: AWS::IAM::RolePolicy
+    Properties:
+      PolicyDocument:
+        Statement:
+        - Action:
+          - s3:GetReplicationConfiguration
+          - s3:ListBucket
+          Effect: Allow
+          Resource:
+            Fn::Sub: arn:${AWS::Partition}:s3:::${AppName}-cflogs-${AWS::Region}-${AWS::AccountId}
+        - Action:
+          - s3:GetObjectVersionForReplication
+          - s3:GetObjectVersionAcl
+          - s3:GetObjectVersionTagging
+          Effect: Allow
+          Resource:
+            Fn::Sub: arn:${AWS::Partition}:s3:::${AppName}-cflogs-${AWS::Region}-${AWS::AccountId}/*
+        - Action:
+          - s3:ReplicateObject
+          - s3:ReplicateDelete
+          - s3:ReplicationTags
+          Effect: Allow
+          Resource:
+            Fn::Sub: arn:${AWS::Partition}:s3:::${AppName}-cflogs-replicas-${AWS::Region}-${AWS::AccountId}/*
+        Version: 2012-10-17
+      PolicyName: bucket-replication-policy
+      RoleName:
+        Ref: SiteCloudFrontLogsReplicationRole
+  SiteCloudFrontLogsReplicationRole:
+    Type: AWS::IAM::Role
+    Properties:
+      AssumeRolePolicyDocument:
+        Statement:
+        - Action:
+          - sts:AssumeRole
+          Effect: Allow
+          Principal:
+            Service:
+            - s3.amazonaws.com
+        Version: 2012-10-17
+      Path: /
+  TestTable:
+    Type: AWS::DynamoDB::Table
+    Properties:
+      AttributeDefinitions:
+      - AttributeName: id
+        AttributeType: S
+      BillingMode: PAY_PER_REQUEST
+      KeySchema:
+      - AttributeName: id
+        KeyType: HASH
+      TableName:
+        Fn::Sub: ${AppName}-test
+Outputs:
+  SiteURL:
+    Value:
+      Fn::Sub: https://${SiteDistribution.DomainName}
+  RedirectURI:
+    Value:
+      Fn::Sub: https://${SiteDistribution.DomainName}/index.html
+  AppName:
+    Value:
+      Ref: AppName
+  RestApiInvokeURL:
+    Value:
+      Fn::Sub: https://${RestApi}.execute-api.${AWS::Region}.amazonaws.com/${RestApiStage}
+  AppClientId:
+    Value:
+      Ref: CognitoClient
+  CognitoDomainPrefix:
+    Value:
+      Ref: AppName
diff --git a/test/webapp/webapp-pkl-pkg.yaml b/test/webapp/webapp-pkl-pkg.yaml
new file mode 100644
index 00000000..132f5e2f
--- /dev/null
+++ b/test/webapp/webapp-pkl-pkg.yaml
@@ -0,0 +1,866 @@
+Description: 'Creates a web application with a static website using S3 and CloudFront, an API Gateway REST API, and a DynamoDB table, with Cognito authentication. This sample uses Rain Metadata commands to upload content to the static  site bucket, and uses a Run property on the S3 directive to build the  lambda function, which for this example is written in Go. Apache-2.0 License. Adapt this template to your needs and thoruoughly test it before introducing it in a production environment. **WARNING** This template will create resources in your account that may incur billing charges. '
+
+AWSTemplateFormatVersion: "2010-09-09"
+
+Metadata:
+  cfn-lint:
+    config:
+      ignore_checks:
+        - W1020
+
+Parameters:
+  AppName:
+    Type: String
+    Default: rain-webapp-pkl
+    Description: This name is used as a prefix for resource names
+
+  LambdaCodeBucket:
+    Type: String
+    Default: rain-artifacts-207567786752-us-east-1
+
+  TestHandlerKey:
+    Type: String
+    Default: db3706b9e9ec0046b308635fbc9ecdb3a4ad31e1069210e881e5442f546cc285
+
+  JwtHandlerKey:
+    Type: String
+    Default: 7b300d8fa211e93b14974d3e699a8c479470a1b31a6202176441cf11e5ad93f3
+
+Resources:
+  TestHandler:
+    Type: AWS::Lambda::Function
+    Properties:
+      Code:
+        S3Bucket: !Sub ${LambdaCodeBucket}
+        S3Key: !Sub ${TestHandlerKey}
+      Environment:
+        Variables:
+          TABLE_NAME: !Ref TestTable
+      FunctionName: !Sub ${AppName}-test-handler
+      Handler: bootstrap
+      Role: !GetAtt TestHandlerRole.Arn
+      Runtime: provided.al2023
+
+  TestHandlerRole:
+    Type: AWS::IAM::Role
+    Properties:
+      AssumeRolePolicyDocument:
+        Version: "2012-10-17"
+        Statement:
+          - Effect: Allow
+            Principal:
+              Service:
+                - lambda.amazonaws.com
+            Action:
+              - sts:AssumeRole
+      ManagedPolicyArns:
+        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
+
+  TestResource:
+    Type: AWS::ApiGateway::Resource
+    Properties:
+      ParentId: !Sub ${RestApi.RootResourceId}
+      PathPart: !Sub test
+      RestApiId: !Sub ${RestApi}
+
+  TestPermission:
+    Type: AWS::Lambda::Permission
+    Properties:
+      Action: lambda:InvokeFunction
+      FunctionName: !GetAtt TestHandler.Arn
+      Principal: apigateway.amazonaws.com
+      SourceArn: !Sub arn:${AWS::Partition}:execute-api:${AWS::Region}:${AWS::AccountId}:TestRestApi/*/*/*
+
+  TestRootPermission:
+    Type: AWS::Lambda::Permission
+    Properties:
+      Action: lambda:InvokeFunction
+      FunctionName: !GetAtt TestHandler.Arn
+      Principal: apigateway.amazonaws.com
+      SourceArn: !Sub arn:${AWS::Partition}:execute-api:${AWS::Region}:${AWS::AccountId}:TestRestApi/*/*/
+
+  TestOptions:
+    Type: AWS::ApiGateway::Method
+    Properties:
+      AuthorizationType: NONE
+      HttpMethod: OPTIONS
+      Integration:
+        IntegrationHttpMethod: POST
+        Type: AWS_PROXY
+        Uri: !Sub arn:${AWS::Partition}:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${TestHandler.Arn}/invocations
+      ResourceId: !Ref TestResource
+      RestApiId: !Sub ${RestApi}
+
+  TestGet:
+    Type: AWS::ApiGateway::Method
+    Properties:
+      AuthorizationType: COGNITO_USER_POOLS
+      AuthorizerId: !Sub ${RestApiAuthorizer}
+      HttpMethod: GET
+      Integration:
+        IntegrationHttpMethod: POST
+        Type: AWS_PROXY
+        Uri: !Sub arn:${AWS::Partition}:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${TestHandler.Arn}/invocations
+      ResourceId: !Ref TestResource
+      RestApiId: !Sub ${RestApi}
+
+  JwtHandler:
+    Type: AWS::Lambda::Function
+    Properties:
+      Code:
+        S3Bucket: !Sub ${LambdaCodeBucket}
+        S3Key: !Sub ${JwtHandlerKey}
+      Environment:
+        Variables:
+          COGNITO_REGION: us-east-1
+          COGNITO_POOL_ID: !Ref CognitoUserPool
+          COGNITO_REDIRECT_URI: !Sub https://${SiteDistribution.DomainName}/index.html
+          COGNITO_DOMAIN_PREFIX: !Ref AppName
+          COGNITO_APP_CLIENT_ID: !Ref CognitoClient
+      FunctionName: !Sub ${AppName}-jwt-handler
+      Handler: bootstrap
+      Role: !GetAtt JwtHandlerRole.Arn
+      Runtime: provided.al2023
+
+  JwtHandlerRole:
+    Type: AWS::IAM::Role
+    Properties:
+      AssumeRolePolicyDocument:
+        Version: "2012-10-17"
+        Statement:
+          - Effect: Allow
+            Principal:
+              Service:
+                - lambda.amazonaws.com
+            Action:
+              - sts:AssumeRole
+      ManagedPolicyArns:
+        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
+
+  JwtResource:
+    Type: AWS::ApiGateway::Resource
+    Properties:
+      ParentId: !Sub ${RestApi.RootResourceId}
+      PathPart: !Sub jwt
+      RestApiId: !Sub ${RestApi}
+
+  JwtPermission:
+    Type: AWS::Lambda::Permission
+    Properties:
+      Action: lambda:InvokeFunction
+      FunctionName: !GetAtt JwtHandler.Arn
+      Principal: apigateway.amazonaws.com
+      SourceArn: !Sub arn:${AWS::Partition}:execute-api:${AWS::Region}:${AWS::AccountId}:JwtRestApi/*/*/*
+
+  JwtRootPermission:
+    Type: AWS::Lambda::Permission
+    Properties:
+      Action: lambda:InvokeFunction
+      FunctionName: !GetAtt JwtHandler.Arn
+      Principal: apigateway.amazonaws.com
+      SourceArn: !Sub arn:${AWS::Partition}:execute-api:${AWS::Region}:${AWS::AccountId}:JwtRestApi/*/*/
+
+  JwtOptions:
+    Type: AWS::ApiGateway::Method
+    Properties:
+      AuthorizationType: NONE
+      HttpMethod: OPTIONS
+      Integration:
+        IntegrationHttpMethod: POST
+        Type: AWS_PROXY
+        Uri: !Sub arn:${AWS::Partition}:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${JwtHandler.Arn}/invocations
+      ResourceId: !Ref JwtResource
+      RestApiId: !Sub ${RestApi}
+
+  JwtGet:
+    Type: AWS::ApiGateway::Method
+    Properties:
+      AuthorizationType: NONE
+      AuthorizerId: !Sub AWS::NoValue
+      HttpMethod: GET
+      Integration:
+        IntegrationHttpMethod: POST
+        Type: AWS_PROXY
+        Uri: !Sub arn:${AWS::Partition}:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${JwtHandler.Arn}/invocations
+      ResourceId: !Ref JwtResource
+      RestApiId: !Sub ${RestApi}
+
+  TestResourceHandlerPolicy:
+    Type: AWS::IAM::RolePolicy
+    Properties:
+      PolicyDocument:
+        Statement:
+          - Action:
+              - dynamodb:BatchGetItem
+              - dynamodb:GetItem
+              - dynamodb:Query
+              - dynamodb:Scan
+              - dynamodb:BatchWriteItem
+              - dynamodb:PutItem
+              - dynamodb:UpdateItem
+            Effect: Allow
+            Resource:
+              - !GetAtt TestTable.Arn
+      PolicyName: handler-policy
+      RoleName: !Ref TestHandlerRole
+
+  RestApi:
+    Type: AWS::ApiGateway::RestApi
+    Properties:
+      Name: !Sub AppName
+
+  RestApiDeployment:
+    Type: AWS::ApiGateway::Deployment
+    Properties:
+      RestApiId: !Ref RestApi
+    DependsOn:
+      - TestGet
+      - TestOptions
+      - JwtGet
+      - JwtOptions
+    Metadata:
+      Version: 2
+
+  RestApiStage:
+    Type: AWS::ApiGateway::Stage
+    Properties:
+      DeploymentId: !Ref RestApiDeployment
+      RestApiId: !Ref RestApi
+      StageName: !Sub prod
+
+  RestApiAuthorizer:
+    Type: AWS::ApiGateway::Authorizer
+    Properties:
+      IdentitySource: method.request.header.authorization
+      Name: CognitoApiAuthorizer
+      ProviderARNs:
+        - !Sub ${CognitoUserPool.Arn}
+      RestApiId: !Ref RestApi
+      Type: COGNITO_USER_POOLS
+
+  CognitoUserPool:
+    Type: AWS::Cognito::UserPool
+    Properties:
+      AdminCreateUserConfig:
+        AllowAdminCreateUserOnly: true
+      AutoVerifiedAttributes:
+        - email
+      Schema:
+        - Name: email
+          Required: true
+        - Name: given_name
+          Required: true
+        - Name: family_name
+          Required: true
+      UserPoolName: !Sub ${AppName}
+    DependsOn:
+      - SiteDistribution
+
+  CognitoDomain:
+    Type: AWS::Cognito::UserPoolDomain
+    Properties:
+      Domain: !Sub ${AppName}
+      UserPoolId: !Ref CognitoUserPool
+
+  CognitoClient:
+    Type: AWS::Cognito::UserPoolClient
+    Properties:
+      AllowedOAuthFlows:
+        - code
+      AllowedOAuthFlowsUserPoolClient: true
+      AllowedOAuthScopes:
+        - phone
+        - email
+        - openid
+      CallbackURLs:
+        - !Sub https://${SiteDistribution.DomainName}/index.html
+      ClientName: !Sub ${AppName}
+      GenerateSecret: false
+      SupportedIdentityProviders:
+        - COGNITO
+      UserPoolId: !Ref CognitoUserPool
+
+  SiteOriginAccessControl:
+    Type: AWS::CloudFront::OriginAccessControl
+    Properties:
+      OriginAccessControlConfig:
+        Name: !Join
+          - ""
+          - - !Sub ${AppName}
+            - !Select
+              - 2
+              - !Split
+                - /
+                - !Ref AWS::StackId
+        OriginAccessControlOriginType: s3
+        SigningBehavior: always
+        SigningProtocol: sigv4
+
+  SiteDistribution:
+    Type: AWS::CloudFront::Distribution
+    Properties:
+      DistributionConfig:
+        DefaultCacheBehavior:
+          CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6
+          Compress: true
+          TargetOriginId: !Sub ${AppName}-origin-1
+          ViewerProtocolPolicy: redirect-to-https
+        DefaultRootObject: index.html
+        Enabled: true
+        HttpVersion: http2
+        IPV6Enabled: true
+        Logging:
+          Bucket: !GetAtt SiteCloudFrontLogsBucket.RegionalDomainName
+        Origins:
+          - DomainName: !GetAtt SiteContentBucket.RegionalDomainName
+            Id: !Sub ${AppName}-origin-1
+            OriginAccessControlId: !GetAtt SiteOriginAccessControl.Id
+            S3OriginConfig:
+              OriginAccessIdentity: ""
+        ViewerCertificate:
+          CloudFrontDefaultCertificate: true
+        WebACLId: !GetAtt SiteWebACL.Arn
+    Metadata:
+      checkov:
+        skip:
+          - id: CKV_AWS_174
+            comment: Using the default cloudfront certificate with no aliases
+      guard:
+        SuppressedRules:
+          - CLOUDFRONT_CUSTOM_SSL_CERTIFICATE
+          - CLOUDFRONT_ORIGIN_FAILOVER_ENABLED
+          - CLOUDFRONT_SNI_ENABLED
+
+  SiteWebACL:
+    Type: AWS::WAFv2::WebACL
+    Properties:
+      DefaultAction:
+        Allow: {}
+      Description: Web ACL with AWS Managed Rules
+      Name: WebACLWithAMR
+      Rules:
+        - Name: AWS-AWSManagedRulesCommonRuleSet
+          OverrideAction:
+            None: {}
+          Priority: 0
+          Statement:
+            ManagedRuleGroupStatement:
+              ExcludedRules:
+                - Name: NoUserAgent_HEADER
+              Name: AWSManagedRulesCommonRuleSet
+              VendorName: AWS
+          VisibilityConfig:
+            CloudWatchMetricsEnabled: true
+            MetricName: MetricForAMRCRS
+            SampledRequestsEnabled: true
+      Scope: CLOUDFRONT
+      Tags:
+        - Key: Name
+          Value: !Sub ${AppName}
+      VisibilityConfig:
+        CloudWatchMetricsEnabled: true
+        MetricName: MetricForWebACLWithAMR
+        SampledRequestsEnabled: true
+
+  SiteContentBucketAccessPolicy:
+    Type: AWS::S3::BucketPolicy
+    Properties:
+      Bucket: !Sub ${AppName}-${AWS::Region}-${AWS::AccountId}
+      PolicyDocument:
+        Statement:
+          - Action: s3:*
+            Condition:
+              Bool:
+                aws:SecureTransport: false
+            Effect: Deny
+            Principal:
+              AWS: '*'
+            Resource:
+              - !Sub arn:${AWS::Partition}:s3:::${AppName}-${AWS::Region}-${AWS::AccountId}
+              - !Sub arn:${AWS::Partition}:s3:::${AppName}-${AWS::Region}-${AWS::AccountId}/*
+          - Action: s3:PutObject
+            Condition:
+              ArnLike:
+                aws:SourceArn:
+                  - !Sub arn:${AWS::Partition}:s3:::${AppName}-${AWS::Region}-${AWS::AccountId}
+              StringEquals:
+                aws:SourceAccount:
+                  - !Ref AWS::AccountId
+            Effect: Allow
+            Principal:
+              Service: logging.s3.amazonaws.com
+            Resource:
+              - !Sub arn:${AWS::Partition}:s3:::${AppName}-${AWS::Region}-${AWS::AccountId}/*
+          - Action: s3:GetObject
+            Effect: Allow
+            Resource: !Sub arn:${AWS::Partition}:s3:::${AppName}-${AWS::Region}-${AWS::AccountId}/*
+            Principal:
+              Service: cloudfront.amazonaws.com
+            Condition:
+              StringEquals:
+                AWS:SourceArn: !Sub arn:aws:cloudfront::${AWS::AccountId}:distribution/${SiteDistribution.Id}
+        Version: "2012-10-17"
+
+  SiteContentLogBucketAccessPolicy:
+    Type: AWS::S3::BucketPolicy
+    Properties:
+      Bucket: !Sub ${AppName}-logs-${AWS::Region}-${AWS::AccountId}
+      PolicyDocument:
+        Statement:
+          - Action: s3:*
+            Condition:
+              Bool:
+                aws:SecureTransport: false
+            Effect: Deny
+            Principal:
+              AWS: '*'
+            Resource:
+              - !Sub arn:${AWS::Partition}:s3:::${AppName}-logs-${AWS::Region}-${AWS::AccountId}
+              - !Sub arn:${AWS::Partition}:s3:::${AppName}-logs-${AWS::Region}-${AWS::AccountId}/*
+          - Action: s3:PutObject
+            Condition:
+              ArnLike:
+                aws:SourceArn:
+                  - !Sub arn:${AWS::Partition}:s3:::${AppName}-logs-${AWS::Region}-${AWS::AccountId}
+              StringEquals:
+                aws:SourceAccount:
+                  - !Ref AWS::AccountId
+            Effect: Allow
+            Principal:
+              Service: logging.s3.amazonaws.com
+            Resource:
+              - !Sub arn:${AWS::Partition}:s3:::${AppName}-logs-${AWS::Region}-${AWS::AccountId}/*
+        Version: "2012-10-17"
+
+  SiteContentReplicaBucketAccessPolicy:
+    Type: AWS::S3::BucketPolicy
+    Properties:
+      Bucket: !Sub ${AppName}-replicas-${AWS::Region}-${AWS::AccountId}
+      PolicyDocument:
+        Statement:
+          - Action: s3:*
+            Condition:
+              Bool:
+                aws:SecureTransport: false
+            Effect: Deny
+            Principal:
+              AWS: '*'
+            Resource:
+              - !Sub arn:${AWS::Partition}:s3:::${AppName}-replicas-${AWS::Region}-${AWS::AccountId}
+              - !Sub arn:${AWS::Partition}:s3:::${AppName}-replicas-${AWS::Region}-${AWS::AccountId}/*
+          - Action: s3:PutObject
+            Condition:
+              ArnLike:
+                aws:SourceArn:
+                  - !Sub arn:${AWS::Partition}:s3:::${AppName}-replicas-${AWS::Region}-${AWS::AccountId}
+              StringEquals:
+                aws:SourceAccount:
+                  - !Ref AWS::AccountId
+            Effect: Allow
+            Principal:
+              Service: logging.s3.amazonaws.com
+            Resource:
+              - !Sub arn:${AWS::Partition}:s3:::${AppName}-replicas-${AWS::Region}-${AWS::AccountId}/*
+        Version: "2012-10-17"
+
+  SiteContentLogBucket:
+    Type: AWS::S3::Bucket
+    Properties:
+      BucketEncryption:
+        ServerSideEncryptionConfiguration:
+          - ServerSideEncryptionByDefault:
+              SSEAlgorithm: AES256
+      BucketName: !Sub ${AppName}-logs-${AWS::Region}-${AWS::AccountId}
+      ObjectLockConfiguration:
+        ObjectLockEnabled: Enabled
+        Rule:
+          DefaultRetention:
+            Mode: COMPLIANCE
+            Years: 1
+      ObjectLockEnabled: true
+      PublicAccessBlockConfiguration:
+        BlockPublicAcls: true
+        BlockPublicPolicy: true
+        IgnorePublicAcls: true
+        RestrictPublicBuckets: true
+      VersioningConfiguration:
+        Status: Enabled
+    Metadata:
+      Comment: This bucket records access logs for the main bucket
+      checkov:
+        skip:
+          - comment: This is the log bucket
+            id: CKV_AWS_18
+      guard:
+        SuppressedRules:
+          - S3_BUCKET_LOGGING_ENABLED
+          - S3_BUCKET_REPLICATION_ENABLED
+
+  SiteContentBucket:
+    Type: AWS::S3::Bucket
+    Properties:
+      BucketEncryption:
+        ServerSideEncryptionConfiguration:
+          - ServerSideEncryptionByDefault:
+              SSEAlgorithm: AES256
+      BucketName: !Sub ${AppName}-${AWS::Region}-${AWS::AccountId}
+      LoggingConfiguration:
+        DestinationBucketName: !Ref SiteContentLogBucket
+      ObjectLockEnabled: false
+      PublicAccessBlockConfiguration:
+        BlockPublicAcls: true
+        BlockPublicPolicy: true
+        IgnorePublicAcls: true
+        RestrictPublicBuckets: true
+      ReplicationConfiguration:
+        Role: !GetAtt SiteContentReplicationRole.Arn
+        Rules:
+          - Destination:
+              Bucket: !GetAtt SiteContentReplicaBucket.Arn
+            Status: Enabled
+      VersioningConfiguration:
+        Status: Enabled
+    Metadata:
+      guard:
+        SuppressedRules:
+          - S3_BUCKET_DEFAULT_LOCK_ENABLED
+      Rain:
+        EmptyOnDelete: true
+        Content: site/dist
+        Version: 2
+        DistributionLogicalId: SiteDistribution
+        RunBefore:
+          Command: buildsite.sh
+        RunAfter:
+          Command: buildsite.sh
+          Args:
+            - Rain::OutputValue RestApiInvokeURL
+            - Rain::OutputValue RedirectURI
+            - Rain::OutputValue AppName
+            - Rain::OutputValue AppClientId
+
+  SiteContentReplicaBucket:
+    Type: AWS::S3::Bucket
+    Properties:
+      BucketEncryption:
+        ServerSideEncryptionConfiguration:
+          - ServerSideEncryptionByDefault:
+              SSEAlgorithm: AES256
+      BucketName: !Sub ${AppName}-replicas-${AWS::Region}-${AWS::AccountId}
+      ObjectLockEnabled: false
+      PublicAccessBlockConfiguration:
+        BlockPublicAcls: true
+        BlockPublicPolicy: true
+        IgnorePublicAcls: true
+        RestrictPublicBuckets: true
+      VersioningConfiguration:
+        Status: Enabled
+    Metadata:
+      Comment: This bucket is used as a target for replicas from the main bucket
+      checkov:
+        skip:
+          - comment: This is the replica bucket
+            id: CKV_AWS_18
+      guard:
+        SuppressedRules:
+          - S3_BUCKET_DEFAULT_LOCK_ENABLED
+          - S3_BUCKET_REPLICATION_ENABLED
+          - S3_BUCKET_LOGGING_ENABLED
+
+  SiteContentReplicationPolicy:
+    Type: AWS::IAM::RolePolicy
+    Properties:
+      PolicyDocument:
+        Statement:
+          - Action:
+              - s3:GetReplicationConfiguration
+              - s3:ListBucket
+            Effect: Allow
+            Resource: !Sub arn:${AWS::Partition}:s3:::${AppName}-${AWS::Region}-${AWS::AccountId}
+          - Action:
+              - s3:GetObjectVersionForReplication
+              - s3:GetObjectVersionAcl
+              - s3:GetObjectVersionTagging
+            Effect: Allow
+            Resource: !Sub arn:${AWS::Partition}:s3:::${AppName}-${AWS::Region}-${AWS::AccountId}/*
+          - Action:
+              - s3:ReplicateObject
+              - s3:ReplicateDelete
+              - s3:ReplicationTags
+            Effect: Allow
+            Resource: !Sub arn:${AWS::Partition}:s3:::${AppName}-replicas-${AWS::Region}-${AWS::AccountId}/*
+        Version: "2012-10-17"
+      PolicyName: bucket-replication-policy
+      RoleName: !Ref SiteContentReplicationRole
+
+  SiteContentReplicationRole:
+    Type: AWS::IAM::Role
+    Properties:
+      AssumeRolePolicyDocument:
+        Statement:
+          - Action:
+              - sts:AssumeRole
+            Effect: Allow
+            Principal:
+              Service:
+                - s3.amazonaws.com
+        Version: "2012-10-17"
+      Path: /
+
+  SiteCloudFrontLogsBucketAccessPolicy:
+    Type: AWS::S3::BucketPolicy
+    Properties:
+      Bucket: !Sub ${AppName}-cflogs-${AWS::Region}-${AWS::AccountId}
+      PolicyDocument:
+        Statement:
+          - Action: s3:*
+            Condition:
+              Bool:
+                aws:SecureTransport: false
+            Effect: Deny
+            Principal:
+              AWS: '*'
+            Resource:
+              - !Sub arn:${AWS::Partition}:s3:::${AppName}-cflogs-${AWS::Region}-${AWS::AccountId}
+              - !Sub arn:${AWS::Partition}:s3:::${AppName}-cflogs-${AWS::Region}-${AWS::AccountId}/*
+          - Action: s3:PutObject
+            Condition:
+              ArnLike:
+                aws:SourceArn:
+                  - !Sub arn:${AWS::Partition}:s3:::${AppName}-cflogs-${AWS::Region}-${AWS::AccountId}
+              StringEquals:
+                aws:SourceAccount:
+                  - !Ref AWS::AccountId
+            Effect: Allow
+            Principal:
+              Service: logging.s3.amazonaws.com
+            Resource:
+              - !Sub arn:${AWS::Partition}:s3:::${AppName}-cflogs-${AWS::Region}-${AWS::AccountId}/*
+        Version: "2012-10-17"
+
+  SiteCloudFrontLogsLogBucketAccessPolicy:
+    Type: AWS::S3::BucketPolicy
+    Properties:
+      Bucket: !Sub ${AppName}-cflogs-logs-${AWS::Region}-${AWS::AccountId}
+      PolicyDocument:
+        Statement:
+          - Action: s3:*
+            Condition:
+              Bool:
+                aws:SecureTransport: false
+            Effect: Deny
+            Principal:
+              AWS: '*'
+            Resource:
+              - !Sub arn:${AWS::Partition}:s3:::${AppName}-cflogs-logs-${AWS::Region}-${AWS::AccountId}
+              - !Sub arn:${AWS::Partition}:s3:::${AppName}-cflogs-logs-${AWS::Region}-${AWS::AccountId}/*
+          - Action: s3:PutObject
+            Condition:
+              ArnLike:
+                aws:SourceArn:
+                  - !Sub arn:${AWS::Partition}:s3:::${AppName}-cflogs-logs-${AWS::Region}-${AWS::AccountId}
+              StringEquals:
+                aws:SourceAccount:
+                  - !Ref AWS::AccountId
+            Effect: Allow
+            Principal:
+              Service: logging.s3.amazonaws.com
+            Resource:
+              - !Sub arn:${AWS::Partition}:s3:::${AppName}-cflogs-logs-${AWS::Region}-${AWS::AccountId}/*
+        Version: "2012-10-17"
+
+  SiteCloudFrontLogsReplicaBucketAccessPolicy:
+    Type: AWS::S3::BucketPolicy
+    Properties:
+      Bucket: !Sub ${AppName}-cflogs-replicas-${AWS::Region}-${AWS::AccountId}
+      PolicyDocument:
+        Statement:
+          - Action: s3:*
+            Condition:
+              Bool:
+                aws:SecureTransport: false
+            Effect: Deny
+            Principal:
+              AWS: '*'
+            Resource:
+              - !Sub arn:${AWS::Partition}:s3:::${AppName}-cflogs-replicas-${AWS::Region}-${AWS::AccountId}
+              - !Sub arn:${AWS::Partition}:s3:::${AppName}-cflogs-replicas-${AWS::Region}-${AWS::AccountId}/*
+          - Action: s3:PutObject
+            Condition:
+              ArnLike:
+                aws:SourceArn:
+                  - !Sub arn:${AWS::Partition}:s3:::${AppName}-cflogs-replicas-${AWS::Region}-${AWS::AccountId}
+              StringEquals:
+                aws:SourceAccount:
+                  - !Ref AWS::AccountId
+            Effect: Allow
+            Principal:
+              Service: logging.s3.amazonaws.com
+            Resource:
+              - !Sub arn:${AWS::Partition}:s3:::${AppName}-cflogs-replicas-${AWS::Region}-${AWS::AccountId}/*
+        Version: "2012-10-17"
+
+  SiteCloudFrontLogsLogBucket:
+    Type: AWS::S3::Bucket
+    Properties:
+      BucketEncryption:
+        ServerSideEncryptionConfiguration:
+          - ServerSideEncryptionByDefault:
+              SSEAlgorithm: AES256
+      BucketName: !Sub ${AppName}-cflogs-logs-${AWS::Region}-${AWS::AccountId}
+      ObjectLockConfiguration:
+        ObjectLockEnabled: Enabled
+        Rule:
+          DefaultRetention:
+            Mode: COMPLIANCE
+            Years: 1
+      ObjectLockEnabled: true
+      PublicAccessBlockConfiguration:
+        BlockPublicAcls: true
+        BlockPublicPolicy: true
+        IgnorePublicAcls: true
+        RestrictPublicBuckets: true
+      VersioningConfiguration:
+        Status: Enabled
+    Metadata:
+      Comment: This bucket records access logs for the main bucket
+      checkov:
+        skip:
+          - comment: This is the log bucket
+            id: CKV_AWS_18
+      guard:
+        SuppressedRules:
+          - S3_BUCKET_LOGGING_ENABLED
+          - S3_BUCKET_REPLICATION_ENABLED
+
+  SiteCloudFrontLogsBucket:
+    Type: AWS::S3::Bucket
+    Properties:
+      BucketEncryption:
+        ServerSideEncryptionConfiguration:
+          - ServerSideEncryptionByDefault:
+              SSEAlgorithm: AES256
+      BucketName: !Sub ${AppName}-cflogs-${AWS::Region}-${AWS::AccountId}
+      LoggingConfiguration:
+        DestinationBucketName: !Ref SiteCloudFrontLogsLogBucket
+      ObjectLockEnabled: false
+      PublicAccessBlockConfiguration:
+        BlockPublicAcls: true
+        BlockPublicPolicy: true
+        IgnorePublicAcls: true
+        RestrictPublicBuckets: true
+      ReplicationConfiguration:
+        Role: !GetAtt SiteCloudFrontLogsReplicationRole.Arn
+        Rules:
+          - Destination:
+              Bucket: !GetAtt SiteCloudFrontLogsReplicaBucket.Arn
+            Status: Enabled
+      VersioningConfiguration:
+        Status: Enabled
+      OwnershipControls:
+        Rules:
+          - ObjectOwnership: BucketOwnerPreferred
+    Metadata:
+      guard:
+        SuppressedRules:
+          - S3_BUCKET_DEFAULT_LOCK_ENABLED
+
+  SiteCloudFrontLogsReplicaBucket:
+    Type: AWS::S3::Bucket
+    Properties:
+      BucketEncryption:
+        ServerSideEncryptionConfiguration:
+          - ServerSideEncryptionByDefault:
+              SSEAlgorithm: AES256
+      BucketName: !Sub ${AppName}-cflogs-replicas-${AWS::Region}-${AWS::AccountId}
+      ObjectLockEnabled: false
+      PublicAccessBlockConfiguration:
+        BlockPublicAcls: true
+        BlockPublicPolicy: true
+        IgnorePublicAcls: true
+        RestrictPublicBuckets: true
+      VersioningConfiguration:
+        Status: Enabled
+    Metadata:
+      Comment: This bucket is used as a target for replicas from the main bucket
+      checkov:
+        skip:
+          - comment: This is the replica bucket
+            id: CKV_AWS_18
+      guard:
+        SuppressedRules:
+          - S3_BUCKET_DEFAULT_LOCK_ENABLED
+          - S3_BUCKET_REPLICATION_ENABLED
+          - S3_BUCKET_LOGGING_ENABLED
+
+  SiteCloudFrontLogsReplicationPolicy:
+    Type: AWS::IAM::RolePolicy
+    Properties:
+      PolicyDocument:
+        Statement:
+          - Action:
+              - s3:GetReplicationConfiguration
+              - s3:ListBucket
+            Effect: Allow
+            Resource: !Sub arn:${AWS::Partition}:s3:::${AppName}-cflogs-${AWS::Region}-${AWS::AccountId}
+          - Action:
+              - s3:GetObjectVersionForReplication
+              - s3:GetObjectVersionAcl
+              - s3:GetObjectVersionTagging
+            Effect: Allow
+            Resource: !Sub arn:${AWS::Partition}:s3:::${AppName}-cflogs-${AWS::Region}-${AWS::AccountId}/*
+          - Action:
+              - s3:ReplicateObject
+              - s3:ReplicateDelete
+              - s3:ReplicationTags
+            Effect: Allow
+            Resource: !Sub arn:${AWS::Partition}:s3:::${AppName}-cflogs-replicas-${AWS::Region}-${AWS::AccountId}/*
+        Version: "2012-10-17"
+      PolicyName: bucket-replication-policy
+      RoleName: !Ref SiteCloudFrontLogsReplicationRole
+
+  SiteCloudFrontLogsReplicationRole:
+    Type: AWS::IAM::Role
+    Properties:
+      AssumeRolePolicyDocument:
+        Statement:
+          - Action:
+              - sts:AssumeRole
+            Effect: Allow
+            Principal:
+              Service:
+                - s3.amazonaws.com
+        Version: "2012-10-17"
+      Path: /
+
+  TestTable:
+    Type: AWS::DynamoDB::Table
+    Properties:
+      AttributeDefinitions:
+        - AttributeName: id
+          AttributeType: S
+      BillingMode: PAY_PER_REQUEST
+      KeySchema:
+        - AttributeName: id
+          KeyType: HASH
+      TableName: !Sub ${AppName}-test
+
+Outputs:
+  SiteURL:
+    Value: !Sub https://${SiteDistribution.DomainName}
+
+  RedirectURI:
+    Value: !Sub https://${SiteDistribution.DomainName}/index.html
+
+  AppName:
+    Value: !Ref AppName
+
+  RestApiInvokeURL:
+    Value: !Sub https://${RestApi}.execute-api.${AWS::Region}.amazonaws.com/${RestApiStage}
+
+  AppClientId:
+    Value: !Ref CognitoClient
+
+  CognitoDomainPrefix:
+    Value: !Ref AppName
+
diff --git a/test/webapp/webapp-todo.pkl b/test/webapp/webapp-todo.pkl
new file mode 100644
index 00000000..bbccf7e8
--- /dev/null
+++ b/test/webapp/webapp-todo.pkl
@@ -0,0 +1,119 @@
+
+  ["TestResource"] {
+    Type {
+      ["Rain::Module"] = "../modules/api-resource.yaml"
+    }
+    Metadata {
+      ["Comment"] = "This module handles all methods on the /test path on the API. The lambda function code is located in api/resources/test."
+    }
+    Properties {
+      ["Name"] {
+        ["Fn::Sub"] = "${AppName}-test"
+      }
+      ["RestApi"] {
+        ["Ref"] = "RestApi"
+      }
+      ["RestApiDeployment"] {
+        ["Ref"] = "RestApiDeployment"
+      }
+      ["BuildScript"] = "../webapp/buildapi.sh"
+      ["CodePath"] = "../webapp/api/dist/test/lambda-handler.zip"
+      ["ResourcePath"] = "test"
+      ["StageName"] = "staging"
+      ["AuthorizerId"] {
+        ["Ref"] = "RestApiAuthorizer"
+      }
+    }
+    Overrides {
+      ["Handler"] {
+        ["Properties"] {
+          ["Environment"] {
+            ["Variables"] {
+              ["TABLE_NAME"] {
+                ["Ref"] = "TestTable"
+              }
+            }
+          }
+        }
+      }
+    }
+  }
+
+  ["TestResourceHandlerPolicy"] = new rolepolicy.RolePolicy {
+    PolicyDocument {
+      Statement {
+        new {
+          Action {
+            "dynamodb:BatchGetItem"
+            "dynamodb:GetItem"
+            "dynamodb:Query"
+            "dynamodb:Scan"
+            "dynamodb:BatchWriteItem"
+            "dynamodb:PutItem"
+            "dynamodb:UpdateItem"
+          }
+          Effect = "Allow"
+          Resource {
+            cfn.GetAtt("TestTable", "Arn")
+          }
+        }
+      }
+    }
+    PolicyName = "handler-policy"
+    RoleName = cfn.Ref("TestResourceHandlerRole")
+
+  }
+
+
+  ["JwtResource"] {
+    Type {
+      ["Rain::Module"] = "../modules/api-resource.yaml"
+    }
+    Metadata {
+      ["Comment"] = "This module handles all methods on the /jwt path on the API. The lambda function code is located in api/resources/jwt"
+    }
+    Properties {
+      ["Name"] {
+        ["Fn::Sub"] = "${AppName}-jwt"
+      }
+      ["RestApi"] {
+        ["Ref"] = "RestApi"
+      }
+      ["RestApiDeployment"] {
+        ["Ref"] = "RestApiDeployment"
+      }
+      ["BuildScript"] = "../webapp/buildapi.sh"
+      ["CodePath"] = "../webapp/api/dist/jwt/lambda-handler.zip"
+      ["ResourcePath"] = "jwt"
+      ["StageName"] = "staging"
+      ["AuthorizerId"] = "AWS::NoValue"
+    }
+    Overrides {
+      ["Handler"] {
+        ["Properties"] {
+          ["Environment"] {
+            ["Variables"] {
+              ["COGNITO_REGION"] = "us-east-1"
+              ["COGNITO_POOL_ID"] {
+                ["Ref"] = "CognitoUserPool"
+              }
+              ["COGNITO_REDIRECT_URI"] {
+                ["Fn::Sub"] = "https://${SiteDistribution.DomainName}/index.html"
+              }
+              ["COGNITO_DOMAIN_PREFIX"] {
+                ["Ref"] = "AppName"
+              }
+              ["COGNITO_APP_CLIENT_ID"] {
+                ["Ref"] = "CognitoClient"
+              }
+            }
+          }
+        }
+      }
+      ["Get"] {
+        ["Properties"] {
+          ["AuthorizationType"] = "NONE"
+        }
+      }
+    }
+  }
diff --git a/test/webapp/webapp.pkl b/test/webapp/webapp.pkl
new file mode 100644
index 00000000..47a64ee5
--- /dev/null
+++ b/test/webapp/webapp.pkl
@@ -0,0 +1,262 @@
+amends "@cfn/template.pkl"
+import "./pkl/api-resource.pkl" as apiresource
+import "./pkl/cognito.pkl"
+import "./pkl/rest-api.pkl" as restapi
+import "./pkl/static-site.pkl" as staticsite
+import "@cfn/aws/iam/rolepolicy.pkl"
+import "@cfn/aws/dynamodb/table.pkl"
+import "@cfn/cloudformation.pkl" as cfn
+
+Description = "Creates a web application with a static website using S3 and CloudFront, an API Gateway REST API, and a DynamoDB table, with Cognito authentication. This sample uses Rain Metadata commands to upload content to the static  site bucket, and uses a Run property on the S3 directive to build the  lambda function, which for this example is written in Go. Apache-2.0 License. Adapt this template to your needs and thoruoughly test it before introducing it in a production environment. **WARNING** This template will create resources in your account that may incur billing charges. "
+
+Metadata {
+    ["cfn-lint"] {
+        ["config"] {
+            ["ignore_checks"] {
+                "W1020"
+            }
+        }
+    }
+}
+
+Parameters {
+  ["AppName"] {
+    Type = "String"
+    Description = "This name is used as a prefix for resource names"
+    Default = "rain-webapp-pkl"
+  }
+  ["LambdaCodeBucket"] {
+    Type = "String"
+    Default = "rain-artifacts-207567786752-us-east-1"
+  }
+  ["TestHandlerKey"] {
+    Type = "String"
+    Default = "db3706b9e9ec0046b308635fbc9ecdb3a4ad31e1069210e881e5442f546cc285" 
+  }
+  ["JwtHandlerKey"] {
+    Type = "String"
+    Default = "7b300d8fa211e93b14974d3e699a8c479470a1b31a6202176441cf11e5ad93f3" 
+  }
+}
+
+local staticSite = new staticsite {
+  AppName = "${AppName}"
+  LogicalId = "Site"
+}
+
+local cognitoAuth = new cognito {
+  AppName = "${AppName}"
+  LogicalId = "Cognito"
+  CallbackURL = "https://${SiteDistribution.DomainName}/index.html"
+}
+
+local restApi = new restapi {
+  AppName = "${AppName}"
+  UserPoolArn = "${CognitoUserPool.Arn}"
+  StageName = "prod"
+  LogicalId = "Rest"
+}
+
+local testResource = new apiresource {
+  Name = "${AppName}-test"
+  LogicalId = "Test"
+  RestApi = "${RestApi}"
+  RootResourceId = "${RestApi.RootResourceId}"
+  RestApiDeployment = "${RestApiDeployment}"
+  ResourcePath = "test"
+  AuthorizerId = "${RestApiAuthorizer}"
+  HandlerBucket = "${LambdaCodeBucket}" 
+  HandlerKey = "${TestHandlerKey}"
+}
+
+local jwtResource = new apiresource {
+  Name = "${AppName}-jwt"
+  LogicalId = "Jwt"
+  RestApi = "${RestApi}"
+  RootResourceId = "${RestApi.RootResourceId}"
+  RestApiDeployment = "${RestApiDeployment}"
+  ResourcePath = "jwt"
+  AuthorizerId = "AWS::NoValue"
+  HandlerBucket = "${LambdaCodeBucket}" 
+  HandlerKey = "${JwtHandlerKey}"
+}
+
+Resources {
+
+  for (logicalId, resource in testResource.Resources) {
+    when (logicalId == "TestHandler") {
+      [logicalId] = (resource) {
+        Properties {
+          ["Environment"] {
+            ["Variables"] {
+              ["TABLE_NAME"] {
+                ["Ref"] = "TestTable"
+              }
+            }
+          }
+        }
+      }
+    } else {
+      [logicalId] = resource
+    }
+  }
+
+  for (logicalId, resource in jwtResource.Resources) {
+    when (logicalId == "JwtHandler") {
+      [logicalId] = (resource) {
+        Properties {
+          ["Environment"] {
+            ["Variables"] {
+              ["COGNITO_REGION"] = "us-east-1"
+              ["COGNITO_POOL_ID"] {
+                ["Ref"] = "CognitoUserPool"
+              }
+              ["COGNITO_REDIRECT_URI"] {
+                ["Fn::Sub"] = "https://${SiteDistribution.DomainName}/index.html"
+              }
+              ["COGNITO_DOMAIN_PREFIX"] {
+                ["Ref"] = "AppName"
+              }
+              ["COGNITO_APP_CLIENT_ID"] {
+                ["Ref"] = "CognitoClient"
+              }
+            }
+          }
+        }
+      }
+    }
+    when (logicalId == "JwtGet") {
+      [logicalId] = (resource) {
+        Properties {
+          ["AuthorizationType"] = "NONE"
+        }
+      }
+    } 
+    when (logicalId != "JwtHandler" && logicalId != "JwtGet") {
+      [logicalId] = resource
+    }
+  }
+
+  ["TestResourceHandlerPolicy"] = new rolepolicy.RolePolicy {
+    PolicyDocument {
+      Statement {
+        new {
+          Action {
+            "dynamodb:BatchGetItem"
+            "dynamodb:GetItem"
+            "dynamodb:Query"
+            "dynamodb:Scan"
+            "dynamodb:BatchWriteItem"
+            "dynamodb:PutItem"
+            "dynamodb:UpdateItem"
+          }
+          Effect = "Allow"
+          Resource {
+            cfn.GetAtt("TestTable", "Arn")
+          }
+        }
+      }
+    }
+    PolicyName = "handler-policy"
+    RoleName = cfn.Ref("TestHandlerRole")
+  }
+
+  for (logicalId, resource in restApi.Resources) {
+    when (logicalId == "RestApiDeployment") {
+      [logicalId] = (resource) {
+        Metadata {
+          ["Version"] = 2
+        }
+        DependsOn {
+          "TestGet"
+          "TestOptions"
+          "JwtGet"
+          "JwtOptions"
+        }
+      }
+    } else {
+      [logicalId] = resource
+    }
+  }
+
+  for (logicalId, resource in cognitoAuth.Resources) {
+    when (logicalId == "CognitoUserPool") {
+      [logicalId] = (resource) {
+        DependsOn = new {
+          "SiteDistribution"
+        }
+      }
+    } else {
+      [logicalId] = resource
+    }
+  }
+
+  // Add resources from the static site module and override the content bucket to add Rain metadata
+  for (logicalId, resource in staticSite.Resources) {
+    when (logicalId == "SiteContentBucket") {
+      [logicalId] = (resource) {
+        Metadata {
+          ["Rain"] {
+            ["EmptyOnDelete"] = true
+            ["Content"] = "site/dist"
+            ["Version"] = 2
+            ["DistributionLogicalId"] = "SiteDistribution"
+            ["RunBefore"] {
+              ["Command"] = "buildsite.sh"
+            }
+            ["RunAfter"] {
+              ["Command"] = "buildsite.sh"
+              ["Args"] {
+                "Rain::OutputValue RestApiInvokeURL"
+                "Rain::OutputValue RedirectURI"
+                "Rain::OutputValue AppName"
+                "Rain::OutputValue AppClientId"
+              }
+            }
+          }
+        }
+      }
+    } else {
+      [logicalId] = resource
+    }
+  }
+
+  ["TestTable"] = new table.Table {
+    BillingMode = "PAY_PER_REQUEST"
+    TableName = cfn.Sub("${AppName}-test")
+
+    AttributeDefinitions {
+      new {
+        AttributeName = "id"
+        AttributeType = "S"
+      }
+    }
+    KeySchema {
+      new {
+        AttributeName = "id"
+        KeyType = "HASH"
+      }
+    }
+  }
+}
+
+Outputs {
+  ["SiteURL"] = new cfn.Output {
+    Value = cfn.Sub("https://${SiteDistribution.DomainName}")
+  }
+  ["RedirectURI"] = new cfn.Output {
+    Value = cfn.Sub("https://${SiteDistribution.DomainName}/index.html")
+  }
+  ["AppName"] = new cfn.Output {
+    Value = cfn.Ref("AppName")
+  }
+  ["RestApiInvokeURL"] = new cfn.Output {
+    Value = cfn.Sub("https://${RestApi}.execute-api.${AWS::Region}.amazonaws.com/${RestApiStage}")
+  }
+  ["AppClientId"] = new cfn.Output {
+    Value = cfn.Ref("CognitoClient")
+  }
+  ["CognitoDomainPrefix"] = new cfn.Output {
+    Value = cfn.Ref("AppName")
+  }
+}