diff --git a/examples/pom.xml b/examples/pom.xml index cca621163..c9b8ea8ae 100644 --- a/examples/pom.xml +++ b/examples/pom.xml @@ -21,6 +21,7 @@ powertools-examples-serialization powertools-examples-sqs powertools-examples-validation + powertools-examples-cloudformation diff --git a/examples/powertools-examples-cloudformation/README.md b/examples/powertools-examples-cloudformation/README.md new file mode 100644 index 000000000..6dbffcf37 --- /dev/null +++ b/examples/powertools-examples-cloudformation/README.md @@ -0,0 +1,40 @@ +# Cloudformation Custom Resource Example + +This project contains an example of Lambda function using the CloudFormation module of Powertools for AWS Lambda in Java. For more information on this module, please refer to the [documentation](https://awslabs.github.io/aws-lambda-powertools-java/utilities/custom_resources/). + +## Deploy the sample application + +This sample can be used either with the Serverless Application Model (SAM) or with CDK. + +### Deploy with SAM CLI +To use the SAM CLI, you need the following tools. + +* SAM CLI - [Install the SAM CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-install.html) +* Java 8 - [Install Java 8](https://docs.aws.amazon.com/corretto/latest/corretto-8-ug/downloads-list.html) +* Maven - [Install Maven](https://maven.apache.org/install.html) +* Docker - [Install Docker community edition](https://hub.docker.com/search/?type=edition&offering=community) + +To build and deploy this application for the first time, run the following in your shell: + +```bash +cd infra/sam +sam build +sam deploy --guided --parameter-overrides BucketNameParam=my-unique-bucket-20230717 +``` + +### Deploy with CDK +To use CDK you need the following tools. + +* CDK - [Install CDK](https://docs.aws.amazon.com/cdk/v2/guide/getting_started.html) +* Java 8 - [Install Java 8](https://docs.aws.amazon.com/corretto/latest/corretto-8-ug/downloads-list.html) +* Maven - [Install Maven](https://maven.apache.org/install.html) +* Docker - [Install Docker community edition](https://hub.docker.com/search/?type=edition&offering=community) + +To build and deploy this application for the first time, run the following in your shell: + +```bash +cd infra/cdk +mvn package +cdk synth +cdk deploy -c BucketNameParam=my-unique-bucket-20230718 +``` \ No newline at end of file diff --git a/examples/powertools-examples-cloudformation/infra/cdk/.gitignore b/examples/powertools-examples-cloudformation/infra/cdk/.gitignore new file mode 100644 index 000000000..1db21f162 --- /dev/null +++ b/examples/powertools-examples-cloudformation/infra/cdk/.gitignore @@ -0,0 +1,13 @@ +.classpath.txt +target +.classpath +.project +.idea +.settings +.vscode +*.iml + +# CDK asset staging directory +.cdk.staging +cdk.out + diff --git a/examples/powertools-examples-cloudformation/infra/cdk/cdk.json b/examples/powertools-examples-cloudformation/infra/cdk/cdk.json new file mode 100644 index 000000000..fe011b328 --- /dev/null +++ b/examples/powertools-examples-cloudformation/infra/cdk/cdk.json @@ -0,0 +1,37 @@ +{ + "app": "mvn -e -q compile exec:java", + "watch": { + "include": [ + "**" + ], + "exclude": [ + "README.md", + "cdk*.json", + "target", + "pom.xml", + "src/test" + ] + }, + "context": { + "@aws-cdk/aws-lambda:recognizeLayerVersion": true, + "@aws-cdk/core:checkSecretUsage": true, + "@aws-cdk/core:target-partitions": [ + "aws", + "aws-cn" + ], + "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true, + "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true, + "@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true, + "@aws-cdk/aws-iam:minimizePolicies": true, + "@aws-cdk/core:validateSnapshotRemovalPolicy": true, + "@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true, + "@aws-cdk/aws-s3:createDefaultLoggingPolicy": true, + "@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true, + "@aws-cdk/aws-apigateway:disableCloudWatchRole": true, + "@aws-cdk/core:enablePartitionLiterals": true, + "@aws-cdk/aws-events:eventsTargetQueueSameAccount": true, + "@aws-cdk/aws-iam:standardizedServicePrincipals": true, + "@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": true, + "@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true + } +} diff --git a/examples/powertools-examples-cloudformation/infra/cdk/pom.xml b/examples/powertools-examples-cloudformation/infra/cdk/pom.xml new file mode 100644 index 000000000..30172fa3f --- /dev/null +++ b/examples/powertools-examples-cloudformation/infra/cdk/pom.xml @@ -0,0 +1,53 @@ + + + 4.0.0 + + com.myorg + powertools-examples-cloudformation-cdk + 0.1 + + + UTF-8 + 2.59.0 + [10.0.0,11.0.0) + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + 8 + 8 + + + + + org.codehaus.mojo + exec-maven-plugin + 3.0.0 + + com.myorg.PowertoolsExamplesCloudformationCdkApp + + + + + + + + + software.amazon.awscdk + aws-cdk-lib + ${cdk.version} + + + + software.constructs + constructs + ${constructs.version} + + + diff --git a/examples/powertools-examples-cloudformation/infra/cdk/src/main/java/com/myorg/PowertoolsExamplesCloudformationCdkApp.java b/examples/powertools-examples-cloudformation/infra/cdk/src/main/java/com/myorg/PowertoolsExamplesCloudformationCdkApp.java new file mode 100644 index 000000000..84060171b --- /dev/null +++ b/examples/powertools-examples-cloudformation/infra/cdk/src/main/java/com/myorg/PowertoolsExamplesCloudformationCdkApp.java @@ -0,0 +1,16 @@ +package com.myorg; + +import software.amazon.awscdk.App; +import software.amazon.awscdk.StackProps; + +public class PowertoolsExamplesCloudformationCdkApp { + public static void main(final String[] args) { + App app = new App(); + + new PowertoolsExamplesCloudformationCdkStack(app, "PowertoolsExamplesCloudformationCdkStack", StackProps.builder() + .build()); + + app.synth(); + } +} + diff --git a/examples/powertools-examples-cloudformation/infra/cdk/src/main/java/com/myorg/PowertoolsExamplesCloudformationCdkStack.java b/examples/powertools-examples-cloudformation/infra/cdk/src/main/java/com/myorg/PowertoolsExamplesCloudformationCdkStack.java new file mode 100644 index 000000000..e880a3534 --- /dev/null +++ b/examples/powertools-examples-cloudformation/infra/cdk/src/main/java/com/myorg/PowertoolsExamplesCloudformationCdkStack.java @@ -0,0 +1,89 @@ +package com.myorg; + +import software.amazon.awscdk.Stack; +import software.amazon.awscdk.*; +import software.amazon.awscdk.services.iam.Effect; +import software.amazon.awscdk.services.iam.PolicyStatement; +import software.amazon.awscdk.services.iam.PolicyStatementProps; +import software.amazon.awscdk.services.lambda.Code; +import software.amazon.awscdk.services.lambda.Function; +import software.amazon.awscdk.services.lambda.FunctionProps; +import software.amazon.awscdk.services.lambda.Runtime; +import software.amazon.awscdk.services.s3.assets.AssetOptions; +import software.constructs.Construct; + +import java.io.Serializable; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + + +import static java.util.Collections.singletonList; +import static software.amazon.awscdk.BundlingOutput.NOT_ARCHIVED; + +public class PowertoolsExamplesCloudformationCdkStack extends Stack { + + public static final String SAMPLE_BUCKET_NAME = "sample-bucket-name-20230315-abc123"; + + public PowertoolsExamplesCloudformationCdkStack(final Construct scope, final String id) { + this(scope, id, null); + } + + public PowertoolsExamplesCloudformationCdkStack(final Construct scope, final String id, final StackProps props) { + super(scope, id, props); + + + List functionPackagingInstructions = Arrays.asList( + "/bin/sh", + "-c", + "mvn clean install" + + "&& mkdir /asset-output/lib" + + "&& cp target/powertools-examples-cloudformation-*.jar /asset-output/lib" + ); + BundlingOptions bundlingOptions = BundlingOptions.builder() + .command(functionPackagingInstructions) + .image(Runtime.JAVA_11.getBundlingImage()) + .volumes(singletonList( + // Mount local .m2 repo to avoid download all the dependencies again inside the container + DockerVolume.builder() + .hostPath(System.getProperty("user.home") + "/.m2/") + .containerPath("/root/.m2/") + .build() + )) + .user("root") + .outputType(NOT_ARCHIVED) + .build(); + + Function helloWorldFunction = new Function(this, "HelloWorldFunction", FunctionProps.builder() + .runtime(Runtime.JAVA_11) + .code(Code.fromAsset("../../", AssetOptions.builder().bundling(bundlingOptions) + .build())) + .handler("helloworld.App::handleRequest") + .memorySize(512) + .timeout(Duration.seconds(20)) + .environment(Collections + .singletonMap("JAVA_TOOL_OPTIONS", "-XX:+TieredCompilation -XX:TieredStopAtLevel=1")) + .build()); + helloWorldFunction.addToRolePolicy(new PolicyStatement(PolicyStatementProps.builder() + .effect(Effect.ALLOW) + .actions(Arrays.asList("s3:GetLifecycleConfiguration", + "s3:PutLifecycleConfiguration", + "s3:CreateBucket", + "s3:ListBucket", + "s3:DeleteBucket")) + .resources(singletonList("*")).build())); + + String bucketName = (String) this.getNode().tryGetContext("BucketNameParam"); + + Map crProperties = new HashMap<>(); + crProperties.put("BucketName", bucketName); + CustomResource.Builder + .create(this, "HelloWorldCustomResource") + .serviceToken(helloWorldFunction.getFunctionArn()) + .properties(crProperties) + .build(); + + } +} diff --git a/examples/powertools-examples-cloudformation/infra/sam/events/create_event.json b/examples/powertools-examples-cloudformation/infra/sam/events/create_event.json new file mode 100644 index 000000000..26ef0a03b --- /dev/null +++ b/examples/powertools-examples-cloudformation/infra/sam/events/create_event.json @@ -0,0 +1,12 @@ +{ + "RequestType": "Create", + "ResponseURL": "http://pre-signed-S3-url-for-response", + "StackId": "arn:aws:cloudformation:us-east-1:123456789012:stack/MyStack/guid", + "RequestId": "unique id for this create request", + "ResourceType": "Custom::TestResource", + "ResourceProperties": { + "BucketName": "test-bucket-20230307-1", + "RetentionDays" : 10, + "StackName": "MyStack" + } +} diff --git a/examples/powertools-examples-cloudformation/infra/sam/events/delete_event.json b/examples/powertools-examples-cloudformation/infra/sam/events/delete_event.json new file mode 100644 index 000000000..d18fdd3e4 --- /dev/null +++ b/examples/powertools-examples-cloudformation/infra/sam/events/delete_event.json @@ -0,0 +1,14 @@ +{ + "RequestType": "Delete", + "ResponseURL": "http://pre-signed-S3-url-for-response", + "StackId": "arn:aws:cloudformation:us-east-1:123456789012:stack/MyStack/guid", + "RequestId": "unique id for this create request", + "ResourceType": "Custom::TestResource", + "LogicalResourceId": "MyTestResource", + "PhysicalResourceId": "test-bucket-20230307-1", + "ResourceProperties": { + "BucketName": "test-bucket-20230307-1", + "RetentionDays" : 10, + "StackName": "MyStack" + } +} diff --git a/examples/powertools-examples-cloudformation/infra/sam/events/update_event.json b/examples/powertools-examples-cloudformation/infra/sam/events/update_event.json new file mode 100644 index 000000000..5a5ae2e3f --- /dev/null +++ b/examples/powertools-examples-cloudformation/infra/sam/events/update_event.json @@ -0,0 +1,14 @@ +{ + "RequestType": "Update", + "ResponseURL": "http://pre-signed-S3-url-for-response", + "StackId": "arn:aws:cloudformation:us-east-1:123456789012:stack/MyStack/guid", + "RequestId": "unique id for this create request", + "ResourceType": "Custom::TestResource", + "LogicalResourceId": "MyTestResource", + "PhysicalResourceId": "test-bucket-20230307-1", + "ResourceProperties": { + "BucketName": "test-bucket-20230307-1", + "RetentionDays" : 100, + "StackName": "MyStack" + } +} diff --git a/examples/powertools-examples-cloudformation/infra/sam/template.yaml b/examples/powertools-examples-cloudformation/infra/sam/template.yaml new file mode 100644 index 000000000..a7ce4adf1 --- /dev/null +++ b/examples/powertools-examples-cloudformation/infra/sam/template.yaml @@ -0,0 +1,50 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: > + powertools-examples-cloudformation + + Sample SAM Template for powertools-examples-cloudformation + +Globals: + Function: + Timeout: 20 + +Parameters: + BucketNameParam: + Type: String + +Resources: + HelloWorldCustomResource: + Type: AWS::CloudFormation::CustomResource + Properties: + ServiceToken: !GetAtt HelloWorldFunction.Arn + BucketName: !Ref BucketNameParam + + HelloWorldFunction: + Type: AWS::Serverless::Function + Properties: + CodeUri: ../../ + Handler: helloworld.App::handleRequest + Runtime: java11 + Architectures: + - x86_64 + MemorySize: 512 + Policies: + - Statement: + - Sid: bucketaccess1 + Effect: Allow + Action: + - s3:GetLifecycleConfiguration + - s3:PutLifecycleConfiguration + - s3:CreateBucket + - s3:ListBucket + - s3:DeleteBucket + Resource: '*' + Environment: + Variables: + JAVA_TOOL_OPTIONS: -XX:+TieredCompilation -XX:TieredStopAtLevel=1 + +Outputs: + HelloWorldFunction: + Description: "Hello World Lambda Function ARN" + Value: !GetAtt HelloWorldFunction.Arn diff --git a/examples/powertools-examples-cloudformation/pom.xml b/examples/powertools-examples-cloudformation/pom.xml new file mode 100644 index 000000000..198a85894 --- /dev/null +++ b/examples/powertools-examples-cloudformation/pom.xml @@ -0,0 +1,219 @@ + + 4.0.0 + + software.amazon.lambda.examples + 1.17.0-SNAPSHOT + powertools-examples-cloudformation + jar + + AWS Lambda Powertools for Java library Examples - CloudFormation + + + 2.20.0 + 1.8 + 1.8 + true + 1.2.2 + 3.11.2 + 2.20.102 + + + + + software.amazon.awssdk + bom + ${aws.sdk.version} + pom + import + + + + + + + com.amazonaws + aws-lambda-java-core + ${lambda.core.version} + + + com.amazonaws + aws-lambda-java-events + ${lambda.events.version} + + + software.amazon.lambda + powertools-cloudformation + ${project.version} + + + software.amazon.lambda + powertools-logging + ${project.version} + + + org.apache.logging.log4j + log4j-core + ${log4j.version} + + + org.apache.logging.log4j + log4j-api + ${log4j.version} + + + software.amazon.awssdk + s3 + + + software.amazon.awssdk + netty-nio-client + + + software.amazon.awssdk + apache-client + + + + + software.amazon.awssdk + apache-client + + + commons-logging + commons-logging + + + + + org.apache.logging.log4j + log4j-jcl + ${log4j.version} + + + + + + + + + + dev.aspectj + aspectj-maven-plugin + 1.13.1 + + ${maven.compiler.source} + ${maven.compiler.target} + ${maven.compiler.target} + + + software.amazon.lambda + powertools-logging + + + + + + + compile + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.5.0 + + + package + + shade + + + + + + + + + + + + com.github.edwgiz + maven-shade-plugin.log4j2-cachefile-transformer + 2.15 + + + + + + + + + jdk8 + + (,11) + + + 1.9.7 + + + + + org.aspectj + aspectjtools + ${aspectj.version} + + + + + + + + dev.aspectj + aspectj-maven-plugin + ${aspectj.plugin.version} + + ${maven.compiler.source} + ${maven.compiler.target} + ${maven.compiler.target} + + + software.amazon.lambda + powertools-logging + + + + + + + compile + test-compile + + + + + + + org.aspectj + aspectjtools + ${aspectj.version} + + + + + + + + + diff --git a/examples/powertools-examples-cloudformation/src/main/java/helloworld/App.java b/examples/powertools-examples-cloudformation/src/main/java/helloworld/App.java new file mode 100644 index 000000000..c7744cd5a --- /dev/null +++ b/examples/powertools-examples-cloudformation/src/main/java/helloworld/App.java @@ -0,0 +1,164 @@ +package helloworld; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.events.CloudFormationCustomResourceEvent; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.core.exception.SdkClientException; +import software.amazon.awssdk.core.waiters.WaiterResponse; +import software.amazon.awssdk.http.apache.ApacheHttpClient; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.*; +import software.amazon.awssdk.services.s3.waiters.S3Waiter; +import software.amazon.lambda.powertools.cloudformation.AbstractCustomResourceHandler; +import software.amazon.lambda.powertools.cloudformation.Response; + +import java.util.Objects; + +/** + * Handler for requests to Lambda function. + */ + +public class App extends AbstractCustomResourceHandler { + private final static Logger log = LogManager.getLogger(App.class); + private final S3Client s3Client; + + public App() { + super(); + s3Client = S3Client.builder().httpClientBuilder(ApacheHttpClient.builder()).build(); + } + + /** + * This method is invoked when CloudFormation Creates the Custom Resource. + * In this example, the method creates an Amazon S3 Bucket with the provided `BucketName` + * + * @param cloudFormationCustomResourceEvent Create Event from CloudFormation + * @param context Lambda Context + * @return Response to send to CloudFormation + */ + @Override + protected Response create(CloudFormationCustomResourceEvent cloudFormationCustomResourceEvent, Context context) { + // Validate the CloudFormation Custom Resource event + Objects.requireNonNull(cloudFormationCustomResourceEvent, "cloudFormationCustomResourceEvent cannot be null."); + Objects.requireNonNull(cloudFormationCustomResourceEvent.getResourceProperties().get("BucketName"), "BucketName cannot be null."); + + log.info(cloudFormationCustomResourceEvent); + String bucketName = (String) cloudFormationCustomResourceEvent.getResourceProperties().get("BucketName"); + log.info("Bucket Name {}", bucketName); + try { + // Create the S3 bucket with the given bucketName + createBucket(bucketName); + // Return a successful response with the bucketName as the physicalResourceId + return Response.success(bucketName); + } catch (AwsServiceException | SdkClientException e) { + // In case of error, return a failed response, with the bucketName as the physicalResourceId + log.error(e); + return Response.failed(bucketName); + } + } + + /** + * This method is invoked when CloudFormation Updates the Custom Resource. + * In this example, the method creates an Amazon S3 Bucket with the provided `BucketName`, if the `BucketName` differs from the previous `BucketName` (for initial creation) + * + * @param cloudFormationCustomResourceEvent Update Event from CloudFormation + * @param context Lambda Context + * @return Response to send to CloudFormation + */ + @Override + protected Response update(CloudFormationCustomResourceEvent cloudFormationCustomResourceEvent, Context context) { + // Validate the CloudFormation Custom Resource event + Objects.requireNonNull(cloudFormationCustomResourceEvent, "cloudFormationCustomResourceEvent cannot be null."); + Objects.requireNonNull(cloudFormationCustomResourceEvent.getResourceProperties().get("BucketName"), "BucketName cannot be null."); + + log.info(cloudFormationCustomResourceEvent); + // Get the physicalResourceId. physicalResourceId is the value returned to CloudFormation in the Create request, and passed in on subsequent requests (e.g. UPDATE or DELETE) + String physicalResourceId = cloudFormationCustomResourceEvent.getPhysicalResourceId(); + log.info("Physical Resource ID {}", physicalResourceId); + + // Get the BucketName from the CloudFormation Event + String newBucketName = (String) cloudFormationCustomResourceEvent.getResourceProperties().get("BucketName"); + + // Check if the physicalResourceId equals the new BucketName + if (!physicalResourceId.equals(newBucketName)) { + // The bucket name has changed - create a new bucket + try { + // Create a new bucket with the newBucketName + createBucket(newBucketName); + // Return a successful response with the newBucketName + return Response.success(newBucketName); + } catch (AwsServiceException | SdkClientException e) { + log.error(e); + return Response.failed(newBucketName); + } + } else { + // Bucket name has not changed, and no changes are needed. + // Return a successful response with the previous physicalResourceId + return Response.success(physicalResourceId); + } + } + + /** + * This method is invoked when CloudFormation Deletes the Custom Resource. + * NOTE: CloudFormation will DELETE a resource, if during the UPDATE a new physicalResourceId is returned. + * Refer to the Powertools Java Documentation for more details. + * + * @param cloudFormationCustomResourceEvent Delete Event from CloudFormation + * @param context Lambda Context + * @return Response to send to CloudFormation + */ + @Override + protected Response delete(CloudFormationCustomResourceEvent cloudFormationCustomResourceEvent, Context context) { + // Validate the CloudFormation Custom Resource event + Objects.requireNonNull(cloudFormationCustomResourceEvent, "cloudFormationCustomResourceEvent cannot be null."); + Objects.requireNonNull(cloudFormationCustomResourceEvent.getPhysicalResourceId(), "PhysicalResourceId cannot be null."); + + log.info(cloudFormationCustomResourceEvent); + // Get the physicalResourceId. physicalResourceId is the value provided to CloudFormation in the Create request. + String bucketName = cloudFormationCustomResourceEvent.getPhysicalResourceId(); + log.info("Bucket Name {}", bucketName); + + // Check if a bucket with bucketName exists + if (bucketExists(bucketName)) { + try { + // If it exists, delete the bucket + s3Client.deleteBucket(DeleteBucketRequest.builder().bucket(bucketName).build()); + log.info("Bucket Deleted {}", bucketName); + // Return a successful response with bucketName as the physicalResourceId + return Response.success(bucketName); + } catch (AwsServiceException | SdkClientException e) { + // Return a failed response in case of errors during the bucket deletion + log.error(e); + return Response.failed(bucketName); + } + } else { + // If the bucket does not exist, return a successful response with the bucketName as the physicalResourceId + log.info("Bucket already deleted - no action"); + return Response.success(bucketName); + } + + } + + private boolean bucketExists(String bucketName) { + try { + HeadBucketResponse headBucketResponse = s3Client.headBucket(HeadBucketRequest.builder().bucket(bucketName).build()); + if (headBucketResponse.sdkHttpResponse().isSuccessful()) { + return true; + } + } catch (NoSuchBucketException e) { + log.info("Bucket does not exist"); + return false; + } + return false; + } + + private void createBucket(String bucketName) { + S3Waiter waiter = s3Client.waiter(); + CreateBucketRequest createBucketRequest = CreateBucketRequest.builder().bucket(bucketName).build(); + s3Client.createBucket(createBucketRequest); + WaiterResponse waiterResponse = waiter.waitUntilBucketExists(HeadBucketRequest.builder().bucket(bucketName).build()); + waiterResponse.matched().response().ifPresent(log::info); + log.info("Bucket Created {}", bucketName); + } +} \ No newline at end of file diff --git a/examples/powertools-examples-cloudformation/src/main/resources/log4j2.xml b/examples/powertools-examples-cloudformation/src/main/resources/log4j2.xml new file mode 100644 index 000000000..8e8162128 --- /dev/null +++ b/examples/powertools-examples-cloudformation/src/main/resources/log4j2.xml @@ -0,0 +1,17 @@ + + + + + + %d{dd MMM yyyy HH:mm:ss,SSS} [%p] <%X{AWSRequestId}> (%t) %c:%L: %m%n + + + + + + + + + + + \ No newline at end of file