From 70fd025eb23ead40be7db4b9471d922f80e75107 Mon Sep 17 00:00:00 2001 From: Pedro Carreira Date: Tue, 11 Apr 2023 11:25:50 +0100 Subject: [PATCH] OS-1795 - add build scripts and CFN templates polish --- .gitignore | 5 + README.md | 438 ++++++- applications/README.md | 21 +- applications/package.json | 2 +- .../src/create-sim/create-sim.spec.ts | 161 +-- applications/src/create-sim/create-sim.ts | 53 +- .../device-onboarding.spec.ts | 10 - .../src/disable-sim/disable-sim.spec.ts | 130 ++ applications/src/disable-sim/disable-sim.ts | 32 + .../services/errorHandlingService.spec.ts | 35 +- .../shared/services/errorHandlingService.ts | 4 +- .../shared/services/iotCoreService.spec.ts | 9 - .../services/managementApiService.spec.ts | 6 +- .../src/shared/services/simService.spec.ts | 214 +++- .../src/shared/services/simService.ts | 40 +- .../services/successMessageService.spec.ts | 17 +- .../shared/services/successMessageService.ts | 18 +- applications/src/shared/test/sqs.fixture.ts | 24 + applications/src/shared/types/sim.spec.ts | 45 +- applications/src/shared/types/sim.ts | 8 +- .../src/shared/utils/dynamoHelper.spec.ts | 23 +- applications/src/shared/utils/dynamoHelper.ts | 9 + .../src/shared/utils/snsHelper.spec.ts | 2 - .../src/shared/utils/sqsHelper.spec.ts | 43 +- applications/src/shared/utils/sqsHelper.ts | 32 + .../src/sim-retrieval/sim-retrieval.spec.ts | 159 ++- .../src/sim-retrieval/sim-retrieval.ts | 25 +- applications/webpack.config.js | 2 + deploymentValues.yaml | 15 + resources/architecture_diagram.png | Bin 0 -> 118514 bytes scripts/build.sh | 101 ++ scripts/ec2-user-data.bash | 17 +- scripts/publish.sh | 43 + templates/api-gateway.yaml | 137 ++- templates/autoscaling.yaml | 108 +- templates/create-sim.yaml | 100 +- templates/device-onboarding-main.yaml | 1046 ++++++++++------- templates/device-onboarding.yaml | 93 +- templates/disable-sim.yaml | 137 +++ templates/iot-core-endpoint-provider.yaml | 30 +- templates/iot-core-policy.yaml | 146 ++- templates/lambda-invoke.yaml | 31 +- templates/network.yaml | 172 +-- templates/s3-lambda-code.yaml | 99 +- templates/secrets-manager.yaml | 29 +- templates/sim-retrieval.yaml | 180 +-- templates/sim-table.yaml | 63 +- templates/sns.yaml | 58 +- templates/sqs.yaml | 78 +- templates/ssm.yaml | 74 +- 50 files changed, 3005 insertions(+), 1319 deletions(-) create mode 100644 applications/src/disable-sim/disable-sim.spec.ts create mode 100644 applications/src/disable-sim/disable-sim.ts create mode 100644 applications/src/shared/test/sqs.fixture.ts create mode 100644 deploymentValues.yaml create mode 100644 resources/architecture_diagram.png create mode 100644 scripts/build.sh mode change 100644 => 100755 scripts/ec2-user-data.bash create mode 100644 scripts/publish.sh create mode 100644 templates/disable-sim.yaml diff --git a/.gitignore b/.gitignore index d024785..81b5521 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ node_modules jspm_packages +# Serverless directories +.serverless + # IDE /.vscode .idea/ @@ -22,3 +25,5 @@ applications/junit.xml # OS X files .DS_Store + +build/ diff --git a/README.md b/README.md index d3f18d9..1b026cd 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,437 @@ -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) -![version](https://img.shields.io/badge/version-1.0.0-blue) +The Open Source project has one clear but distinctive focus - Enabling AWS customers to automatically onboard their IoT Devices into the AWS IoT Core (device-onboarding-as-a-Service) following a self-managed approach. Customers with the "1NCE Connect" product can map their IoT devices via SIM cards to certificates for the AWS IoT Core. The certificates allow publishing, subscription, and connection to AWS IoT Core MQTT broker. +Certificates and other credentials for MQTT subscription can be retrieved from HTTP GET endpoint by using the SIM-as-an-Identity service. Service allows using a single endpoint for all devices. Individual certificates are returned to each device depending on the IP address, that is being used in the 1NCE network. -# 1NCE IoT Device Onboarding +


+## Quick start + +#### Prerequisites +At least one 1NCE SIM card and access to the 1NCE.com portal. Access to an AWS account and the possibility to rollout a CFN stack is required. + +#### Step-by-step process + +1. Log in to your AWS account +2. Choose a Region with IoT Core service support +3. Go to Cloudformation > Stacks +4. Select Create Stack "With new resources" +5. As Amazon S3 URL use "https://device-onboarding-prod-cloudformation-templates.s3.eu-central-1.amazonaws.com/latest/device-onboarding-main.yaml" +6. Fill in stack name and [parameters](#input-parameters) +7. When the stack is rolled out, go to Systems Manager > Parameter store and get value from "openvpn-onboarding-proxy-server". This value is the onboarding endpoint URL. +8. Call the onboarding endpoint URL with a HTTP GET request and receive your device onboarding credentials. The request should be sent via 1NCE mobile network. +9. Use the Credentials to connect to the AWS IoT Core MQTT broker. + +### Input Parameters + +##### ManagementApiUsername, ManagementApiPassword +[1NCE portal](https://portal.1nce.com/portal/customer/users?) API User credentials. + +A user with an API role should be created. Credentials will be used to retrieve all customers' 1NCE SIM cards via [Get ALL SIMs](https://help.1nce.com/dev-hub/reference/getsimsusingget) Endpoint. + +##### OpenvpnOnboardingUsername, OpenvpnOnboardingPassword +[1NCE OpenVPN credentials](https://portal.1nce.com/portal/customer/configuration/credentials) can be downloaded: +1NCE portal > Configuration > OpenVPN Configuration > Download credentials.txt + +OpenvpnOnboardingUsername is the numerical value in the first line of the file. E.g. 12345
+OpenvpnOnboardingPassword is the token placed in the second line of the file. + + +##### LambdaCron +Crontab determines when CloudWatch Events runs the rule that triggers the [SIM Retrieval Lambda](#sim-retrieval-lambda) + +[Documentation](https://docs.aws.amazon.com/AmazonCloudWatch/latest/events/ScheduledEvents.html#CronExpressions) + +##### APIGatewayStageName: +Stage name of API Gateway deployment. + +Example: `https://fka8ojq6lh.execute-api.us-west-1.amazonaws.com/APIGatewayStageName/SimOnboardingPath` +##### SimOnboardingPath: +REST API Path for sim onboarding endpoint. + +Example: `https://fka8ojq6lh.execute-api.us-west-1.amazonaws.com/APIGatewayStageName/SimOnboardingPath` +##### BreakoutRegion: +Breakout region configured in the 1NCE Portal > Configuration > Breakout settings + +Default: eu-central-1 +


+# Low-level docs + +## Architecture diagram: +![Diagram](resources/architecture_diagram.png?raw=true "Diagram") + +## Public S3 bucket content. + +The Public S3 bucket folder structure is created as follows: +- latest/
+ [device-onboarding-main.yaml](https://device-onboarding-prod-cloudformation-templates.s3.eu-central-1.amazonaws.com/latest/device-onboarding-main.yaml) - Device Onboarding Main Stack: Main stack that is responsible for creating all nested stack resources in the correct sequence. +- VX.X.X/ + - *.yaml - Substack CFN files for all the required [resources](#resouces-per-cfn-templates). + - lambda/ + - *.zip - zip code for [Lambda](#lambdas) + - nginxConfig/ + - nginx.conf - Nginx configuration. The configuration is used in the EC2 instance, that is acting as a proxy. It proxies device onboarding requests to API GW. + - openVpnConfig/ + - *.conf - OpenVPN configuration file for each breakout region. The configuration is used in the EC2 instance. + +## Private S3 bucket content. +The private S3 bucket folder structure is created as follows: + +- VX.X.X/ + - lambda/ + - create-sim.zip - [Create SIM Lambda](#create-sim-lambda) zip code. + - device-onboarding.zip - [Onboarding Lambda](#onboarding-lambda) zip code. + - sim-retrieval.zip - [SIM Retrieval Lambda](#sim-retrieval-lambda) zip code. + - disable-sim.zip - [Disable SIM lambda](#disable-sim-lambda) zip code. + +## SNS + +Two SNS topics are created where customers can subscribe to receive notifications on successful events or failures. + +### Failure Topic + +On this topic notifications will be sent on such failures: +- [SIM Retrieval Lambda](#sim-retrieval-lambda) fails to retrieve SIM from 1NCE API. +- [SIM Retrieval Lambda](#sim-retrieval-lambda) fails to publish SIM changes to [SIMs create SQS](#sims-createfifo) or [SIMs disable SQS](#sims-disablefifo). +- [Create SIM Lambda](#create-sim-lambda) fails in case if an error occurs during IoT Core resources (thing, cert) creation or SIM creation in DB. +- [Disable SIM lambda](#disable-sim-lambda) sends failures if an error occurs during SIM disable. + +Example message: +``` +{ + "timestamp":1680526955876, + "message": "Incorrect username or password" +} +``` +### Success Topic + +On this topic notifications will be sent on such events: +- [Create SIM Lambda](#create-sim-lambda) sends details about successfully created SIMs. +- [Disable SIM lambda](#disable-sim-lambda) sends details about successfully disabled SIMs. + +Example messages: +``` +{ + "iccid": "8988280666001099538", + "ip": "10.242.0.3", + "timestamp": 1680618078888, + "message": "SIM enabled" +} + +{ + "iccid": "8988280666001099538", + "ip": "10.242.0.3", + "timestamp": 1680618079888, + "message": "SIM disabled" +} +``` + +## SQS + +Two SQS queues are created where [SIM Retrieval Lambda](#sim-retrieval-lambda) is posting messages. + +### sims-create.fifo +In this SQS messages are posted for SIMs that need to be created. Messages are processed by [Create SIM Lambda](#create-sim-lambda). + +### sims-disable.fifo +In this SQS messages are posted for SIMs that need to be disabled. Messages are processed by [Disable SIM lambda](#disable-sim-lambda). + +## SSM + + SSM parameters are used to exchange the values with [EC2 Instance](#ec2). SSM parameters names are configurable. Those contains: +- Parameter where API Gateway Endpoint URL is stored. Used by [EC2 Instance](#ec2) Nginx server. +- Parameter where API Gateway Endpoint Path is stored. Used by [EC2 Instance](#ec2) Nginx server configuration. +- Parameter filled by [EC2 Instance](#ec2). It contains the Proxy Server endpoint for the device onboarding. This is the actual onboarding endpoint where customers' devices should request the onboarding details. +- Breakout Region in 1NCE Portal. Used by [EC2 Instance](#ec2) for correct configuration download. + +## Secrets Manager + +Secrets manager secrets where values for [EC2 Instance](#ec2) are being stored. Secrets names are configurable. Those contain: +- Secret where 1NCE Management API credentials are being stored. +- Secret where 1NCE OpenVPN credentials are being stored. +## Lambdas + +### SIM Retrieval Lambda + +Lambda step-by-step flow: + +1. Generate 1NCE API token by using credentials stored in the secrets manager +2. Use 1NCE API token to call [Get ALL SIMs](https://help.1nce.com/dev-hub/reference/getsimsusingget) Endpoint. +3. Get All SIMs from Dynamo DB. +4. Compare the lists: + 1. If a SIM that doesn't exist in DB is returned by 1NCE API, then SIM details are being sent to [SIMs create SQS](#sims-createfifo). + 2. If SIM that exists in DB is not returned by 1NCE API, then SIM details are being sent to [SIMs disable SQS](#sims-disablefifo) +5. If any error occurs - failure details are sent to [SNS Failure Topic](#failure-topic) + + +### Create SIM lambda + +Lambda step-by-step flow: + +1. Read the message from [SIMs create SQS](#sims-createfifo). +2. Create a new IoT Core Certificate. +3. Attach IoT Core Policy to the certificate. +4. Create a new IoT Core Thing. SIM cards iccid is being used as Thing name. +5. Attach the Certificate to the Thing. +6. Put SIM details in Dynamo DB. +7. Send a Success message to [SNS Success Topic](#success-topic) +8. Send a message to MQTT topic `registration` +9. If any error occurs - failure details are sent to [SNS Failure Topic](#failure-topic) + +The message format for SNS and MQTT: +``` +{ + "iccid": "8988280666001099538", + "ip": "10.242.0.3", + "timestamp": 1680618078888, + "message": "SIM enabled" +} +``` +### Disable SIM lambda + +Lambda step-by-step flow: + +1. Read the message from [SIMs disable SQS](#sims-disablefifo). +2. Change the Dynamo DB item. Mark the certificate as inactive `a: false` +3. Send a Success message to [SNS Success Topic](#success-topic) +4. If any error occurs - failure details are sent to [SNS Failure Topic](#failure-topic) + +The message format for SNS: +``` +{ + "iccid": "8988280666001099538", + "ip": "10.242.0.3", + "timestamp": 1680618078888, + "message": "SIM disabled" +} +``` + +### Onboarding lambda + +Lambda step-by-step success flow: + +1. Get the IP address from the request. The IP address is added in request headers by the Nginx server running on the EC2 instance. +2. Find onboarding details (certs, private keys, IoT Core Endpoint URL) for the IP address in Dynamo DB. +3. Validate if the SIM is still active `a: true` +4. Form and return the response: + 1. If header {"content-type": "text/csv"} in the request is present - return the response in CSV format. + 2. If no such header exists return the response in JSON format. +1. If details are not found for the IP in DB, or SIM is not active - return the 404 status code. +2. If an unexpected error occurred - return the 500 status code. + +JSON response example: +``` +{ + "amazonRootCaUrl": "https://www.amazontrust.com/repository/AmazonRootCA1.pem", + "certificate": "-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----\n", + "privateKey": "-----BEGIN RSA PRIVATE KEY-----\n-----END RSA PRIVATE KEY-----\n", + "iccid": "8988280666001099538", + "iotCoreEndpointUrl": "xxxxxxxxxxxxxx.iot.us-west-1.amazonaws.com" +} +``` + +## EC2 + +[AutoScalingAndLaunchTemplateStack](#autoscalingandlaunchtemplatestack) defines the autoscaling group and launch template for EC2 Instance. + +Full EC2 User Data can be found in `scripts/ec2-user-data.bash` + +EC2 instance flow: + +- Install AWS cli and all required utils. +- Get configured breakout region from SSM parameter. Download the OpenVPN config for the configured breakout region. Config is being downloaded from the public S3 bucket. +- Get OpenVPN credentials from Secrets Manager. +- Start OpenVPN service. Wait till the OpenVPN connection is established and the IP address is assigned. +- Use OpenVPN IP address, to generate onboarding endpoint proxy server endpoint. Put this value in the SSM parameter. +- Get onboarding endpoint from SSM parameter. Get onboarding endpoint x-api-key from api gateway. +- Download the Nginx config template from the public S3 bucket. Fill Nginx config. Run the Nginx server. +- Reboot EC2 instance. + + +### Connect to EC2 machine. + +1. AWS > EC2 > Instances +2. Find EC2 instance with openvpn-onboarding-security-group SG +3. Connect > Session Manager + + +### Troubleshoot EC2 instance + +#### Open VPN +`ifconfig` should show the tun0 interface. If the interface is not there - check the OpenVPN folder: +
+`ls /etc/openvpn/` +
+`client credentials.txt openvpn-1nce-client.conf server update-resolv-conf` + +credentials.txt should contain the same content as 1NCE portal > Configuration > OpenVPN Configuration > Download credentials.txt + +More details https://help.1nce.com/dev-hub/docs/network-services-vpn-service + +#### Nginx server +Nginx config: +`cat /etc/nginx/sites-available/default` + +"proxy_pass" should contain the onboarding endpoint from the SSM parameter.
+"proxy_set_header" should set x-api-key for onboarding endpoint. +"proxy_set_header" should set onboarding-ip header.
+ +Nginx logs: +`tail /var/log/nginx/access.log` + + +## IoT Core + +Following resources are created for onboarding: + +1. IoT Core Policy. The policy is attached to all certificates. The policy is being generated with [IOTCorePolicyStack.IotPolicyGenerator](#iotcorepolicystack). +2. IoT Core Certificates. The certificate is being attached to IoT Thing by [Create SIM lambda](#create-sim-lambda). +3. IoT Core Thing. Thing's name matches SIM iccid. The thing is being created by [Create SIM lambda](#create-sim-lambda). + + +## Resouces per CFN templates + +##### IOTCorePolicyStack: +File Name: iot-core-policy.yaml + +Lambda and other resources for IoT Core Policy Generation. Lambda is being used here because on stack deletion the Policy shouldn't be deleted, because it could be assigned to some resources. IoT Core Policy allows to subscribe, connect, and publish to MQTT broker topics. The policy is being attached to Certificates generated for each IoT Core Thing. +##### SQSResourcesStack: +File Name: sqs.yaml + +[SQS](#sqs) queues. +##### SimTableStack: +File Name: sim-table.yaml + +Dynamo table that stores customer SIMs details, together with certificates and private keys. Used by [SIM Retrieval Lambda](#sim-retrieval-lambda) to compare SIMs from DB with the SIMs returned by 1NCE API. Also Used by [Onboarding Lambda](#onboarding-lambda) to return the onboarding details for the device. + +##### LambdaSimRetrievalStack: +File Name: sim-retrieval.yaml + +[SIM Retrieval Lambda](#sim-retrieval-lambda) and required resources - IAM Role, Permissions and Crontab schedule. +##### LambdaCreateSimStack: +File Name: create-sim.yaml + +[Create SIM Lambda](#create-sim-lambda) and required resources - IAM Role, Event Source Mapping +##### LambdaDisableSimStack: +File Name: disable-sim.yaml + +[Disable SIM lambda](#disable-sim-lambda) and required resources - IAM Role, Event Source Mapping +##### IotCoreEndpointProviderStack: +File Name: iot-core-endpoint-provider.yaml + +Lambda and other resources that reads and provides IOT Core Endpoint URL as output. IAM Role allows to describe IoT Core endpoint. +##### LambdaDeviceOnboardingStack: +File Name: device-onboarding.yaml + +[Onboarding Lambda](#onboarding-lambda) and required IAM Role. +##### ApiGatewayStack: +File Name: api-gateway.yaml + +API Gateway endpoint and all required resources for the [Onboarding Lambda](#onboarding-lambda). +##### SSMResourcesStack: +File Name: ssm.yaml + +[SSM parameters](#ssm). +##### SNSResourcesStack: +File Name: sns.yaml + +[SNS Topics](#sns) and SNS policy. +##### NetworkResourcesStack: +File Name: network.yaml + +VPC and all the network resources for onboarding. +##### AutoScalingAndLaunchTemplateStack: +FileName: autoscaling.yaml + +Auto Scaling group and Launch Template resources for [EC2 Instance](#ec2). EC2 instances launch template uses the latest Ubuntu canonical AMI ID. As user data [scripts/ec2-user-data.bash](scripts/ec2-user-data.bash) is being used.
+IAM role Allows EC2 instances to get and put SSM parameters, get secrets manager values, and get API Gateway keys. It also allows connecting EC2 instances via AWS Session Manager.
+Auto Scaling group defines that there should always be 1 EC2 instance available as an onboarding proxy server. +##### S3BucketAndLocalFilesStack: +FileName: s3-lambda-code.yaml + +[Private S3 bucket](#private-s3-bucket-content) used to store code for lambdas. Stack also contains Lambda resources that downloads files from the Public S3 bucket to the Private S3 bucket. Downloaded files contain code for [lambdas](#lambdas). This is needed because lambdas can take code only from S3 buckets that are placed in the same region.
+IAM Role allows to download files from the Public S3 bucket and Put them in the Private S3 bucket. +##### SecretsManagerStack: +FileName: secrets-manager.yaml + +[Secrets Manager](#secrets-manager) secrets. +##### LambdaInvokeStack: +FileName: lambda-invoke.yaml + +Custom Lambda and resources to invoke [SIM Retrieval Lambda](#sim-retrieval-lambda). This is done during Stack rollout to get all SIMs from [Get ALL SIMs](https://help.1nce.com/dev-hub/reference/getsimsusingget) Endpoint immediately. +## Building the project + +In order to be able to deploy the templates to your S3 Bucket and then use it in the Cloud Formation, it is necessary to build the files beforehand.
For that purpose, there is a script developed for Linux systems that will do the job. The script has the following requirements: +- Admin rights, to be able to install dependencies +- Node 14 or newer +- Deployment values file properly filled (deploymentValues.yaml) + +The `build.sh` script is located under the `scripts` folder and it can receive two arguments. The first refers to the environment in which the files will be deployed. The second argument is used to pass a version different from the one described in the `deploymentValues.yaml`. That is useful when deploying temporary testable versions. + +### Deployment Values + +Deployment values is a file located in the root of the repository and has the responsibility to keep shared values used across the templates. Most of them don't need to be touched and are there only because that is a common place for being reused. But, the values of `codeBaseBucket`, `codeBaseBucketRegion`, and `version` needs to be updated before running the script. Those values, except for the `version`, are described under the name of an environment. One can have multiple environments in the file, but each of those needs to have values for `codeBaseBucket` and `codeBaseBucketRegion`. When running the script, the environment should be informed as the first argument and the respective value will be used when reading the file.

Description of the keys in the deploymentValues.yaml: +- `codeBaseBucket` is the S3 bucket name +- `codeBaseBucketRegion` is the region where the S3 bucket is located +- `version` which will be used as the main folder in the bucket (will be ignored if a second argument is provided to the build script) +- `apiGatewayUrlSSMParamName` is the SSM parameter name where the API gateway URL will be placed +- `onboardingPathSSMParamName` is the SSM parameter name where the onboarding endpoint path will be placed +- `proxyServerSSMParamName` is the SSM parameter name where the proxy server address will be placed +- `breakoutRegionSSMParamName` is the SSM parameter name where the VPN breakout region will be placed +- `openVPNCredentialsSecretName` is the AWS Secrets name where the OpenVPN credentials will be placed. +- `onboardingApiKeyName` is the name of the API Key used in the Onboarding API Gateway. + +### The script + +The build script starts by installing some tools, namely ZIP, WGET, and YQ. Those packages can only be installed if `apt` or `apk` package manager is available. The second step will be checking and extracting the values from `deploymentValues.yaml`. Then, all the node dependencies will be installed using `npm ci` just before bundling and zipping it. Finally, all the files will be moved to the build folder, and values extracted from `deploymentValues.yaml` placed in the right spots. Now, the project is ready to go. + +```sh +./scripts/build.sh {{ENVIRONMENT}} {{VERSION}} +``` + +## CFN Templates publishing to the AWS S3 bucket + +For Device Onboarding stack rollout all CFN templates and other supporting files must be uploaded to the public S3 bucket. For convenience, S3 bucket with the publicly available script files is already created and available for everyone interested to try this solution. In case if customer wants to host his own S3 bucket, there is available a special Shell script to publish all files `./scripts/publish.sh`. + +Prerequisites before running the script: +- AWS S3 bucket with "Public" access rights is created +- Non expired AWS credentials are available for AWS CLI under default profile, with the rights to upload files to the S3 bucket +- Python pip command is available in CLI +- Solution is compiled and prepared in the build folder using `./scripts/build.sh` script +- `deploymentValues.yaml` file is available and contains version number and name of the `codeBaseBucket` under according environment name + + +Script must be executed with sudo permissions, because it is installing yq which is needed for yaml parameter file reading. +Following command can be used: +```sh +sudo ./scripts/publish.sh dev V1.0.0 latest +``` +Script input parameters: + +|Parameter Index | Parameter name | Mandatory | Description | +|----------------| --------------- | ------------- | --------- | +| 1 | environment | Yes | parameters group name from the `deploymentValues.yaml` file. Will use `codeBaseBucket` value from that group | +| 2 | version-number | No | Non mandatory parameter, default value will be taken from `version` value in `deploymentValues.yaml` file. All files are uploaded to folder with the name of this parameter on S3 bucket | +| 3 | latest | No | Non mandatory parameter, if provided uploads `device-onboarding-main.yaml` file to the latest folder in the bucket | + +# Delete and cleanup process + +If the stack is being deleted, then the majority of resources will be deleted by the CFN stack. Some of the resources still need to be cleaned/deleted manually: + +- IoT Core Things +- IoT Core Certs +- IoT Core Policy + +# FAQ + +Q: My SIMs were migrated to a different 1NCE organization and stopped working.
+A: If SIMs were migrated to a different 1NCE organization, this means that SIM will not be returned via [Get ALL SIMs](https://help.1nce.com/dev-hub/reference/getsimsusingget) Endpoint. Therefore [SIM retrieval lambda](#sim-retrieval-lambda) will send an SQS message to disable the SIM. SIM is being disabled by setting the certificate in DynamoDB as `a: false`. When onboarding will be called for such SIM card a message `{"message":"Device with the IP=1.2.3.4 is not active"}$` with 404 status code will be returned. + +# Asking for help + +The most effective communication with our team is through GitHub. Simply create a [new issue](https://github.com/1NCE-GmbH/1nce-iot-device-onboarding/issues/new/choose) and select from a range of templates covering bug reports, feature requests, documentation issues, or General Questions. + +# Contributing +See [CONTRIBUTING.md](./CONTRIBUTING.md) for information on contributing + +# Change log + +[Change log file](CHANGELOG.md) diff --git a/applications/README.md b/applications/README.md index 9679061..46a7ff8 100644 --- a/applications/README.md +++ b/applications/README.md @@ -1,8 +1,3 @@ -# SIM Retrieval Service - -## Description -The responsibility of this service is to retrieve all customer SIM cards through the management API, compare the results with the open source project database and create SQS events for the new and removed SIMs. - ## Requirements - `zip` command - used to compress code to upload to AWS Lambda ``` @@ -14,13 +9,13 @@ brew install zip ``` ## Environment variables -| Name | Description | Example | -| ------------------------------------- | --------------------------------------------- | --------------------------------------------------------- | -| MANAGEMENT_API_URL | 1nce management API URL | https://api-prod.1nce.com/management-api | -| MANAGEMENT_API_CREDENTIALS_SECRET_ARN | ID of secret with API credentials | arn:aws:secretsmanager:REGION:ACCOUNT-ID:secret:ID | -| SIMS_TABLE | Table used to store SIMs data | sim-metastore | -| SIM_CREATE_QUEUE_URL | SQS queue URL to upload new SIMs data | https://sqs.REGION.amazonaws.com/ACCOUNT/sims-create.fifo | -| SIM_DELETE_QUEUE_URL | SQS queue URL to upload deleted SIMs data | https://sqs.REGION.amazonaws.com/ACCOUNT/sims-delete.fifo | +| Name | Description | Example | +| ------------------------------------- | --------------------------------------------- | ---------------------------------------------------------- | +| MANAGEMENT_API_URL | 1nce management API URL | https://api-prod.1nce.com/management-api | +| MANAGEMENT_API_CREDENTIALS_SECRET_ARN | ID of secret with API credentials | arn:aws:secretsmanager:REGION:ACCOUNT-ID:secret:ID | +| SIMS_TABLE | Table used to store SIMs data | sim-metastore | +| SIM_CREATE_QUEUE_URL | SQS queue URL to upload new SIMs data | https://sqs.REGION.amazonaws.com/ACCOUNT/sims-create.fifo | +| SIM_DISABLE_QUEUE_URL | SQS queue URL to upload SIMs data to disable | https://sqs.REGION.amazonaws.com/ACCOUNT/sims-disable.fifo | ## How to run unit tests ``` @@ -61,5 +56,3 @@ npm run zip ``` 3) Inside the `dist` folder you will find the `sim-retrieval.zip` file ready to upload to AWS Lambda - - diff --git a/applications/package.json b/applications/package.json index aa36c3b..fa8cd3f 100644 --- a/applications/package.json +++ b/applications/package.json @@ -10,7 +10,7 @@ "test:watch": "jest --watch", "test:coverage": "jest --coverage", "zip:lambda": "cd dist/${LAMBDA} && zip ${LAMBDA}.zip index.js", - "zip:all": "LAMBDA=sim-retrieval npm run zip:lambda && LAMBDA=device-onboarding npm run zip:lambda && LAMBDA=create-sim npm run zip:lambda" + "zip:all": "LAMBDA=sim-retrieval npm run zip:lambda && LAMBDA=device-onboarding npm run zip:lambda && LAMBDA=create-sim npm run zip:lambda && LAMBDA=disable-sim npm run zip:lambda" }, "author": "", "license": "ISC", diff --git a/applications/src/create-sim/create-sim.spec.ts b/applications/src/create-sim/create-sim.spec.ts index fd3d3b0..9b6e1af 100644 --- a/applications/src/create-sim/create-sim.spec.ts +++ b/applications/src/create-sim/create-sim.spec.ts @@ -5,12 +5,12 @@ import { mocked } from "jest-mock"; import { marshall } from "@aws-sdk/util-dynamodb"; import { handler } from "./create-sim"; import { putItem } from "../shared/utils/dynamoHelper"; -import { type SQSEvent } from "aws-lambda"; import { publishErrorToSnsTopic } from "../shared/services/errorHandlingService"; import { IoTCoreCertificate } from "../shared/types/iotCoreCertificate"; import { deleteIotCertificate, deleteIotThing } from "../shared/services/iotCoreService"; import { IoTCoreThing } from "../shared/types/iotCoreThing"; import { publishRegistrationToMqtt, publishSuccessSummaryToSns } from "../shared/services/successMessageService"; +import { buildSqsEvent } from "../shared/test/sqs.fixture"; jest.mock("../shared/utils/dynamoHelper"); jest.mock("../shared/services/errorHandlingService"); @@ -34,14 +34,18 @@ const mockIotCoreThingCreate = mocked(IoTCoreThing.create); console.error = jest.fn(); console.log = jest.fn(); +const mockDate = new Date("2022-04-02T09:00:00.000Z"); describe("Create SIM", () => { let iotCertificate: IoTCoreCertificate; let iotThing: IoTCoreThing; beforeAll(() => { - jest.useFakeTimers(); - jest.setSystemTime(new Date("2022-04-02T09:00:00.000Z")); + jest.useFakeTimers( + { + now: mockDate.getTime(), + }, + ); }); afterAll(() => { @@ -58,8 +62,8 @@ describe("Create SIM", () => { PK: "IP#10.0.0.0", SK: "P#MQTT", crt: "pem", - ct: "2022-04-02T09:00:00.000Z", - ut: "2022-04-02T09:00:00.000Z", + ct: mockDate.toISOString(), + ut: mockDate.toISOString(), i: "123456789", ip: "10.0.0.0", prk: "private-key", @@ -90,29 +94,24 @@ describe("Create SIM", () => { ], }); - expect(console.error).toHaveBeenCalledWith("error parsing SQS record body: invalid JSON", expect.any(SyntaxError)); + expect(console.error).toHaveBeenCalledWith("error parsing SQS record body", expect.any(SyntaxError)); expect(console.log).not.toHaveBeenCalled(); }); }); - describe("when device certificate is not properly generated", () => { + describe("when SIM certificate is not properly generated", () => { it("should log creation error", async () => { const error = "Certificate generation error"; mockIotCoreCertCreate.mockRejectedValueOnce(error); mockPublishErrorToSnsTopic.mockResolvedValueOnce(); - await handler(buildSqsEvent("123456789", "10.0.0.0")); + const event = buildSqsEvent("123456789", "10.0.0.0"); + await handler(event); expect(mockIotCoreCertCreate).toHaveBeenCalled(); - expect(mockPublishErrorToSnsTopic).toHaveBeenCalledWith(error, { - iccid: "123456789", - ip: "10.0.0.0", - createdTime: new Date("2022-04-02T09:00:00.000Z"), - updatedTime: new Date("2022-04-02T09:00:00.000Z"), - }); - expect(console.error).toHaveBeenCalledTimes(2); - expect(console.error).toHaveBeenCalledWith("Error creating SIM", error); - expect(console.error).toHaveBeenNthCalledWith(2, "FAILURE device not created", { iccid: "123456789", ip: "10.0.0.0" }); + expect(mockPublishErrorToSnsTopic).toHaveBeenCalledWith("SIM enable failed", error, undefined); + expect(console.error).toHaveBeenCalledTimes(1); + expect(console.error).toHaveBeenNthCalledWith(1, "FAILURE SIM not created", error, event.Records[0]); expect(console.log).not.toHaveBeenCalled(); }); }); @@ -129,17 +128,24 @@ describe("Create SIM", () => { expect(mockIotCoreCertCreate).toHaveBeenCalled(); expect(mockIoTCoreCertAttachPolicy).toHaveBeenCalledWith("IOT_CORE_POLICY_NAME"); - expect(mockPublishErrorToSnsTopic).toHaveBeenCalledWith(error, { + expect(mockPublishErrorToSnsTopic).toHaveBeenCalledWith("SIM enable failed", error, { iccid: "123456789", ip: "10.0.0.0", - createdTime: new Date("2022-04-02T09:00:00.000Z"), - updatedTime: new Date("2022-04-02T09:00:00.000Z"), + certificate: "pem", + privateKey: "private-key", + active: true, + createdTime: mockDate, + updatedTime: mockDate, }); - expect(console.error).toHaveBeenCalledTimes(2); - expect(console.error).toHaveBeenNthCalledWith(1, "Error creating SIM", error); - expect(console.error).toHaveBeenNthCalledWith(2, "FAILURE device not created", { + expect(console.error).toHaveBeenCalledTimes(1); + expect(console.error).toHaveBeenNthCalledWith(1, "FAILURE SIM not created", error, { iccid: "123456789", ip: "10.0.0.0", + certificate: "pem", + privateKey: "private-key", + active: true, + createdTime: mockDate, + updatedTime: mockDate, }); expect(console.log).not.toHaveBeenCalled(); }); @@ -160,18 +166,26 @@ describe("Create SIM", () => { expect(mockIotCoreCertCreate).toHaveBeenCalled(); expect(mockIoTCoreCertAttachPolicy).toHaveBeenCalledWith("IOT_CORE_POLICY_NAME"); expect(mockIotCoreThingCreate).toHaveBeenCalledWith("123456789"); - expect(mockPublishErrorToSnsTopic).toHaveBeenCalledWith(error, { + expect(mockPublishErrorToSnsTopic).toHaveBeenCalledWith("SIM enable failed", error, { iccid: "123456789", ip: "10.0.0.0", certificate: "pem", privateKey: "private-key", - createdTime: new Date("2022-04-02T09:00:00.000Z"), - updatedTime: new Date("2022-04-02T09:00:00.000Z"), + active: true, + createdTime: mockDate, + updatedTime: mockDate, }); expect(mockDeleteIotCertificate).toHaveBeenCalledWith(iotCertificate); - expect(console.error).toHaveBeenCalledTimes(2); - expect(console.error).toHaveBeenCalledWith("Error creating SIM", error); - expect(console.error).toHaveBeenNthCalledWith(2, "FAILURE device not created", { iccid: "123456789", ip: "10.0.0.0" }); + expect(console.error).toHaveBeenCalledTimes(1); + expect(console.error).toHaveBeenNthCalledWith(1, "FAILURE SIM not created", error, { + iccid: "123456789", + ip: "10.0.0.0", + certificate: "pem", + privateKey: "private-key", + active: true, + createdTime: mockDate, + updatedTime: mockDate, + }); expect(console.log).not.toHaveBeenCalled(); }); }); @@ -195,19 +209,27 @@ describe("Create SIM", () => { expect(mockIoTCoreCertAttachPolicy).toHaveBeenCalledWith("IOT_CORE_POLICY_NAME"); expect(mockIotCoreThingCreate).toHaveBeenCalledWith("123456789"); expect(mockIoTCoreThingAttachCert).toHaveBeenCalledWith("arn"); - expect(mockPublishErrorToSnsTopic).toHaveBeenCalledWith(error, { + expect(mockPublishErrorToSnsTopic).toHaveBeenCalledWith("SIM enable failed", error, { iccid: "123456789", ip: "10.0.0.0", certificate: "pem", privateKey: "private-key", - createdTime: new Date("2022-04-02T09:00:00.000Z"), - updatedTime: new Date("2022-04-02T09:00:00.000Z"), + active: true, + createdTime: mockDate, + updatedTime: mockDate, }); expect(mockDeleteIotCertificate).toHaveBeenCalledWith(iotCertificate); expect(mockDeleteIotThing).toHaveBeenCalledWith(iotThing, iotCertificate); - expect(console.error).toHaveBeenCalledTimes(2); - expect(console.error).toHaveBeenCalledWith("Error creating SIM", error); - expect(console.error).toHaveBeenNthCalledWith(2, "FAILURE device not created", { iccid: "123456789", ip: "10.0.0.0" }); + expect(console.error).toHaveBeenCalledTimes(1); + expect(console.error).toHaveBeenNthCalledWith(1, "FAILURE SIM not created", error, { + iccid: "123456789", + ip: "10.0.0.0", + certificate: "pem", + privateKey: "private-key", + active: true, + createdTime: mockDate, + updatedTime: mockDate, + }); expect(console.log).not.toHaveBeenCalled(); }); }); @@ -233,25 +255,33 @@ describe("Create SIM", () => { expect(mockIotCoreThingCreate).toHaveBeenCalledWith("123456789"); expect(mockIoTCoreThingAttachCert).toHaveBeenCalledWith("arn"); expect(mockPutItem).toHaveBeenCalledWith({ TableName: "SIMS_TABLE", Item: marshall(dynamoDbSimItem) }); - expect(mockPublishErrorToSnsTopic).toHaveBeenCalledWith(new Error(error), { + expect(mockPublishErrorToSnsTopic).toHaveBeenCalledWith("SIM enable failed", new Error(error), { iccid: "123456789", ip: "10.0.0.0", certificate: "pem", privateKey: "private-key", - createdTime: new Date("2022-04-02T09:00:00.000Z"), - updatedTime: new Date("2022-04-02T09:00:00.000Z"), + active: true, + createdTime: mockDate, + updatedTime: mockDate, }); expect(mockDeleteIotCertificate).toHaveBeenCalledWith(iotCertificate); expect(mockDeleteIotThing).toHaveBeenCalledWith(iotThing, iotCertificate); - expect(console.error).toHaveBeenCalledTimes(2); - expect(console.error).toHaveBeenNthCalledWith(1, "Error creating SIM", new Error(error)); - expect(console.error).toHaveBeenNthCalledWith(2, "FAILURE device not created", { iccid: "123456789", ip: "10.0.0.0" }); + expect(console.error).toHaveBeenCalledTimes(1); + expect(console.error).toHaveBeenNthCalledWith(1, "FAILURE SIM not created", new Error(error), { + iccid: "123456789", + ip: "10.0.0.0", + certificate: "pem", + privateKey: "private-key", + active: true, + createdTime: mockDate, + updatedTime: mockDate, + }); expect(console.log).not.toHaveBeenCalled(); }); }); describe("when the creation process is completed sucessfully", () => { - it("should log device created successfully", async () => { + it("should log SIM created successfully", async () => { mockIotCoreCertCreate.mockResolvedValueOnce(iotCertificate); mockCertificateInstance(); mockIoTCoreCertAttachPolicy.mockResolvedValueOnce({}); @@ -274,49 +304,36 @@ describe("Create SIM", () => { ip: "10.0.0.0", certificate: "pem", privateKey: "private-key", - createdTime: new Date("2022-04-02T09:00:00.000Z"), - updatedTime: new Date("2022-04-02T09:00:00.000Z"), - }); + active: true, + createdTime: mockDate, + updatedTime: mockDate, + }, "SIM enabled"); expect(mockPublishSuccessSummaryToSns).toHaveBeenCalledWith({ iccid: "123456789", ip: "10.0.0.0", certificate: "pem", privateKey: "private-key", - createdTime: new Date("2022-04-02T09:00:00.000Z"), - updatedTime: new Date("2022-04-02T09:00:00.000Z"), - }); + active: true, + createdTime: mockDate, + updatedTime: mockDate, + }, "SIM enabled"); expect(mockDeleteIotCertificate).toHaveBeenCalledTimes(0); expect(mockDeleteIotThing).toHaveBeenCalledTimes(0); expect(console.error).not.toHaveBeenCalled(); expect(console.log).toHaveBeenCalledTimes(1); - expect(console.log).toHaveBeenCalledWith("SUCCESS device created", { iccid: "123456789", ip: "10.0.0.0" }); + expect(console.log).toHaveBeenCalledWith("SUCCESS SIM created", { + iccid: "123456789", + ip: "10.0.0.0", + certificate: "pem", + privateKey: "private-key", + active: true, + createdTime: mockDate, + updatedTime: mockDate, + }); }); }); }); -function buildSqsEvent(iccid: string, ip: string): SQSEvent { - return { - Records: [ - { - messageId: "id", - body: JSON.stringify({ iccid, ip }), - receiptHandle: "handle", - attributes: { - ApproximateReceiveCount: "0", - SentTimestamp: "timestamp", - SenderId: "sender-id", - ApproximateFirstReceiveTimestamp: "timestamp", - }, - messageAttributes: {}, - md5OfBody: "md5", - eventSource: "source", - eventSourceARN: "arn", - awsRegion: "region", - }, - ], - }; -} - function mockCertificateInstance(): void { const mockIoTCoreCertInstance = mockIoTCoreCert.mock.instances[0]; mockIoTCoreCertInstance.attachPolicy = mockIoTCoreCertAttachPolicy; diff --git a/applications/src/create-sim/create-sim.ts b/applications/src/create-sim/create-sim.ts index c3be0f8..a818556 100644 --- a/applications/src/create-sim/create-sim.ts +++ b/applications/src/create-sim/create-sim.ts @@ -10,9 +10,11 @@ import { IoTCoreThing } from "../shared/types/iotCoreThing"; import { publishErrorToSnsTopic } from "../shared/services/errorHandlingService"; import { deleteIotCertificate, deleteIotThing } from "../shared/services/iotCoreService"; import { publishRegistrationToMqtt, publishSuccessSummaryToSns } from "../shared/services/successMessageService"; +import { parseSimRetrievalSqsBody } from "../shared/utils/sqsHelper"; const SIMS_TABLE = process.env.SIMS_TABLE as string; const IOT_CORE_POLICY_NAME = process.env.IOT_CORE_POLICY_NAME as string; +const SUCCESS_SUMMARY_MESSAGE = "SIM enabled"; export const handler = async (event: SQSEvent): Promise => { await Promise.allSettled( @@ -21,35 +23,27 @@ export const handler = async (event: SQSEvent): Promise => { }; async function handleSQSRecord(record: SQSRecord): Promise { - const body = parseRecordBody(record); - const sim = new SIM(body); - - const simCreated = await createSim(sim); - if (!simCreated) { - console.error("FAILURE device not created", body); - return; - } - - console.log("SUCCESS device created", body); -} - -function parseRecordBody(record: SQSRecord): any { - try { - return JSON.parse(record.body); - } catch (error) { - console.error(`error parsing SQS record body: ${record.body}`, error); - } -} - -async function createSim(sim: SIM): Promise { let iotCoreCertificate: IoTCoreCertificate | undefined; let iotCoreThing: IoTCoreThing | undefined; + let sim: SIM | undefined; try { - // Generate new certificate and attach it to policy + const body = parseSimRetrievalSqsBody(record); + + // Generate new certificate iotCoreCertificate = await IoTCoreCertificate.create(); + + // Create SIM instance + sim = new SIM({ + iccid: body.iccid, + ip: body.ip, + active: true, + certificate: iotCoreCertificate.certificate, + privateKey: iotCoreCertificate.privateKey, + }); + + // Attach policy to certificate await iotCoreCertificate.attachPolicy(IOT_CORE_POLICY_NAME); - sim.setCertificate(iotCoreCertificate); // Create thing and attach it to certificate iotCoreThing = await IoTCoreThing.create(sim.iccid); @@ -60,18 +54,17 @@ async function createSim(sim: SIM): Promise { // Publish MQTT and SNS messages await Promise.allSettled([ - publishRegistrationToMqtt(sim), - publishSuccessSummaryToSns(sim), + publishRegistrationToMqtt(sim, SUCCESS_SUMMARY_MESSAGE), + publishSuccessSummaryToSns(sim, SUCCESS_SUMMARY_MESSAGE), ]); - return true; + console.log("SUCCESS SIM created", sim); } catch (error: unknown) { - console.error("Error creating SIM", error); - await publishErrorToSnsTopic(error, sim); + console.error("FAILURE SIM not created", error, sim ?? record); - await deleteIoTCoreResources(iotCoreThing, iotCoreCertificate); + await publishErrorToSnsTopic("SIM enable failed", error, sim); - return false; + await deleteIoTCoreResources(iotCoreThing, iotCoreCertificate); } } diff --git a/applications/src/device-onboarding/device-onboarding.spec.ts b/applications/src/device-onboarding/device-onboarding.spec.ts index 72f8864..a6a40e0 100644 --- a/applications/src/device-onboarding/device-onboarding.spec.ts +++ b/applications/src/device-onboarding/device-onboarding.spec.ts @@ -19,16 +19,6 @@ const mockGetDbSimByIp = mocked(getDbSimByIp); const axiosGet = jest.spyOn(axios, "get"); describe("Device Onboarding", () => { - beforeAll(() => { - jest.useFakeTimers(); - }); - - afterAll(() => {}); - - beforeEach(() => { - jest.resetAllMocks(); - }); - describe("handler", () => { describe("When IP_HEADER is missing in the event", () => { it("should throw 500 error", async () => { diff --git a/applications/src/disable-sim/disable-sim.spec.ts b/applications/src/disable-sim/disable-sim.spec.ts new file mode 100644 index 0000000..a9d31b1 --- /dev/null +++ b/applications/src/disable-sim/disable-sim.spec.ts @@ -0,0 +1,130 @@ +process.env.SIMS_TABLE = "SIMS_TABLE"; + +import { mocked } from "jest-mock"; +import { handler } from "./disable-sim"; +import { publishErrorToSnsTopic } from "../shared/services/errorHandlingService"; +import { publishSuccessSummaryToSns } from "../shared/services/successMessageService"; +import { disableSim } from "../shared/services/simService"; +import { SIM } from "../shared/types/sim"; +import { parseSimRetrievalSqsBody } from "../shared/utils/sqsHelper"; +import { buildSqsEvent } from "../shared/test/sqs.fixture"; + +jest.mock("../shared/services/errorHandlingService"); +jest.mock("../shared/services/successMessageService"); +jest.mock("../shared/services/simService"); +jest.mock("../shared/utils/sqsHelper"); + +const mockPublishErrorToSnsTopic = mocked(publishErrorToSnsTopic); +const mockPublishSuccessSummaryToSns = mocked(publishSuccessSummaryToSns); +const mockDisableSim = mocked(disableSim); +const mockParseSimRetrievalSqsBody = mocked(parseSimRetrievalSqsBody); + +console.error = jest.fn(); +console.log = jest.fn(); +const mockDate = new Date("2022-04-02T09:00:00.000Z"); + +describe("Disable SIM", () => { + beforeAll(() => { + jest.useFakeTimers({ + now: mockDate.getTime(), + }); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + const sim = new SIM({ + iccid: "123456789", + ip: "10.0.0.0", + createdTime: mockDate.toISOString(), + updatedTime: mockDate.toISOString(), + active: false, + certificate: "pem", + privateKey: "private-key", + }); + + describe("when SQS event body is not JSON", () => { + it("should log parsing error", async () => { + mockParseSimRetrievalSqsBody.mockImplementation(() => { + throw new Error("Parsing error"); + }); + const event = { + Records: [ + { + messageId: "id", + body: "invalid JSON", + receiptHandle: "handle", + attributes: { + ApproximateReceiveCount: "0", + SentTimestamp: "timestamp", + SenderId: "sender-id", + ApproximateFirstReceiveTimestamp: "timestamp", + }, + messageAttributes: {}, + md5OfBody: "md5", + eventSource: "source", + eventSourceARN: "arn", + awsRegion: "region", + }, + ], + }; + await handler(event); + + expect(console.error).toHaveBeenCalledTimes(1); + expect(console.error).toHaveBeenNthCalledWith(1, "FAILURE disabling SIM", new Error("Parsing error"), event.Records[0]); + expect(console.log).not.toHaveBeenCalled(); + }); + }); + + describe("when disable SIM throws an error", () => { + it("should log disabling error", async () => { + const error = "Error retrieving SIM"; + mockParseSimRetrievalSqsBody.mockReturnValue(sim); + mockDisableSim.mockRejectedValueOnce(error); + mockPublishErrorToSnsTopic.mockResolvedValueOnce(); + + const event = buildSqsEvent("123456789", "10.0.0.0"); + await handler(event); + + expect(mockDisableSim).toHaveBeenCalledWith("10.0.0.0", "123456789"); + expect(mockPublishErrorToSnsTopic).toHaveBeenCalledWith( + "SIM disable failed", + error, + undefined, + ); + expect(console.error).toHaveBeenCalledTimes(1); + expect(console.error).toHaveBeenNthCalledWith(1, "FAILURE disabling SIM", error, event.Records[0]); + expect(console.log).not.toHaveBeenCalled(); + }); + }); + + describe("when the creation process is completed sucessfully", () => { + it("should log SIM created successfully", async () => { + mockParseSimRetrievalSqsBody.mockReturnValue(sim); + mockDisableSim.mockResolvedValueOnce(sim); + mockPublishSuccessSummaryToSns.mockResolvedValueOnce(); + + const event = buildSqsEvent("123456789", "10.0.0.0"); + await handler(event); + + expect(mockDisableSim).toHaveBeenCalledWith("10.0.0.0", "123456789"); + expect(mockPublishSuccessSummaryToSns).toHaveBeenCalledWith({ + iccid: "123456789", + ip: "10.0.0.0", + certificate: "pem", + privateKey: "private-key", + active: false, + createdTime: mockDate, + updatedTime: mockDate, + }, "SIM disabled"); + expect(console.error).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledTimes(1); + expect(console.log).toHaveBeenCalledWith("SUCCESS SIM disabled", sim); + }); + }); +}); diff --git a/applications/src/disable-sim/disable-sim.ts b/applications/src/disable-sim/disable-sim.ts new file mode 100644 index 0000000..b9fbf60 --- /dev/null +++ b/applications/src/disable-sim/disable-sim.ts @@ -0,0 +1,32 @@ +import { + type SQSEvent, + type SQSRecord, +} from "aws-lambda"; +import { parseSimRetrievalSqsBody } from "../shared/utils/sqsHelper"; +import { publishErrorToSnsTopic } from "../shared/services/errorHandlingService"; +import { publishSuccessSummaryToSns } from "../shared/services/successMessageService"; +import { disableSim } from "../shared/services/simService"; + +const SUCCESS_SUMMARY_MESSAGE = "SIM disabled"; + +export const handler = async (event: SQSEvent): Promise => { + await Promise.allSettled( + event.Records.map(async (record: SQSRecord) => handleSQSRecord(record)), + ); +}; + +async function handleSQSRecord(record: SQSRecord): Promise { + let sim; + try { + const body = parseSimRetrievalSqsBody(record); + + sim = await disableSim(body.ip, body.iccid); + + await publishSuccessSummaryToSns(sim, SUCCESS_SUMMARY_MESSAGE); + + console.log("SUCCESS SIM disabled", body); + } catch (error) { + console.error("FAILURE disabling SIM", error, sim ?? record); + await publishErrorToSnsTopic("SIM disable failed", error, sim); + } +} diff --git a/applications/src/shared/services/errorHandlingService.spec.ts b/applications/src/shared/services/errorHandlingService.spec.ts index 49f503d..b5d856d 100644 --- a/applications/src/shared/services/errorHandlingService.spec.ts +++ b/applications/src/shared/services/errorHandlingService.spec.ts @@ -11,10 +11,13 @@ console.log = jest.fn(); console.error = jest.fn(); const mockPublishToSnsTopic = mocked(publishToSnsTopic); +const mockDate = new Date("2022-04-02T09:00:00.000Z"); + describe("Error service", () => { beforeAll(() => { - jest.useFakeTimers(); - jest.setSystemTime(new Date(2023, 1, 1)); + jest.useFakeTimers({ + now: mockDate.getTime(), + }); }); afterAll(() => { @@ -30,15 +33,15 @@ describe("Error service", () => { it("should log the error", async () => { mockPublishToSnsTopic.mockRejectedValueOnce(new Error("SNS error")); - await publishErrorToSnsTopic(new Error("SNS error")); + await publishErrorToSnsTopic("Error description", new Error("SNS error")); expect(mockPublishToSnsTopic).toHaveBeenCalledTimes(1); expect(mockPublishToSnsTopic).toHaveBeenCalledWith("SNS_FAILURE_SUMMARY_TOPIC", JSON.stringify({ - timestamp: 1675209600000, + timestamp: mockDate.getTime(), message: "SNS error", })); expect(console.log).toHaveBeenCalledTimes(1); - expect(console.log).toHaveBeenNthCalledWith(1, "Sending error message to SNS topic", { timestamp: 1675209600000, message: "SNS error" }); + expect(console.log).toHaveBeenNthCalledWith(1, "Sending error message to SNS topic", { timestamp: mockDate.getTime(), message: "SNS error" }); expect(console.error).toHaveBeenCalledTimes(1); expect(console.error).toHaveBeenNthCalledWith(1, "Error sending message to SNS failure topic", new Error("SNS error")); }); @@ -48,15 +51,15 @@ describe("Error service", () => { it("should log the error", async () => { mockPublishToSnsTopic.mockRejectedValueOnce("SNS error"); - await publishErrorToSnsTopic("SNS error"); + await publishErrorToSnsTopic("Error description", "SNS error"); expect(mockPublishToSnsTopic).toHaveBeenCalledTimes(1); expect(mockPublishToSnsTopic).toHaveBeenCalledWith("SNS_FAILURE_SUMMARY_TOPIC", JSON.stringify({ - timestamp: 1675209600000, + timestamp: mockDate.getTime(), message: "SNS error", })); expect(console.log).toHaveBeenCalledTimes(1); - expect(console.log).toHaveBeenNthCalledWith(1, "Sending error message to SNS topic", { timestamp: 1675209600000, message: "SNS error" }); + expect(console.log).toHaveBeenNthCalledWith(1, "Sending error message to SNS topic", { timestamp: mockDate.getTime(), message: "SNS error" }); expect(console.error).toHaveBeenCalledTimes(1); expect(console.error).toHaveBeenNthCalledWith(1, "Error sending message to SNS failure topic", "SNS error"); }); @@ -68,15 +71,15 @@ describe("Error service", () => { it("should send the message", async () => { mockPublishToSnsTopic.mockResolvedValueOnce({ $metadata: {} }); - await publishErrorToSnsTopic(new Error("SNS error")); + await publishErrorToSnsTopic("Error description", new Error("SNS error")); expect(mockPublishToSnsTopic).toHaveBeenCalledTimes(1); expect(mockPublishToSnsTopic).toHaveBeenCalledWith("SNS_FAILURE_SUMMARY_TOPIC", JSON.stringify({ - timestamp: 1675209600000, + timestamp: mockDate.getTime(), message: "SNS error", })); expect(console.log).toHaveBeenCalledTimes(1); - expect(console.log).toHaveBeenNthCalledWith(1, "Sending error message to SNS topic", { timestamp: 1675209600000, message: "SNS error" }); + expect(console.log).toHaveBeenNthCalledWith(1, "Sending error message to SNS topic", { timestamp: mockDate.getTime(), message: "SNS error" }); expect(console.error).toHaveBeenCalledTimes(0); }); }); @@ -85,7 +88,7 @@ describe("Error service", () => { it("should send the message", async () => { mockPublishToSnsTopic.mockResolvedValueOnce({ $metadata: {} }); - await publishErrorToSnsTopic(new Error("SNS error"), new SIM({ + await publishErrorToSnsTopic("Error description", new Error("SNS error"), new SIM({ ip: "10.0.0.0", iccid: "123456789", active: true, @@ -97,15 +100,15 @@ describe("Error service", () => { expect(mockPublishToSnsTopic).toHaveBeenCalledWith("SNS_FAILURE_SUMMARY_TOPIC", JSON.stringify({ iccid: "123456789", ip: "10.0.0.0", - timestamp: 1675209600000, - message: "SNS error", + timestamp: mockDate.getTime(), + message: "Error description. Error: SNS error", })); expect(console.log).toHaveBeenCalledTimes(1); expect(console.log).toHaveBeenNthCalledWith(1, "Sending error message to SNS topic", { iccid: "123456789", ip: "10.0.0.0", - timestamp: 1675209600000, - message: "SNS error", + timestamp: mockDate.getTime(), + message: "Error description. Error: SNS error", }); expect(console.error).toHaveBeenCalledTimes(0); }); diff --git a/applications/src/shared/services/errorHandlingService.ts b/applications/src/shared/services/errorHandlingService.ts index f347e4c..f26decd 100644 --- a/applications/src/shared/services/errorHandlingService.ts +++ b/applications/src/shared/services/errorHandlingService.ts @@ -3,7 +3,7 @@ import { publishToSnsTopic } from "../utils/snsHelper"; const SNS_FAILURE_SUMMARY_TOPIC = process.env.SNS_FAILURE_SUMMARY_TOPIC as string; -export async function publishErrorToSnsTopic(error: unknown, sim?: SIM): Promise { +export async function publishErrorToSnsTopic(description: string, error: unknown, sim?: SIM): Promise { try { const errorMessage = error instanceof Error ? error.message : error as string; let snsMessage = { @@ -12,7 +12,7 @@ export async function publishErrorToSnsTopic(error: unknown, sim?: SIM): Promise }; if (sim) { - snsMessage = sim.toMessage(errorMessage); + snsMessage = sim.toMessage(`${description}. Error: ${errorMessage}`); } console.log("Sending error message to SNS topic", snsMessage); diff --git a/applications/src/shared/services/iotCoreService.spec.ts b/applications/src/shared/services/iotCoreService.spec.ts index 2750f1f..9ea828a 100644 --- a/applications/src/shared/services/iotCoreService.spec.ts +++ b/applications/src/shared/services/iotCoreService.spec.ts @@ -20,15 +20,6 @@ describe("IoT Core service", () => { let iotCertificate: IoTCoreCertificate; let iotThing: IoTCoreThing; - beforeAll(() => { - jest.useFakeTimers(); - jest.setSystemTime(new Date(2023, 1, 1)); - }); - - afterAll(() => { - jest.useRealTimers(); - }); - beforeEach(() => { jest.resetAllMocks(); iotCertificate = new IoTCoreCertificate({ id: "id", arn: "arn", pem: "pem", privateKey: "private-key" }); diff --git a/applications/src/shared/services/managementApiService.spec.ts b/applications/src/shared/services/managementApiService.spec.ts index e04b385..7cd0dce 100644 --- a/applications/src/shared/services/managementApiService.spec.ts +++ b/applications/src/shared/services/managementApiService.spec.ts @@ -15,11 +15,13 @@ const mockGetAxios = mocked(axios.get); const mockRetrieveJSONSecret = mocked(retrieveJSONSecret); console.log = jest.fn(); console.error = jest.fn(); +const mockDate = new Date("2022-04-02T09:00:00.000Z"); describe("Management API Service", () => { beforeAll(() => { - jest.useFakeTimers(); - jest.setSystemTime(new Date(2023, 1, 1)); + jest.useFakeTimers({ + now: mockDate.getTime(), + }); }); afterAll(() => { diff --git a/applications/src/shared/services/simService.spec.ts b/applications/src/shared/services/simService.spec.ts index b7d8d58..9e0e4d9 100644 --- a/applications/src/shared/services/simService.spec.ts +++ b/applications/src/shared/services/simService.spec.ts @@ -1,7 +1,12 @@ +process.env.SIMS_TABLE = "SIMS_TABLE"; + +import { ConditionalCheckFailedException } from "@aws-sdk/client-dynamodb"; +import { marshall } from "@aws-sdk/util-dynamodb"; import { mocked } from "jest-mock"; +import { NotFoundError } from "../types/error"; import { SIM } from "../types/sim"; -import { getItem, query } from "../utils/dynamoHelper"; -import { getDbSimByIp, getDbSims } from "./simService"; +import { getItem, query, updateItem } from "../utils/dynamoHelper"; +import { disableSim, getDbSimByIp, getDbSims } from "./simService"; jest.mock("../utils/dynamoHelper"); jest.mock("../utils/awsIotCoreHelper"); @@ -9,13 +14,16 @@ jest.mock("../utils/snsHelper"); const mockQuery = mocked(query); const mockGetItem = mocked(getItem); +const mockUpdateItem = mocked(updateItem); console.error = jest.fn(); console.log = jest.fn(); +const mockDate = new Date("2022-04-02T09:00:00.000Z"); describe("SIM Service", () => { beforeAll(() => { - jest.useFakeTimers(); - jest.setSystemTime(new Date("2022-04-02T09:00:00.000Z")); + jest.useFakeTimers({ + now: mockDate.getTime(), + }); }); afterAll(() => { @@ -26,56 +34,6 @@ describe("SIM Service", () => { jest.resetAllMocks(); }); - it("should return SIM instances array", async () => { - mockQuery.mockResolvedValueOnce([ - { - PK: { S: "" }, - SK: { S: "" }, - i: { S: "1111111111" }, - ip: { S: "10.0.0.1" }, - ct: { S: "2023-02-01T00:00:00.000Z" }, - ut: { S: "2023-02-01T00:00:00.000Z" }, - a: { BOOL: true }, - crt: { S: "certificate" }, - prk: { S: "private_key" }, - }, - { - PK: { S: "" }, - SK: { S: "" }, - i: { S: "2222222222" }, - ip: { S: "10.0.0.2" }, - ct: { S: "2023-02-01T00:00:00.000Z" }, - ut: { S: "2023-02-01T00:00:00.000Z" }, - a: { BOOL: true }, - crt: { S: "certificate" }, - prk: { S: "private_key" }, - }, - ]); - - const result = await getDbSims(); - - expect(result).toStrictEqual([ - new SIM({ - iccid: "1111111111", - ip: "10.0.0.1", - createdTime: "2023-02-01T00:00:00.000Z", - updatedTime: "2023-02-01T00:00:00.000Z", - active: true, - certificate: "certificate", - privateKey: "private_key", - }), - new SIM({ - iccid: "2222222222", - ip: "10.0.0.2", - createdTime: "2023-02-01T00:00:00.000Z", - updatedTime: "2023-02-01T00:00:00.000Z", - active: true, - certificate: "certificate", - privateKey: "private_key", - }), - ]); - }); - describe("getDbSims", () => { it("should return SIM instances array", async () => { mockQuery.mockResolvedValueOnce([ @@ -125,6 +83,7 @@ describe("SIM Service", () => { privateKey: "private_key", }), ]); + expect(mockQuery).toHaveBeenCalledWith({ TableName: "SIMS_TABLE" }); }); it("should return empty array when query result is null", async () => { @@ -198,4 +157,151 @@ describe("SIM Service", () => { expect(console.error).toHaveBeenCalledWith("FAILURE retrieving sim by ip=ip from database", "Database error"); }); + + describe("disableSim", () => { + describe("when the update sim query throws an error", () => { + it("and the exception is ConditionalCheckFailedException should throw error with specific message", async () => { + const error = new ConditionalCheckFailedException({} as any); + mockUpdateItem.mockRejectedValueOnce(error); + + try { + await disableSim("10.0.0.0", "123456789"); + fail("Test should throw error"); + } catch (error) { + expect(error).toStrictEqual(new Error("Sim with ip 10.0.0.0 and iccid 123456789 is already disabled or does not exist")); + } + + expect(mockUpdateItem).toBeCalledWith({ + TableName: "SIMS_TABLE", + Key: { + PK: { S: "IP#10.0.0.0" }, + SK: { S: "P#MQTT" }, + }, + ConditionExpression: "i=:iccid AND a=:oldActive", + UpdateExpression: "SET ut=:updateTime, a=:active", + ExpressionAttributeValues: marshall({ + ":updateTime": new Date().toISOString(), + ":oldActive": true, + ":active": false, + ":iccid": "123456789", + }), + ReturnValues: "ALL_NEW", + }); + expect(console.error).toBeCalledWith("FAILURE updating sim with ip=10.0.0.0 and iccid=123456789", error); + }); + + it("and the exception is unexpected should throw the unexpected error", async () => { + const error = new Error("Unexpected error"); + mockUpdateItem.mockRejectedValueOnce(error); + + try { + await disableSim("10.0.0.0", "123456789"); + fail("Test should throw error"); + } catch (error) { + expect(error).toStrictEqual(error); + } + + expect(mockUpdateItem).toBeCalledWith({ + TableName: "SIMS_TABLE", + Key: { + PK: { S: "IP#10.0.0.0" }, + SK: { S: "P#MQTT" }, + }, + ConditionExpression: "i=:iccid AND a=:oldActive", + UpdateExpression: "SET ut=:updateTime, a=:active", + ExpressionAttributeValues: marshall({ + ":updateTime": new Date().toISOString(), + ":oldActive": true, + ":active": false, + ":iccid": "123456789", + }), + ReturnValues: "ALL_NEW", + }); + expect(console.error).toBeCalledWith("FAILURE updating sim with ip=10.0.0.0 and iccid=123456789", error); + }); + }); + + describe("when the update sim query does not return Attributes", () => { + it("should throw NotFoundError", async () => { + const error = new NotFoundError("Sim not found for ip 10.0.0.0 and iccid 123456789"); + mockUpdateItem.mockResolvedValueOnce({ + $metadata: {}, + Attributes: undefined, + }); + + try { + await disableSim("10.0.0.0", "123456789"); + fail("Test should throw error"); + } catch (error) { + expect(error).toStrictEqual(error); + } + + expect(mockUpdateItem).toBeCalledWith({ + TableName: "SIMS_TABLE", + Key: { + PK: { S: "IP#10.0.0.0" }, + SK: { S: "P#MQTT" }, + }, + ConditionExpression: "i=:iccid AND a=:oldActive", + UpdateExpression: "SET ut=:updateTime, a=:active", + ExpressionAttributeValues: marshall({ + ":updateTime": new Date().toISOString(), + ":oldActive": true, + ":active": false, + ":iccid": "123456789", + }), + ReturnValues: "ALL_NEW", + }); + expect(console.error).toBeCalledWith("FAILURE updating sim with ip=10.0.0.0 and iccid=123456789", error); + }); + }); + + describe("when the update sim is successfully done", () => { + it("should return SIM instance", async () => { + mockUpdateItem.mockResolvedValueOnce({ + $metadata: {}, + Attributes: marshall({ + PK: "IP#10.0.0.0", + SK: "P#MQTT", + crt: "pem", + ct: mockDate.toISOString(), + ut: mockDate.toISOString(), + i: "123456789", + ip: "10.0.0.0", + prk: "private-key", + a: false, + }), + }); + + const sim = await disableSim("10.0.0.0", "123456789"); + + expect(sim).toStrictEqual(new SIM({ + active: false, + certificate: "pem", + privateKey: "private-key", + iccid: "123456789", + ip: "10.0.0.0", + createdTime: mockDate.toISOString(), + updatedTime: mockDate.toISOString(), + })); + expect(mockUpdateItem).toBeCalledWith({ + TableName: "SIMS_TABLE", + Key: { + PK: { S: "IP#10.0.0.0" }, + SK: { S: "P#MQTT" }, + }, + ConditionExpression: "i=:iccid AND a=:oldActive", + UpdateExpression: "SET ut=:updateTime, a=:active", + ExpressionAttributeValues: marshall({ + ":updateTime": new Date().toISOString(), + ":oldActive": true, + ":active": false, + ":iccid": "123456789", + }), + ReturnValues: "ALL_NEW", + }); + expect(console.error).toBeCalledTimes(0); + }); + }); + }); }); diff --git a/applications/src/shared/services/simService.ts b/applications/src/shared/services/simService.ts index c00cb1f..27d5d05 100644 --- a/applications/src/shared/services/simService.ts +++ b/applications/src/shared/services/simService.ts @@ -1,7 +1,8 @@ -import { unmarshall } from "@aws-sdk/util-dynamodb"; +import { marshall, unmarshall } from "@aws-sdk/util-dynamodb"; import { fromItem, keyFromIp, type IDynamoSim, type SIM } from "../types/sim"; -import { getItem, query } from "../utils/dynamoHelper"; -import { type GetItemCommandInput } from "@aws-sdk/client-dynamodb"; +import { getItem, query, updateItem } from "../utils/dynamoHelper"; +import { ConditionalCheckFailedException, type UpdateItemCommandInput, type GetItemCommandInput } from "@aws-sdk/client-dynamodb"; +import { NotFoundError } from "../types/error"; const SIMS_TABLE = process.env.SIMS_TABLE as string; @@ -30,3 +31,36 @@ export async function getDbSimByIp(ip: string): Promise { throw error; } } + +export async function disableSim(ip: string, iccid: string): Promise { + try { + const input: UpdateItemCommandInput = { + TableName: SIMS_TABLE, + Key: keyFromIp(ip), + ConditionExpression: "i=:iccid AND a=:oldActive", + UpdateExpression: "SET ut=:updateTime, a=:active", + ExpressionAttributeValues: marshall({ + ":updateTime": new Date().toISOString(), + ":oldActive": true, + ":active": false, + ":iccid": iccid, + }), + ReturnValues: "ALL_NEW", + }; + const dbSim = await updateItem(input); + + if (!dbSim.Attributes) { + throw new NotFoundError(`Sim not found for ip ${ip} and iccid ${iccid}`); + } + + return fromItem(unmarshall(dbSim.Attributes) as IDynamoSim); + } catch (error) { + console.error(`FAILURE updating sim with ip=${ip} and iccid=${iccid}`, error); + + if (error instanceof ConditionalCheckFailedException) { + throw new Error(`Sim with ip ${ip} and iccid ${iccid} is already disabled or does not exist`); + } + + throw error; + } +} diff --git a/applications/src/shared/services/successMessageService.spec.ts b/applications/src/shared/services/successMessageService.spec.ts index 13e5a82..2f28fbc 100644 --- a/applications/src/shared/services/successMessageService.spec.ts +++ b/applications/src/shared/services/successMessageService.spec.ts @@ -16,10 +16,13 @@ const mockPublishToSnsTopic = mocked(publishToSnsTopic); console.error = jest.fn(); console.log = jest.fn(); +const mockDate = new Date("2022-04-02T09:00:00.000Z"); + describe("Success Message Service", () => { beforeAll(() => { - jest.useFakeTimers(); - jest.setSystemTime(new Date("2022-04-02T09:00:00.000Z")); + jest.useFakeTimers({ + now: mockDate.getTime(), + }); }); afterAll(() => { @@ -42,12 +45,12 @@ describe("Success Message Service", () => { active: true, certificate: "pem", privateKey: "private-key", - })); + }), "enabled"); const mqttMessage = { iccid: "123456789", ip: "10.0.0.0", - timestamp: 1648890000000, + timestamp: mockDate.getTime(), message: "enabled", }; expect(mockPublishToMqtt).toHaveBeenCalledWith("registration", JSON.stringify(mqttMessage)); @@ -66,7 +69,7 @@ describe("Success Message Service", () => { active: true, certificate: "pem", privateKey: "private-key", - })); + }), "enabled"); const mqttMessage = { iccid: "123456789", @@ -93,7 +96,7 @@ describe("Success Message Service", () => { active: true, certificate: "pem", privateKey: "private-key", - })); + }), "enabled"); const snsMessage = { iccid: "123456789", @@ -117,7 +120,7 @@ describe("Success Message Service", () => { active: true, certificate: "pem", privateKey: "private-key", - })); + }), "enabled"); const snsMessage = { iccid: "123456789", diff --git a/applications/src/shared/services/successMessageService.ts b/applications/src/shared/services/successMessageService.ts index e9e5e6d..c0b4d2c 100644 --- a/applications/src/shared/services/successMessageService.ts +++ b/applications/src/shared/services/successMessageService.ts @@ -3,27 +3,25 @@ import { publishToMqtt } from "../utils/awsIotCoreHelper"; import { publishToSnsTopic } from "../utils/snsHelper"; const SNS_SUCCESS_SUMMARY_TOPIC = process.env.SNS_SUCCESS_SUMMARY_TOPIC as string; - const MQTT_REGISTRATION_TOPIC = "registration"; -const SUCCESS_SUMMARY_MESSAGE = "enabled"; -export async function publishRegistrationToMqtt(sim: SIM): Promise { +export async function publishRegistrationToMqtt(sim: SIM, message: string): Promise { try { - const message = sim.toMessage(SUCCESS_SUMMARY_MESSAGE); + const mqttMessage = sim.toMessage(message); - console.log("Sending message to MQTT topic", message); - await publishToMqtt(MQTT_REGISTRATION_TOPIC, JSON.stringify(message)); + console.log("Sending message to MQTT topic", mqttMessage); + await publishToMqtt(MQTT_REGISTRATION_TOPIC, JSON.stringify(mqttMessage)); } catch (error) { console.error(`Error sending registration message to MQTT topic: ${MQTT_REGISTRATION_TOPIC}`, error); } } -export async function publishSuccessSummaryToSns(sim: SIM): Promise { +export async function publishSuccessSummaryToSns(sim: SIM, message: string): Promise { try { - const message = sim.toMessage(SUCCESS_SUMMARY_MESSAGE); + const snsMessage = sim.toMessage(message); - console.log("Sending message to SNS topic", message); - await publishToSnsTopic(SNS_SUCCESS_SUMMARY_TOPIC, JSON.stringify(message)); + console.log("Sending message to SNS topic", snsMessage); + await publishToSnsTopic(SNS_SUCCESS_SUMMARY_TOPIC, JSON.stringify(snsMessage)); } catch (error) { console.error(`Error sending success summary message to SNS topic: ${SNS_SUCCESS_SUMMARY_TOPIC}`, error); } diff --git a/applications/src/shared/test/sqs.fixture.ts b/applications/src/shared/test/sqs.fixture.ts new file mode 100644 index 0000000..e5cfe1a --- /dev/null +++ b/applications/src/shared/test/sqs.fixture.ts @@ -0,0 +1,24 @@ +import { type SQSEvent } from "aws-lambda"; + +export function buildSqsEvent(iccid?: string, ip?: string): SQSEvent { + return { + Records: [ + { + messageId: "id", + body: JSON.stringify({ iccid, ip }), + receiptHandle: "handle", + attributes: { + ApproximateReceiveCount: "0", + SentTimestamp: "timestamp", + SenderId: "sender-id", + ApproximateFirstReceiveTimestamp: "timestamp", + }, + messageAttributes: {}, + md5OfBody: "md5", + eventSource: "source", + eventSourceARN: "arn", + awsRegion: "region", + }, + ], + }; +} diff --git a/applications/src/shared/types/sim.spec.ts b/applications/src/shared/types/sim.spec.ts index d53888a..cf14216 100644 --- a/applications/src/shared/types/sim.spec.ts +++ b/applications/src/shared/types/sim.spec.ts @@ -1,18 +1,18 @@ -import { IoTCoreCertificate } from "./iotCoreCertificate"; import { fromItem, keyFromIp, SIM } from "./sim"; console.log = jest.fn(); console.error = jest.fn(); +const mockDate = new Date("2022-04-02T09:00:00.000Z"); describe("SIM", () => { const sim: SIM = new SIM({ iccid: "123456789", ip: "10.0.0.0", - createdTime: new Date(2023, 1, 1).toISOString(), - updatedTime: new Date(2023, 1, 1).toISOString(), + createdTime: mockDate.toISOString(), + updatedTime: mockDate.toISOString(), active: true, - certificate: "certificate", - privateKey: "private_key", + certificate: "pem", + privateKey: "private-key", }); const dynamoDbSimItem = { @@ -28,8 +28,9 @@ describe("SIM", () => { }; beforeAll(() => { - jest.useFakeTimers(); - jest.setSystemTime(new Date(2022, 3, 2, 10)); + jest.useFakeTimers({ + now: mockDate.getTime(), + }); }); afterAll(() => { @@ -52,8 +53,8 @@ describe("SIM", () => { expect(result.iccid).toEqual("123456789"); expect(result.ip).toEqual("10.0.0.0"); - expect(result.createdTime).toEqual(new Date(2022, 3, 2, 10)); - expect(result.updatedTime).toEqual(new Date(2022, 3, 2, 10)); + expect(result.createdTime).toEqual(mockDate); + expect(result.updatedTime).toEqual(mockDate); }); it("should build and return SIM instance with defined created/updated times", async () => { @@ -90,24 +91,8 @@ describe("SIM", () => { }); }); - describe("setCertificate", () => { - it("should set a new certificate", () => { - const certificate = new IoTCoreCertificate({ - id: "id", - arn: "arn", - pem: "pem", - privateKey: "private-key", - }); - - sim.setCertificate(certificate); - - expect(sim.certificate).toStrictEqual("pem"); - expect(sim.privateKey).toStrictEqual("private-key"); - }); - }); - describe("toItem", () => { - it("should set a new certificate", () => { + it("should build dynamo item", () => { const result = sim.toItem(); expect(result).toStrictEqual({ @@ -115,8 +100,8 @@ describe("SIM", () => { SK: "P#MQTT", a: true, crt: "pem", - ct: "2023-02-01T00:00:00.000Z", - ut: "2023-02-01T00:00:00.000Z", + ct: mockDate.toISOString(), + ut: mockDate.toISOString(), i: "123456789", ip: "10.0.0.0", prk: "private-key", @@ -125,14 +110,14 @@ describe("SIM", () => { }); describe("toMessage", () => { - it("should set a new certificate", () => { + it("should build SIM message", () => { const result = sim.toMessage("message"); expect(result).toStrictEqual({ iccid: "123456789", ip: "10.0.0.0", message: "message", - timestamp: 1675209600000, + timestamp: mockDate.getTime(), }); }); }); diff --git a/applications/src/shared/types/sim.ts b/applications/src/shared/types/sim.ts index ed491cd..ec31c2f 100644 --- a/applications/src/shared/types/sim.ts +++ b/applications/src/shared/types/sim.ts @@ -1,6 +1,5 @@ import { type AttributeValue } from "@aws-sdk/client-dynamodb"; import { type SendMessageBatchRequestEntry } from "@aws-sdk/client-sqs"; -import { type IoTCoreCertificate } from "./iotCoreCertificate"; import { type IDynamoItem } from "./common"; export const SQS_MESSAGE_GROUP_ID = "sims"; @@ -24,11 +23,6 @@ export class SIM { this.privateKey = sim.privateKey; } - setCertificate(certificate: IoTCoreCertificate): void { - this.certificate = certificate.certificate; - this.privateKey = certificate.privateKey; - } - buildSqsMessageEntry(): SendMessageBatchRequestEntry { return { Id: this.iccid, @@ -51,7 +45,7 @@ export class SIM { ut: this.updatedTime.toISOString(), i: this.iccid, ip: this.ip, - a: true, + a: this.active, }; return item; diff --git a/applications/src/shared/utils/dynamoHelper.spec.ts b/applications/src/shared/utils/dynamoHelper.spec.ts index 83cd46b..a9a8fb3 100644 --- a/applications/src/shared/utils/dynamoHelper.spec.ts +++ b/applications/src/shared/utils/dynamoHelper.spec.ts @@ -1,7 +1,7 @@ -import { DynamoDB, ScanCommand, PutItemCommand, GetItemCommand } from "@aws-sdk/client-dynamodb"; +import { DynamoDB, ScanCommand, PutItemCommand, GetItemCommand, UpdateItemCommand } from "@aws-sdk/client-dynamodb"; import { marshall } from "@aws-sdk/util-dynamodb"; import { mockClient } from "aws-sdk-client-mock"; -import { query, putItem, getItem } from "../utils/dynamoHelper"; +import { query, putItem, getItem, updateItem } from "../utils/dynamoHelper"; console.log = jest.fn(); @@ -69,4 +69,23 @@ describe("dynamoHelper", () => { expect(res).toStrictEqual({ test: 1 }); expect(console.log).toHaveBeenCalledWith("Getting item from dynamo with ", params); }); + + describe("updateItem", () => { + it("should update item to dynamoDB", async () => { + DynamoDbClientMock.on( + UpdateItemCommand, + { TableName: "SIMS_TABLE", Key: { PK: { S: "PK" } }, UpdateExpression: "SET ut=:updateTime, a=:active" }, + ).resolves({ Attributes: {} }); + + const params = { + TableName: "SIMS_TABLE", + Key: { PK: { S: "PK" } }, + UpdateExpression: "SET ut=:updateTime, a=:active", + }; + const result = await updateItem(params); + + expect(result).toStrictEqual({ Attributes: {} }); + expect(console.log).toHaveBeenCalledWith("dynamo update item", params); + }); + }); }); diff --git a/applications/src/shared/utils/dynamoHelper.ts b/applications/src/shared/utils/dynamoHelper.ts index d7d1230..73b7e80 100644 --- a/applications/src/shared/utils/dynamoHelper.ts +++ b/applications/src/shared/utils/dynamoHelper.ts @@ -3,10 +3,13 @@ import { ScanCommand, PutItemCommand, GetItemCommand, + UpdateItemCommand, type QueryInput, type PutItemCommandInput, type PutItemCommandOutput, type GetItemCommandInput, + type UpdateItemCommandInput, + type UpdateItemCommandOutput, } from "@aws-sdk/client-dynamodb"; const DYNAMO_DB_VERSION = "2012-08-10"; @@ -33,3 +36,9 @@ export async function putItem(params: PutItemCommandInput): Promise { + console.log("dynamo update item", params); + const command = new UpdateItemCommand(params); + return await dynamoDb.send(command); +} diff --git a/applications/src/shared/utils/snsHelper.spec.ts b/applications/src/shared/utils/snsHelper.spec.ts index 35ac652..4046f62 100644 --- a/applications/src/shared/utils/snsHelper.spec.ts +++ b/applications/src/shared/utils/snsHelper.spec.ts @@ -4,8 +4,6 @@ import { publishToSnsTopic } from "../utils/snsHelper"; console.log = jest.fn(); -process.env.SIM_DELETE_QUEUE_URL = "SIM_DELETE_QUEUE_URL"; - describe("SNS Helper", () => { const SNSClientMock = mockClient(SNSClient); diff --git a/applications/src/shared/utils/sqsHelper.spec.ts b/applications/src/shared/utils/sqsHelper.spec.ts index 6bd6243..64c409d 100644 --- a/applications/src/shared/utils/sqsHelper.spec.ts +++ b/applications/src/shared/utils/sqsHelper.spec.ts @@ -1,6 +1,7 @@ import { SQS, SendMessageBatchCommand } from "@aws-sdk/client-sqs"; import { mockClient } from "aws-sdk-client-mock"; -import { sendSQSBatchMessages } from "../utils/sqsHelper"; +import { buildSqsEvent } from "../test/sqs.fixture"; +import { parseSimRetrievalSqsBody, sendSQSBatchMessages } from "../utils/sqsHelper"; console.log = jest.fn(); console.error = jest.fn(); @@ -128,4 +129,44 @@ describe("SQS Helper", () => { }); }); }); + + describe("parseSimRetrievalSqsBody", () => { + describe("when the iccid is missing", () => { + it("should throw exception", () => { + const event = buildSqsEvent(undefined, "10.0.0.0"); + + try { + parseSimRetrievalSqsBody(event.Records[0]); + fail("Test should throw error"); + } catch (error) { + expect(error).toStrictEqual(new Error("No iccid found in the SQS message body")); + } + }); + }); + + describe("when the ip is missing", () => { + it("should throw exception", () => { + const event = buildSqsEvent("123456789", undefined); + + try { + parseSimRetrievalSqsBody(event.Records[0]); + fail("Test should throw error"); + } catch (error) { + expect(error).toStrictEqual(new Error("No ip found in the SQS message body")); + } + }); + }); + + describe("when the sqs record is valid", () => { + it("should return an object with ip and iccid", () => { + const event = buildSqsEvent("123456789", "10.0.0.0"); + const result = parseSimRetrievalSqsBody(event.Records[0]); + + expect(result).toStrictEqual({ + ip: "10.0.0.0", + iccid: "123456789", + }); + }); + }); + }); }); diff --git a/applications/src/shared/utils/sqsHelper.ts b/applications/src/shared/utils/sqsHelper.ts index 784d976..d0b5b46 100644 --- a/applications/src/shared/utils/sqsHelper.ts +++ b/applications/src/shared/utils/sqsHelper.ts @@ -1,10 +1,16 @@ import { SQS, SendMessageBatchCommand, type SendMessageBatchRequestEntry } from "@aws-sdk/client-sqs"; +import { type SQSRecord } from "aws-lambda"; const SQS_VERSION = "2012-11-05"; const MAIN_REGION = process.env.MAIN_REGION as string; const sqs = new SQS({ apiVersion: SQS_VERSION, region: MAIN_REGION }); +export interface SimRetrievalSQSRecordBody { + iccid: string; + ip: string; +} + /** * Send a SQS messages in chunks of 10 messages (max allowed) to a given queue * @param queueUrl - The URL of the Amazon SQS queue to which a message is sent. @@ -39,3 +45,29 @@ async function sendMessagesToSQS(queueUrl: string, messages: SendMessageBatchReq throw error; } } + +/** + * Parse and validate SQS message body from sim retrieval process + * @param record - SQS record + */ +export function parseSimRetrievalSqsBody(record: SQSRecord): SimRetrievalSQSRecordBody { + try { + const body = JSON.parse(record.body); + + if (!body.iccid) { + throw new Error("No iccid found in the SQS message body"); + } + + if (!body.ip) { + throw new Error("No ip found in the SQS message body"); + } + + return { + iccid: body.iccid, + ip: body.ip, + }; + } catch (error) { + console.error("error parsing SQS record body", error); + throw error; + } +} diff --git a/applications/src/sim-retrieval/sim-retrieval.spec.ts b/applications/src/sim-retrieval/sim-retrieval.spec.ts index 5324212..89c3680 100644 --- a/applications/src/sim-retrieval/sim-retrieval.spec.ts +++ b/applications/src/sim-retrieval/sim-retrieval.spec.ts @@ -1,5 +1,5 @@ process.env.SIM_CREATE_QUEUE_URL = "SIM_CREATE_QUEUE_URL"; -process.env.SIM_DELETE_QUEUE_URL = "SIM_DELETE_QUEUE_URL"; +process.env.SIM_DISABLE_QUEUE_URL = "SIM_DISABLE_QUEUE_URL"; process.env.SNS_FAILURE_SUMMARY_TOPIC = "SNS_FAILURE_SUMMARY_TOPIC"; import { mocked } from "jest-mock"; @@ -19,20 +19,11 @@ console.log = jest.fn(); console.error = jest.fn(); const mockGetAuthToken = mocked(getAuthToken); const mockGetAllSims = mocked(getAllSims); -const mockGetDbSims = mocked(getDbSims); +const mockgetDbSims = mocked(getDbSims); const mockSendSQSBatchMessages = mocked(sendSQSBatchMessages); const mockPublishErrorToSnsTopic = mocked(publishErrorToSnsTopic); describe("SIM Retrieval", () => { - beforeAll(() => { - jest.useFakeTimers(); - jest.setSystemTime(new Date(2023, 1, 1)); - }); - - afterAll(() => { - jest.useRealTimers(); - }); - beforeEach(() => { jest.resetAllMocks(); }); @@ -45,7 +36,7 @@ describe("SIM Retrieval", () => { await handler(); - expect(mockPublishErrorToSnsTopic).toHaveBeenCalledWith(new Error("Auth Error")); + expect(mockPublishErrorToSnsTopic).toHaveBeenCalledWith("SIM retrieve failed", new Error("Auth Error")); expect(console.error).toHaveBeenCalledWith("Error retrieving SIMs", new Error("Auth Error")); }); }); @@ -58,7 +49,7 @@ describe("SIM Retrieval", () => { await handler(); - expect(mockPublishErrorToSnsTopic).toHaveBeenCalledWith("API Error"); + expect(mockPublishErrorToSnsTopic).toHaveBeenCalledWith("SIM retrieve failed", "API Error"); expect(console.error).toHaveBeenCalledWith("Error retrieving SIMs", "API Error"); }); }); @@ -75,12 +66,12 @@ describe("SIM Retrieval", () => { privateKey: "private_key", }), ]); - mockGetDbSims.mockRejectedValueOnce(new Error("Database Error")); + mockgetDbSims.mockRejectedValueOnce(new Error("Database Error")); mockPublishErrorToSnsTopic.mockResolvedValueOnce(); await handler(); - expect(mockPublishErrorToSnsTopic).toHaveBeenCalledWith(new Error("Database Error")); + expect(mockPublishErrorToSnsTopic).toHaveBeenCalledWith("SIM retrieve failed", new Error("Database Error")); expect(console.error).toHaveBeenCalledWith("Error retrieving SIMs", new Error("Database Error")); expect(console.log).toHaveBeenNthCalledWith(1, "SIMs returned by API: 1"); }); @@ -99,7 +90,7 @@ describe("SIM Retrieval", () => { privateKey: "private_key", }), ]); - mockGetDbSims.mockResolvedValueOnce([ + mockgetDbSims.mockResolvedValueOnce([ new SIM({ iccid: "1111111111", ip: "10.0.0.1", @@ -115,7 +106,7 @@ describe("SIM Retrieval", () => { expect(console.log).toHaveBeenNthCalledWith(1, "SIMs returned by API: 1"); expect(console.log).toHaveBeenNthCalledWith(2, "SIMs returned by database: 1"); expect(console.log).toHaveBeenNthCalledWith(3, "SIMs to be created: 0"); - expect(console.log).toHaveBeenNthCalledWith(4, "SIMs to be deleted: 0"); + expect(console.log).toHaveBeenNthCalledWith(4, "SIMs to be disabled: 0"); }); }); @@ -138,7 +129,7 @@ describe("SIM Retrieval", () => { privateKey: "private_key", }), ]); - mockGetDbSims.mockResolvedValueOnce([ + mockgetDbSims.mockResolvedValueOnce([ new SIM({ iccid: "1111111111", ip: "10.0.0.1", @@ -163,12 +154,12 @@ describe("SIM Retrieval", () => { expect(console.log).toHaveBeenNthCalledWith(1, "SIMs returned by API: 2"); expect(console.log).toHaveBeenNthCalledWith(2, "SIMs returned by database: 1"); expect(console.log).toHaveBeenNthCalledWith(3, "SIMs to be created: 1"); - expect(console.log).toHaveBeenNthCalledWith(4, "SIMs to be deleted: 0"); + expect(console.log).toHaveBeenNthCalledWith(4, "SIMs to be disabled: 0"); }); }); describe("and the API returns fewer SIMs than registered in the database", () => { - it("should send messages to delete these SIMs into SQS delete queue", async () => { + it("should send messages to disable these SIMs into SQS disable queue", async () => { mockGetAuthToken.mockResolvedValueOnce("JWT_TOKEN"); mockGetAllSims.mockResolvedValueOnce([ new SIM({ @@ -186,7 +177,7 @@ describe("SIM Retrieval", () => { privateKey: "private_key", }), ]); - mockGetDbSims.mockResolvedValueOnce([ + mockgetDbSims.mockResolvedValueOnce([ new SIM({ iccid: "1111111111", ip: "10.0.0.1", @@ -213,7 +204,7 @@ describe("SIM Retrieval", () => { await handler(); expect(mockSendSQSBatchMessages).toHaveBeenCalledTimes(1); - expect(mockSendSQSBatchMessages).toHaveBeenCalledWith("SIM_DELETE_QUEUE_URL", [ + expect(mockSendSQSBatchMessages).toHaveBeenCalledWith("SIM_DISABLE_QUEUE_URL", [ new SIM({ iccid: "3333333333", ip: "10.0.0.3", @@ -225,12 +216,120 @@ describe("SIM Retrieval", () => { expect(console.log).toHaveBeenNthCalledWith(1, "SIMs returned by API: 2"); expect(console.log).toHaveBeenNthCalledWith(2, "SIMs returned by database: 3"); expect(console.log).toHaveBeenNthCalledWith(3, "SIMs to be created: 0"); - expect(console.log).toHaveBeenNthCalledWith(4, "SIMs to be deleted: 1"); + expect(console.log).toHaveBeenNthCalledWith(4, "SIMs to be disabled: 1"); + }); + }); + + describe("and the API returns fewer SIMs than registered in the database but they are already disabled", () => { + it("should NOT send messages to disable these SIMs", async () => { + mockGetAuthToken.mockResolvedValueOnce("JWT_TOKEN"); + mockGetAllSims.mockResolvedValueOnce([ + new SIM({ + iccid: "1111111111", + ip: "10.0.0.1", + active: true, + certificate: "certificate", + privateKey: "private_key", + }), + new SIM({ + iccid: "2222222222", + ip: "10.0.0.2", + active: true, + certificate: "certificate", + privateKey: "private_key", + }), + ]); + mockgetDbSims.mockResolvedValueOnce([ + new SIM({ + iccid: "1111111111", + ip: "10.0.0.1", + active: true, + certificate: "certificate", + privateKey: "private_key", + }), + new SIM({ + iccid: "2222222222", + ip: "10.0.0.2", + active: true, + certificate: "certificate", + privateKey: "private_key", + }), + new SIM({ + iccid: "3333333333", + ip: "10.0.0.3", + active: false, + certificate: "certificate", + privateKey: "private_key", + }), + ]); + + await handler(); + + expect(mockSendSQSBatchMessages).toHaveBeenCalledTimes(0); + expect(console.log).toHaveBeenNthCalledWith(1, "SIMs returned by API: 2"); + expect(console.log).toHaveBeenNthCalledWith(2, "SIMs returned by database: 3"); + expect(console.log).toHaveBeenNthCalledWith(3, "SIMs to be created: 0"); + expect(console.log).toHaveBeenNthCalledWith(4, "SIMs to be disabled: 0"); + }); + }); + + describe("and the API returns SIM that is disabled in the database", () => { + it("should send message to create disabled SIM", async () => { + mockGetAuthToken.mockResolvedValueOnce("JWT_TOKEN"); + mockGetAllSims.mockResolvedValueOnce([ + new SIM({ + iccid: "1111111111", + ip: "10.0.0.1", + active: true, + certificate: "certificate", + privateKey: "private_key", + }), + new SIM({ + iccid: "2222222222", + ip: "10.0.0.2", + active: true, + certificate: "certificate", + privateKey: "private_key", + }), + ]); + mockgetDbSims.mockResolvedValueOnce([ + new SIM({ + iccid: "1111111111", + ip: "10.0.0.1", + active: false, + certificate: "certificate", + privateKey: "private_key", + }), + new SIM({ + iccid: "2222222222", + ip: "10.0.0.2", + active: true, + certificate: "certificate", + privateKey: "private_key", + }), + ]); + + await handler(); + + expect(mockSendSQSBatchMessages).toHaveBeenCalledTimes(1); + expect(mockSendSQSBatchMessages).toHaveBeenCalledWith("SIM_CREATE_QUEUE_URL", [ + new SIM({ + iccid: "1111111111", + ip: "10.0.0.1", + active: true, + certificate: "certificate", + privateKey: "private_key", + }).buildSqsMessageEntry(), + ]); + expect(console.log).toHaveBeenNthCalledWith(1, "SIMs returned by API: 2"); + expect(console.log).toHaveBeenNthCalledWith(2, "SIMs returned by database: 2"); + expect(console.log).toHaveBeenNthCalledWith(3, "SIMs to be created: 1"); + expect(console.log).toHaveBeenNthCalledWith(4, "SIMs to be disabled: 0"); }); }); describe("and the API returns a new SIM and a missing SIM compared to the database", () => { - it("should send messages to create and delete these SIMs into SQS queues", async () => { + it("should send messages to create and disable these SIMs into SQS queues", async () => { mockGetAuthToken.mockResolvedValueOnce("JWT_TOKEN"); mockGetAllSims.mockResolvedValueOnce([ new SIM({ @@ -255,7 +354,7 @@ describe("SIM Retrieval", () => { privateKey: "private_key", }), ]); - mockGetDbSims.mockResolvedValueOnce([ + mockgetDbSims.mockResolvedValueOnce([ new SIM({ iccid: "1111111111", ip: "10.0.0.1", @@ -291,7 +390,7 @@ describe("SIM Retrieval", () => { privateKey: "private_key", }).buildSqsMessageEntry(), ]); - expect(mockSendSQSBatchMessages).toHaveBeenCalledWith("SIM_DELETE_QUEUE_URL", [ + expect(mockSendSQSBatchMessages).toHaveBeenCalledWith("SIM_DISABLE_QUEUE_URL", [ new SIM({ iccid: "3333333333", ip: "10.0.0.3", @@ -303,7 +402,7 @@ describe("SIM Retrieval", () => { expect(console.log).toHaveBeenNthCalledWith(1, "SIMs returned by API: 3"); expect(console.log).toHaveBeenNthCalledWith(2, "SIMs returned by database: 3"); expect(console.log).toHaveBeenNthCalledWith(3, "SIMs to be created: 1"); - expect(console.log).toHaveBeenNthCalledWith(4, "SIMs to be deleted: 1"); + expect(console.log).toHaveBeenNthCalledWith(4, "SIMs to be disabled: 1"); }); }); @@ -319,7 +418,7 @@ describe("SIM Retrieval", () => { privateKey: "private_key", }), ]); - mockGetDbSims.mockResolvedValueOnce([]); + mockgetDbSims.mockResolvedValueOnce([]); mockSendSQSBatchMessages.mockRejectedValueOnce("SQS error"); mockPublishErrorToSnsTopic.mockResolvedValueOnce(); @@ -335,12 +434,12 @@ describe("SIM Retrieval", () => { privateKey: "private_key", }).buildSqsMessageEntry(), ]); - expect(mockPublishErrorToSnsTopic).toHaveBeenCalledWith(new Error(JSON.stringify(["SQS error"]))); + expect(mockPublishErrorToSnsTopic).toHaveBeenCalledWith("SIM retrieve failed", new Error(JSON.stringify(["SQS error"]))); expect(console.log).toHaveBeenCalledTimes(4); expect(console.log).toHaveBeenNthCalledWith(1, "SIMs returned by API: 1"); expect(console.log).toHaveBeenNthCalledWith(2, "SIMs returned by database: 0"); expect(console.log).toHaveBeenNthCalledWith(3, "SIMs to be created: 1"); - expect(console.log).toHaveBeenNthCalledWith(4, "SIMs to be deleted: 0"); + expect(console.log).toHaveBeenNthCalledWith(4, "SIMs to be disabled: 0"); expect(console.error).toHaveBeenCalledTimes(1); expect(console.error).toHaveBeenNthCalledWith(1, "Error retrieving SIMs", new Error(JSON.stringify(["SQS error"]))); }); diff --git a/applications/src/sim-retrieval/sim-retrieval.ts b/applications/src/sim-retrieval/sim-retrieval.ts index 3fb15b2..e3f369a 100644 --- a/applications/src/sim-retrieval/sim-retrieval.ts +++ b/applications/src/sim-retrieval/sim-retrieval.ts @@ -5,14 +5,14 @@ import { type SIM } from "../shared/types/sim"; import { sendSQSBatchMessages } from "../shared/utils/sqsHelper"; const SIM_CREATE_QUEUE_URL = process.env.SIM_CREATE_QUEUE_URL as string; -const SIM_DELETE_QUEUE_URL = process.env.SIM_DELETE_QUEUE_URL as string; +const SIM_DISABLE_QUEUE_URL = process.env.SIM_DISABLE_QUEUE_URL as string; export const handler = async (): Promise => { try { await simRetrieval(); } catch (error) { console.error("Error retrieving SIMs", error); - await publishErrorToSnsTopic(error); + await publishErrorToSnsTopic("SIM retrieve failed", error); } }; @@ -26,12 +26,12 @@ async function simRetrieval(): Promise { const createSimMessages = getSimsToCreate(apiSimList, dbSimList).map(sim => sim.buildSqsMessageEntry()); console.log(`SIMs to be created: ${createSimMessages.length}`); - const deleteSimMessages = getSimsToDelete(apiSimList, dbSimList).map(sim => sim.buildSqsMessageEntry()); - console.log(`SIMs to be deleted: ${deleteSimMessages.length}`); + const disableSimMessages = getSimsToDisable(apiSimList, dbSimList).map(sim => sim.buildSqsMessageEntry()); + console.log(`SIMs to be disabled: ${disableSimMessages.length}`); const batchResults = await Promise.allSettled([ createSimMessages.length ? sendSQSBatchMessages(SIM_CREATE_QUEUE_URL, createSimMessages) : Promise.resolve(), - deleteSimMessages.length ? sendSQSBatchMessages(SIM_DELETE_QUEUE_URL, deleteSimMessages) : Promise.resolve(), + disableSimMessages.length ? sendSQSBatchMessages(SIM_DISABLE_QUEUE_URL, disableSimMessages) : Promise.resolve(), ]); const errors: string[] = batchResults @@ -46,15 +46,20 @@ async function simRetrieval(): Promise { function getSimsToCreate(apiSimList: SIM[], dbSimList: SIM[]): SIM[] { return apiSimList .filter(apiSim => { - const dbSim = dbSimList.find(dbSim => dbSim.iccid === apiSim.iccid); - return !dbSim; + const dbSim = dbSimList.find(dbSim => dbSim.iccid === apiSim.iccid && dbSim.ip === apiSim.ip); + + // Create if sim does not exist in db or not active + return !dbSim?.active; }); } -function getSimsToDelete(apiSimList: SIM[], dbSimList: SIM[]): SIM[] { +function getSimsToDisable(apiSimList: SIM[], dbSimList: SIM[]): SIM[] { return dbSimList .filter(dbSim => { - const apiSim = apiSimList.find(apiSim => apiSim.iccid === dbSim.iccid); - return !apiSim; + // need to check also IP address if sim with same ICCID was migrated away, but then same SIM was migrated + // back with new IP, we need to disable entry with the old IP + // we will get two entries with same ICCID, but at least one of those will be disabled + const apiSim = apiSimList.find(apiSim => apiSim.iccid === dbSim.iccid && apiSim.ip === dbSim.ip); + return dbSim.active && !apiSim; }); } diff --git a/applications/webpack.config.js b/applications/webpack.config.js index 7ad543a..28b072b 100644 --- a/applications/webpack.config.js +++ b/applications/webpack.config.js @@ -6,6 +6,7 @@ module.exports = { ["sim-retrieval"]: "./src/sim-retrieval/sim-retrieval.ts", ["create-sim"]: "./src/create-sim/create-sim.ts", ["device-onboarding"]: "./src/device-onboarding/device-onboarding.ts", + ["disable-sim"]: "./src/disable-sim/disable-sim.ts", }, target: "node", output: { @@ -32,4 +33,5 @@ module.exports = { protectWebpackAssets: false, cleanAfterEveryBuildPatterns: ['**/*.LICENSE.txt'], })], + stats: 'errors-only', }; diff --git a/deploymentValues.yaml b/deploymentValues.yaml new file mode 100644 index 0000000..74dd7f3 --- /dev/null +++ b/deploymentValues.yaml @@ -0,0 +1,15 @@ +version: V1.0.0 +apiGatewayUrlSSMParamName: openvpn-onboarding-api-gateway-url +onboardingPathSSMParamName: openvpn-onboarding-path +proxyServerSSMParamName: openvpn-onboarding-proxy-server +breakoutRegionSSMParamName: breakout-region +openVPNCredentialsSecretName: open-source-device-onboarding-openvpn-credentials +onboardingApiKeyName: device-onboarding-key + +dev: + codeBaseBucket: device-onboarding-dev-cloudformation-templates + codeBaseBucketRegion: eu-central-1 + +prod: + codeBaseBucket: device-onboarding-prod-cloudformation-templates + codeBaseBucketRegion: eu-central-1 diff --git a/resources/architecture_diagram.png b/resources/architecture_diagram.png new file mode 100644 index 0000000000000000000000000000000000000000..b942d2e4c2aead423c20dbf5c0aa6257ee61a46f GIT binary patch literal 118514 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 YZ^;mxaB|kZMgTvGa;mbq()XYL7YfU1z5oCK literal 0 HcmV?d00001 diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100644 index 0000000..b167056 --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,101 @@ +#!/bin/sh +os=$(uname | sed -n '/^Linux/p') +if [ -z "$os" ]; then + echo "ERROR: This script is only supported by Linux systems. Actual: $(uname)" + exit 1 +fi +if [ -z "$1" ]; then + echo "ERROR: No environment supplied" + exit 1 +fi +node_version=$(node -v | sed -n '/^v[1-9][4-9][[:digit:]]*\.[[:digit:]]\+\.[[:digit:]]\+$/p') +if [ -z "$node_version" ]; then + echo "ERROR: Node 14 or posterior is missing. Actual: $(node -v)" + exit 1 +fi + +echo "Installing tools..." +if which apt-get; then + apt-get update -y + apt-get install zip wget -y +elif which apk; then + apk -U add zip wget +else + echo "ERROR: This script is only supported when apt-get or apk is available" + exit 1 +fi + +wget https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 -O /usr/bin/yq && chmod +x /usr/bin/yq + +echo "Retrieving values from deploymentValues.yaml..." +get_version=$(yq '.version' deploymentValues.yaml) +version="${2:-$get_version}" +if [ -z "$version" ]; then + echo "ERROR: No version provided in the deploymentValues.yaml nor in the script arguments" + exit 1 +fi +api_gateway_url_ssm_param_name=$(yq '.apiGatewayUrlSSMParamName' deploymentValues.yaml) +onboarding_path_ssm_param_name=$(yq '.onboardingPathSSMParamName' deploymentValues.yaml) +proxy_server_ssm_param_name=$(yq '.proxyServerSSMParamName' deploymentValues.yaml) +breakout_region_ssm_param_name=$(yq '.breakoutRegionSSMParamName' deploymentValues.yaml) +openvpn_creds_secret_name=$(yq '.openVPNCredentialsSecretName' deploymentValues.yaml) +onboarding_api_key_name=$(yq '.onboardingApiKeyName' deploymentValues.yaml) +nginx_port=8080 +cfn_codebase_bucket=$(myenv=$1 yq '.[env(myenv)].codeBaseBucket' deploymentValues.yaml) +if [ -z "$cfn_codebase_bucket" ]; then + echo "ERROR: No codebase bucket provided in the deploymentValues.yaml for $1 env" + exit 1 +fi +cfn_codebase_bucket_region=$(myenv=$1 yq '.[env(myenv)].codeBaseBucketRegion' deploymentValues.yaml) +if [ -z "$cfn_codebase_bucket_region" ]; then + echo "ERROR: No codebase bucket region provided in the deploymentValues.yaml for $1 env" + exit 1 +fi + +echo "Installing applications and zipping it..." +cd applications || exit 1 +npm ci +echo "Bundling..." +npm run bundle +echo "Zipping..." +npm run zip:all + +echo "Moving files to build folder..." +cd .. +rm -rf build/ +cp -r templates/ build/ +cp scripts/ec2-user-data.bash build/ +cp -r nginxConfig/ build/nginxConfig/ +cp -r openVpnConfig/ build/openVpnConfig/ +mkdir build/lambda +cp -r applications/dist/**/*.zip build/lambda/ + +echo "Replacing values in the ec2-user-data.bash script..." +sed -i s,replace_with_version,"$version",g build/ec2-user-data.bash +sed -i s,replace_with_code_bucket_name,"$cfn_codebase_bucket",g build/ec2-user-data.bash +sed -i s,replace_with_code_bucket_region_name,"$cfn_codebase_bucket_region",g build/ec2-user-data.bash +sed -i s,replace_with_ssm_param_name_to_api_gateway_url,"$api_gateway_url_ssm_param_name",g build/ec2-user-data.bash +sed -i s,replace_with_ssm_param_name_to_onboarding_path,"$onboarding_path_ssm_param_name",g build/ec2-user-data.bash +sed -i s,replace_with_ssm_param_name_to_proxy_server,"$proxy_server_ssm_param_name",g build/ec2-user-data.bash +sed -i s,replace_with_ssm_param_name_to_breakout_region,"$breakout_region_ssm_param_name",g build/ec2-user-data.bash +sed -i s,replace_with_secret_name_to_openvpn_creds,"$openvpn_creds_secret_name",g build/ec2-user-data.bash +sed -i s,replace_with_onboarding_api_key_name,"$onboarding_api_key_name",g build/ec2-user-data.bash +sed -i s,replace_with_nginx_port,"$nginx_port",g build/ec2-user-data.bash + +echo "Replacing values in the device-onboarding-main.yaml template..." +encoded_user_data=$(base64 -w0 build/ec2-user-data.bash) +sed -i s,replace_with_version,"$version",g build/device-onboarding-main.yaml +sed -i s,replace_with_code_bucket_name,"$cfn_codebase_bucket",g build/device-onboarding-main.yaml +sed -i s,replace_with_code_bucket_region_name,"$cfn_codebase_bucket_region",g build/device-onboarding-main.yaml +sed -i s,replace_with_ssm_param_name_to_api_gateway_url,"$api_gateway_url_ssm_param_name",g build/device-onboarding-main.yaml +sed -i s,replace_with_ssm_param_name_to_onboarding_path,"$onboarding_path_ssm_param_name",g build/device-onboarding-main.yaml +sed -i s,replace_with_ssm_param_name_to_proxy_server,"$proxy_server_ssm_param_name",g build/device-onboarding-main.yaml +sed -i s,replace_with_ssm_param_name_to_breakout_region,"$breakout_region_ssm_param_name",g build/device-onboarding-main.yaml +sed -i s,replace_with_secret_name_to_openvpn_creds,"$openvpn_creds_secret_name",g build/device-onboarding-main.yaml +sed -i s,replace_with_onboarding_api_key_name,"$onboarding_api_key_name",g build/device-onboarding-main.yaml +sed -i s,replace_with_user_data_base_64_script,"$encoded_user_data",g build/device-onboarding-main.yaml + +echo "Cleaning the clutter..." +rm -rf build/ec2-user-data.bash + +echo "Build complete" diff --git a/scripts/ec2-user-data.bash b/scripts/ec2-user-data.bash old mode 100644 new mode 100755 index e70fb5f..99434d3 --- a/scripts/ec2-user-data.bash +++ b/scripts/ec2-user-data.bash @@ -6,11 +6,11 @@ curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip unzip awscliv2.zip ./aws/install # Get breakout region -breakout_region=$(aws ssm get-parameter --name breakout-region | jq -r '.Parameter.Value') +breakout_region=$(aws ssm get-parameter --name replace_with_ssm_param_name_to_breakout_region | jq -r '.Parameter.Value') # Download and save OpenVPN configuration according to breakout region -curl https://device-onboarding-prod-cloudformation-templates.s3.eu-central-1.amazonaws.com/V1.0.0/vpnConfig/$breakout_region.conf -o /etc/openvpn/openvpn-1nce-client.conf +curl https://replace_with_code_bucket_name.s3.replace_with_code_bucket_region_name.amazonaws.com/replace_with_version/openVpnConfig/$breakout_region.conf -o /etc/openvpn/openvpn-1nce-client.conf # Get and Save OpenVPN credentials file -openvpn_credentials=$(aws secretsmanager get-secret-value --secret-id open-source-device-onboarding-openvpn-credentials | jq -r '.SecretString') +openvpn_credentials=$(aws secretsmanager get-secret-value --secret-id replace_with_secret_name_to_openvpn_creds | jq -r '.SecretString') echo -e "$(echo $openvpn_credentials | jq -r '.username')\n$(echo $openvpn_credentials | jq -r '.password')" > /etc/openvpn/credentials.txt # Start OpenVPN as a service sudo systemctl start openvpn@openvpn-1nce-client @@ -31,14 +31,15 @@ ip_uncut=`ifconfig $ifc | grep -i netmask` ip_endcut=${ip_uncut#*inet} ip=${ip_endcut% netmask*} # Store OpenVPN tunnel ip address in SSM -aws ssm put-parameter --name openvpn-onboarding-proxy-server --value "$ip:8080" --type String --overwrite +onboarding_path=$(aws ssm get-parameter --name replace_with_ssm_param_name_to_onboarding_path | jq -r '.Parameter.Value') +aws ssm put-parameter --name replace_with_ssm_param_name_to_proxy_server --value "$ip:replace_with_nginx_port/$onboarding_path" --type String --overwrite # Get and export Nginx config as env variables -export ONBOARDING_ENDPOINT=$(aws ssm get-parameter --name openvpn-onboarding-api-endpoint | jq -r '.Parameter.Value') -export ONBOARDING_X_API_KEY=$(aws apigateway get-api-keys --name-query device-onboarding-key --include-values | jq -r '.items[0].value') -export NGINX_PORT=8080 +export ONBOARDING_ENDPOINT=$(aws ssm get-parameter --name replace_with_ssm_param_name_to_api_gateway_url | jq -r '.Parameter.Value') +export ONBOARDING_X_API_KEY=$(aws apigateway get-api-keys --name-query replace_with_onboarding_api_key_name --include-values | jq -r '.items[0].value') +export NGINX_PORT=replace_with_nginx_port export DOLLAR="$" # needed to escape nginx built-in env variables # Download Nginx template and set the server -curl https://device-onboarding-prod-cloudformation-templates.s3.eu-central-1.amazonaws.com/V1.0.0/nginxConfig/nginx.conf -o /etc/nginx/conf.d/my-template.conf.template +curl https://replace_with_code_bucket_name.s3.replace_with_code_bucket_region_name.amazonaws.com/replace_with_version/nginxConfig/nginx.conf -o /etc/nginx/conf.d/my-template.conf.template envsubst < /etc/nginx/conf.d/my-template.conf.template > /etc/nginx/sites-available/default sudo chmod 644 /etc/nginx/sites-available/default # Run Nginx server as a service diff --git a/scripts/publish.sh b/scripts/publish.sh new file mode 100644 index 0000000..203c986 --- /dev/null +++ b/scripts/publish.sh @@ -0,0 +1,43 @@ +#!/bin/sh + +if [ -z "$1" ]; then + echo "ERROR: No environment supplied" + exit 1 +fi + +if ! which pip > /dev/null; then + echo "ERROR: pip is missing" + exit 1 +fi + +echo "Preparing script..." + +pip install awscli +wget -q https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 -O /usr/bin/yq && chmod +x /usr/bin/yq + +echo "Reading deployment values from deploymentValues.yaml file for $1 environment..." + +## Get values from deploymentValues file +get_version=$(yq '.version' deploymentValues.yaml) +version="${2:-$get_version}" +cfn_codebase_bucket=$(myenv=$1 yq '.[env(myenv)].codeBaseBucket' deploymentValues.yaml) + +echo "Publishing template files to AWS S3 bucket $cfn_codebase_bucket folder '$version'" + +## Validate if build directory exists +if [ ! -d "build" ]; then + echo "ERROR: 'build' directory is missing, please first build/bundle solution using build script" + exit 1 +fi + +## Copy templates to S3 bucket +aws s3 cp build s3://"$cfn_codebase_bucket"/"$version"/ --recursive + +if [ -n "$3" ]; then + echo "Publishing main template file to AWS S3 bucket $cfn_codebase_bucket folder 'latest'" + aws s3 cp build/device-onboarding-main.yaml s3://"$cfn_codebase_bucket"/latest/ +fi + +echo "Templates publishing complete" + +exit 0 diff --git a/templates/api-gateway.yaml b/templates/api-gateway.yaml index 6a21458..5ef6c74 100644 --- a/templates/api-gateway.yaml +++ b/templates/api-gateway.yaml @@ -1,5 +1,5 @@ AWSTemplateFormatVersion: "2010-09-09" -Description: "Create API Gateway resource" +Description: Create API Gateway resource Parameters: #======================================================= @@ -21,9 +21,9 @@ Parameters: LambdaNameDeviceOnboarding: Type: String - Default: "device-onboarding" + Default: device-onboarding Description: Lambda name for Device Onboarding - + LambdaARNDeviceOnboarding: Type: String Description: Lambda ARN for Device Onboarding @@ -42,109 +42,130 @@ Resources: Type: AWS::ApiGateway::RestApi Properties: Name: device-onboarding-api - Description: "Provides Rest API endpoints for device onboarding service" + Description: Provides Rest API endpoints for device onboarding service EndpointConfiguration: Types: - EDGE Policy: Version: "2012-10-17" Statement: - - Effect: "Allow" + - Effect: Allow Principal: "*" - Action: "execute-api:Invoke" - Resource: "execute-api:/*" + Action: execute-api:Invoke + Resource: execute-api:/* Condition: IpAddress: - aws:SourceIp: - - !Sub "${PublicIpAddress}/32" - + aws:SourceIp: + - Fn::Sub: ${PublicIpAddress}/32 ApiGatewayResourceOnboarding: Type: AWS::ApiGateway::Resource Properties: - ParentId: !GetAtt - - ApiGatewayRestApi - - RootResourceId - RestApiId: !Ref ApiGatewayRestApi - PathPart: !Ref SimOnboardingPath + ParentId: + Fn::GetAtt: + - ApiGatewayRestApi + - RootResourceId + RestApiId: + Ref: ApiGatewayRestApi + PathPart: + Ref: SimOnboardingPath ApiGatewayGetDeviceOnboardingMethod: - Type: "AWS::ApiGateway::Method" + Type: AWS::ApiGateway::Method Properties: - RestApiId: !Ref ApiGatewayRestApi - ResourceId: !Ref ApiGatewayResourceOnboarding - HttpMethod: "GET" - AuthorizationType: "NONE" + RestApiId: + Ref: ApiGatewayRestApi + ResourceId: + Ref: ApiGatewayResourceOnboarding + HttpMethod: GET + AuthorizationType: NONE ApiKeyRequired: true MethodResponses: - ResponseModels: {} - StatusCode: "200" + StatusCode: 200 Integration: - CacheNamespace: !Ref ApiGatewayResourceOnboarding - IntegrationHttpMethod: "POST" + CacheNamespace: + Ref: ApiGatewayResourceOnboarding + IntegrationHttpMethod: POST IntegrationResponses: - - SelectionPattern: ".*" - StatusCode: "200" - PassthroughBehavior: "WHEN_NO_MATCH" + - SelectionPattern: .* + StatusCode: 200 + PassthroughBehavior: WHEN_NO_MATCH TimeoutInMillis: 29000 - Type: "AWS_PROXY" - Uri: !Sub "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${LambdaNameDeviceOnboarding}/invocations" + Type: AWS_PROXY + Uri: + Fn::Sub: arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${LambdaNameDeviceOnboarding}/invocations ApiGatewayLambdaInvokePermission: Type: AWS::Lambda::Permission Properties: - FunctionName: !Ref LambdaARNDeviceOnboarding + FunctionName: + Ref: LambdaARNDeviceOnboarding Action: lambda:InvokeFunction Principal: apigateway.amazonaws.com - SourceAccount: !Ref "AWS::AccountId" - SourceArn: !Sub "arn:${AWS::Partition}:execute-api:${AWS::Region}:${AWS::AccountId}:${ApiGatewayRestApi}/*/*" + SourceAccount: + Ref: AWS::AccountId + SourceArn: + Fn::Sub: arn:${AWS::Partition}:execute-api:${AWS::Region}:${AWS::AccountId}:${ApiGatewayRestApi}/*/* ApiGatewayDeployment: - Type: "AWS::ApiGateway::Deployment" - DependsOn : ApiGatewayGetDeviceOnboardingMethod + Type: AWS::ApiGateway::Deployment + DependsOn: ApiGatewayGetDeviceOnboardingMethod Properties: - RestApiId: !Ref ApiGatewayRestApi - Description: "API Gateway deployment for Device Onboarding" + RestApiId: + Ref: ApiGatewayRestApi + Description: API Gateway deployment for Device Onboarding ApiGatewayStage: - Type: "AWS::ApiGateway::Stage" + Type: AWS::ApiGateway::Stage Properties: - StageName: !Ref APIGatewayStageName - DeploymentId: !Ref ApiGatewayDeployment - RestApiId: !Ref ApiGatewayRestApi - Description: "API Gateway deployment stage for Device Onboarding" + StageName: + Ref: APIGatewayStageName + DeploymentId: + Ref: ApiGatewayDeployment + RestApiId: + Ref: ApiGatewayRestApi + Description: API Gateway deployment stage for Device Onboarding CacheClusterEnabled: false TracingEnabled: true ApiGatewayUsagePlan: - Type: "AWS::ApiGateway::UsagePlan" + Type: AWS::ApiGateway::UsagePlan Properties: - UsagePlanName: "device-onboarding-usage-plan" - Description: "Usage plan for Api Gateway of Device Onboarding" + UsagePlanName: device-onboarding-usage-plan + Description: Usage plan for Api Gateway of Device Onboarding ApiStages: - - ApiId: !Ref ApiGatewayRestApi - Stage: !Ref ApiGatewayStage + - ApiId: + Ref: ApiGatewayRestApi + Stage: + Ref: ApiGatewayStage ApiGatewayApiKey: - Type: "AWS::ApiGateway::ApiKey" + Type: AWS::ApiGateway::ApiKey Properties: - Description: "API key for Device Onboarding API Gateway" + Description: API key for Device Onboarding API Gateway Enabled: true - Name: !Ref OnboardingApiKeyName + Name: + Ref: OnboardingApiKeyName ApiGatewayUsagePlanKey: - Type: "AWS::ApiGateway::UsagePlanKey" + Type: AWS::ApiGateway::UsagePlanKey Properties: - KeyId: !GetAtt ApiGatewayApiKey.APIKeyId - KeyType: "API_KEY" - UsagePlanId: !Ref ApiGatewayUsagePlan + KeyId: + Fn::GetAtt: + - ApiGatewayApiKey + - APIKeyId + KeyType: API_KEY + UsagePlanId: + Ref: ApiGatewayUsagePlan Outputs: #======================================================= - # - # OUTPUTS - # + # + # CloudFormation Outputs + # #======================================================= - OnboardingEndpointUrl: - Description: URL for Device Onboarding endpoint in API Gateway - Value: !Sub "https://${ApiGatewayRestApi}.execute-api.${AWS::Region}.amazonaws.com/${APIGatewayStageName}/${SimOnboardingPath}" + ApiGatewayUrl: + Description: URL for API Gateway + Value: + Fn::Sub: https://${ApiGatewayRestApi}.execute-api.${AWS::Region}.amazonaws.com diff --git a/templates/autoscaling.yaml b/templates/autoscaling.yaml index 1fecf1a..ef49adb 100644 --- a/templates/autoscaling.yaml +++ b/templates/autoscaling.yaml @@ -1,5 +1,5 @@ AWSTemplateFormatVersion: "2010-09-09" -Description: "AutoScaling and Launch Configuration for openvpn onboarding" +Description: AutoScaling and Launch Configuration for openvpn onboarding Parameters: #======================================================= # @@ -11,12 +11,12 @@ Parameters: Description: Security group id used for EC2 Instance EC2AmiId: Description: AMI ID Used for EC2 Ubuntu Instance - Type: 'AWS::SSM::Parameter::Value' - Default: '/aws/service/canonical/ubuntu/server/jammy/stable/current/amd64/hvm/ebs-gp2/ami-id' + Type: AWS::SSM::Parameter::Value + Default: /aws/service/canonical/ubuntu/server/jammy/stable/current/amd64/hvm/ebs-gp2/ami-id EC2InstanceType: Type: String Description: Instance Type Used for EC2 - Default: "t2.micro" + Default: t2.micro VPCPrivateSubnetId: Type: String Description: VPC Private Subnet id @@ -33,87 +33,99 @@ Resources: # #======================================================= IAMRole: - Type: "AWS::IAM::Role" + Type: AWS::IAM::Role Properties: - Path: "/" + Path: / AssumeRolePolicyDocument: Version: "2012-10-17" Statement: - - Effect: "Allow" + - Effect: Allow Principal: Service: - - "ec2.amazonaws.com" - Action: "sts:AssumeRole" + - ec2.amazonaws.com + Action: sts:AssumeRole ManagedPolicyArns: - - !Ref IAMManagedPolicy - - "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore" - Description: "Allows EC2 instances to call AWS services on your behalf." + - Ref: IAMManagedPolicy + - arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore + Description: Allows EC2 instances to call AWS services on your behalf. IAMManagedPolicy: - Type: "AWS::IAM::ManagedPolicy" + Type: AWS::IAM::ManagedPolicy Properties: - Path: "/" + Path: / PolicyDocument: Version: "2012-10-17" Statement: - - Effect: "Allow" + - Effect: Allow Action: - - "ssm:PutParameter" - - "ssm:GetParameter" + - ssm:PutParameter + - ssm:GetParameter Resource: - - !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/*" - - Effect: "Allow" + - Fn::Sub: arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/* + - Effect: Allow Action: - - "secretsmanager:GetSecretValue" + - secretsmanager:GetSecretValue Resource: - - !Sub "arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:*" - - Effect: "Allow" + - Fn::Sub: arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:* + - Effect: Allow Action: - - "apigateway:GET" + - apigateway:GET Resource: - - !Sub "arn:aws:apigateway:${AWS::Region}::/apikeys" + - Fn::Sub: arn:aws:apigateway:${AWS::Region}::/apikeys IAMInstanceProfile: - Type: "AWS::IAM::InstanceProfile" + Type: AWS::IAM::InstanceProfile Properties: - Path: "/" - InstanceProfileName: !Ref IAMRole + Path: / + InstanceProfileName: + Ref: IAMRole Roles: - - !Ref IAMRole + - Ref: IAMRole AutoScalingGroup: - Type: "AWS::AutoScaling::AutoScalingGroup" + Type: AWS::AutoScaling::AutoScalingGroup Properties: - AutoScalingGroupName: "openvpn-onboarding-autoscaling-group" + AutoScalingGroupName: openvpn-onboarding-autoscaling-group LaunchTemplate: - LaunchTemplateId: !Ref EC2LaunchTemplate - Version: !GetAtt "EC2LaunchTemplate.LatestVersionNumber" - MinSize: "1" - MaxSize: "1" - DesiredCapacity: "1" - Cooldown: "300" + LaunchTemplateId: + Ref: EC2LaunchTemplate + Version: + Fn::GetAtt: + - EC2LaunchTemplate + - LatestVersionNumber + MinSize: 1 + MaxSize: 1 + DesiredCapacity: 1 + Cooldown: 300 AvailabilityZones: - - !Ref VPCPrivateSubnetAvailabilityZone - HealthCheckType: "EC2" + - Ref: VPCPrivateSubnetAvailabilityZone + HealthCheckType: EC2 HealthCheckGracePeriod: 300 VPCZoneIdentifier: - - !Ref VPCPrivateSubnetId + - Ref: VPCPrivateSubnetId TerminationPolicies: - - "Default" + - Default NewInstancesProtectedFromScaleIn: false EC2LaunchTemplate: - Type: "AWS::EC2::LaunchTemplate" + Type: AWS::EC2::LaunchTemplate Properties: - LaunchTemplateName: "openvpn-onboarding-launch-template" + LaunchTemplateName: openvpn-onboarding-launch-template LaunchTemplateData: - UserData: !Ref EC2UserData + UserData: + Ref: EC2UserData IamInstanceProfile: - Arn: !GetAtt IAMInstanceProfile.Arn + Arn: + Fn::GetAtt: + - IAMInstanceProfile + - Arn NetworkInterfaces: - DeviceIndex: 0 Groups: - - !Ref EC2SecurityGroupId - SubnetId: !Ref VPCPrivateSubnetId - ImageId: !Ref EC2AmiId - InstanceType: !Ref EC2InstanceType + - Ref: EC2SecurityGroupId + SubnetId: + Ref: VPCPrivateSubnetId + ImageId: + Ref: EC2AmiId + InstanceType: + Ref: EC2InstanceType diff --git a/templates/create-sim.yaml b/templates/create-sim.yaml index e6b7034..0319b89 100644 --- a/templates/create-sim.yaml +++ b/templates/create-sim.yaml @@ -1,5 +1,5 @@ AWSTemplateFormatVersion: "2010-09-09" -Description: "Create lambda to handle the process of create SIMs" +Description: Create lambda to handle the process of create SIMs Parameters: #======================================================= @@ -9,7 +9,7 @@ Parameters: #======================================================= LambdaName: Type: String - Default: "create-sim" + Default: create-sim Description: Lambda name IotCorePolicyName: Type: String @@ -23,10 +23,9 @@ Parameters: SimTableARN: Description: DynamoDB ARN to store SIMs data. Type: String - Default: '' SimsTableName: Type: String - Default: "sim-metastore" + Default: sim-metastore Description: DynamoDB table name to store SIMs data S3LocalBucketArn: Description: Arn for S3 bucket used to store the lambda code on customers account. @@ -48,24 +47,24 @@ Resources: # #======================================================= LambdaCreateSimIAMRole: - Type: "AWS::IAM::Role" + Type: AWS::IAM::Role Properties: - Path: "/" - RoleName: !Sub "${LambdaName}-role" + Path: / AssumeRolePolicyDocument: Version: "2012-10-17" Statement: - - Effect: "Allow" + - Effect: Allow Principal: Service: - - "lambda.amazonaws.com" - Action: "sts:AssumeRole" + - lambda.amazonaws.com + Action: sts:AssumeRole ManagedPolicyArns: - - "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole Policies: - - PolicyName: !Sub "create-sim-policy-${AWS::Region}" + - PolicyName: + Fn::Sub: create-sim-policy-${AWS::Region} PolicyDocument: - Version: '2012-10-17' + Version: "2012-10-17" Statement: - Effect: Allow Action: @@ -73,17 +72,19 @@ Resources: - sqs:DeleteMessage - sqs:GetQueueAttributes - sqs:ChangeMessageVisibility - Resource: !Ref SqsQueueARN + Resource: + Ref: SqsQueueARN - Effect: Allow Action: - sns:Publish - Resource: - - !Ref SnsSuccessSummaryTopicARN - - !Ref SnsFailureSummaryTopicARN - - Effect: "Allow" - Action: + Resource: + - Ref: SnsSuccessSummaryTopicARN + - Ref: SnsFailureSummaryTopicARN + - Effect: Allow + Action: - dynamodb:PutItem - Resource: !Ref SimTableARN + Resource: + Ref: SimTableARN - Effect: Allow Action: - iot:CreateKeysAndCertificate @@ -99,49 +100,58 @@ Resources: Resource: "*" - Effect: Allow Action: - - 's3:GetObject' - - 's3:ListBucket' - - 's3:GetBucketPolicy' - - 'S3:PutObjectTagging' + - s3:GetObject + - s3:ListBucket + - s3:GetBucketPolicy + - S3:PutObjectTagging Resource: - - !Join - - '' - - - !Ref S3LocalBucketArn - - /* - - !Ref S3LocalBucketArn + - Fn::Sub: ${S3LocalBucketArn}/* + - Ref: S3LocalBucketArn LambdaCreateSimFunction: - Type: "AWS::Lambda::Function" + Type: AWS::Lambda::Function Properties: Description: Create SIMs in database and IoT Core Environment: Variables: - IOT_CORE_POLICY_NAME: !Ref "IotCorePolicyName" - SNS_FAILURE_SUMMARY_TOPIC: !Ref "SnsFailureSummaryTopicARN" - SNS_SUCCESS_SUMMARY_TOPIC: !Ref "SnsSuccessSummaryTopicARN" - SIMS_TABLE: !Ref "SimsTableName" - FunctionName: !Ref "LambdaName" - Handler: "index.handler" + IOT_CORE_POLICY_NAME: + Ref: IotCorePolicyName + SNS_FAILURE_SUMMARY_TOPIC: + Ref: SnsFailureSummaryTopicARN + SNS_SUCCESS_SUMMARY_TOPIC: + Ref: SnsSuccessSummaryTopicARN + SIMS_TABLE: + Ref: SimsTableName + FunctionName: + Ref: LambdaName + Handler: index.handler ## Reserve dedicated lambda executions to prevent IoT Core Throttling issues ReservedConcurrentExecutions: 5 Architectures: - - "x86_64" + - x86_64 Code: - S3Bucket: !Ref "S3LocalBucketName" - S3Key: !Ref "CreateSimLambdaZipPath" + S3Bucket: + Ref: S3LocalBucketName + S3Key: + Ref: CreateSimLambdaZipPath MemorySize: 128 - Role: !GetAtt "LambdaCreateSimIAMRole.Arn" - Runtime: "nodejs14.x" + Role: + Fn::GetAtt: + - LambdaCreateSimIAMRole + - Arn + Runtime: nodejs14.x TracingConfig: - Mode: "PassThrough" + Mode: Active EphemeralStorage: Size: 512 - + LambdaCreateSimFunctionEventSourceMapping: Type: AWS::Lambda::EventSourceMapping Properties: BatchSize: 1 Enabled: true - EventSourceArn: !Ref SqsQueueARN - FunctionName: !Ref LambdaName + EventSourceArn: + Ref: SqsQueueARN + FunctionName: + Ref: LambdaName DependsOn: LambdaCreateSimFunction diff --git a/templates/device-onboarding-main.yaml b/templates/device-onboarding-main.yaml index bc0674a..1fbaefc 100644 --- a/templates/device-onboarding-main.yaml +++ b/templates/device-onboarding-main.yaml @@ -1,5 +1,5 @@ AWSTemplateFormatVersion: "2010-09-09" -Description: "Main stack for device onboarding service" +Description: Main stack for device onboarding service Parameters: #======================================================= # @@ -9,21 +9,21 @@ Parameters: ManagementApiUsername: Type: String Description: Management API username - MinLength: "1" + MinLength: 1 ManagementApiPassword: Type: String Description: Management API password NoEcho: true - MinLength: "1" + MinLength: 1 OpenvpnOnboardingUsername: Type: String Description: Username for OpenVPN - MinLength: "1" + MinLength: 1 OpenvpnOnboardingPassword: Type: String NoEcho: true Description: Password for OpenVPN - MinLength: "1" + MinLength: 1 LambdaCron: Description: Crontab that determines when CloudWatch Events runs the rule that triggers the Lambda function. Default: cron(0 1 * * ? *) @@ -31,11 +31,11 @@ Parameters: APIGatewayStageName: Description: Stage name of API Gateway deployment Type: String - Default: "dev" + Default: dev SimOnboardingPath: Description: REST API Path for sim onboarding endpoint Type: String - Default: "onboarding" + Default: onboarding BreakoutRegion: Type: String Default: eu-central-1 @@ -52,19 +52,20 @@ Mappings: #======================================================= Configuration: BaseConfiguration: - CodebaseVersion: V1.0.0 - CodebaseBucket: device-onboarding-prod-cloudformation-templates - CodebaseBucketRegion: eu-central-1 + CodebaseVersion: replace_with_version + CodebaseBucket: replace_with_code_bucket_name + CodebaseBucketRegion: replace_with_code_bucket_region_name SimRetrievalLambdaZipFile: lambda/sim-retrieval.zip CreateSimLambdaZipFile: lambda/create-sim.zip DisableSimLambdaZipFile: lambda/disable-sim.zip DeviceOnboardingLambdaZipFile: lambda/device-onboarding.zip - OnboardingApiKeyName: device-onboarding-key - ApiEndpointSSMParamName: openvpn-onboarding-api-endpoint - ProxyServerSSMParamName: openvpn-onboarding-proxy-server - BreakoutRegionSSMParamName: breakout-region - OpenVPNCredentialsSecretName: open-source-device-onboarding-openvpn-credentials - UserDataBase64Script: IyEvYmluL2Jhc2gKYXB0LWdldCB1cGRhdGUgLXkKYXB0LWdldCBpbnN0YWxsIC15IG9wZW52cG4gbmV0LXRvb2xzIHVuemlwIGN1cmwgbmdpbngganEKIyBEb3dubG9hZCBhbmQgaW5zdGFsbCBBV1MKY3VybCAiaHR0cHM6Ly9hd3NjbGkuYW1hem9uYXdzLmNvbS9hd3NjbGktZXhlLWxpbnV4LXg4Nl82NC56aXAiIC1vICJhd3NjbGl2Mi56aXAiCnVuemlwIGF3c2NsaXYyLnppcAouL2F3cy9pbnN0YWxsCiMgR2V0IGJyZWFrb3V0IHJlZ2lvbgpicmVha291dF9yZWdpb249JChhd3Mgc3NtIGdldC1wYXJhbWV0ZXIgLS1uYW1lIGJyZWFrb3V0LXJlZ2lvbiB8IGpxIC1yICcuUGFyYW1ldGVyLlZhbHVlJykKIyBEb3dubG9hZCBhbmQgc2F2ZSBPcGVuVlBOIGNvbmZpZ3VyYXRpb24gYWNjb3JkaW5nIHRvIGJyZWFrb3V0IHJlZ2lvbgpjdXJsIGh0dHBzOi8vZGV2aWNlLW9uYm9hcmRpbmctcHJvZC1jbG91ZGZvcm1hdGlvbi10ZW1wbGF0ZXMuczMuZXUtY2VudHJhbC0xLmFtYXpvbmF3cy5jb20vVjEuMC4wL3ZwbkNvbmZpZy8kYnJlYWtvdXRfcmVnaW9uLmNvbmYgLW8gL2V0Yy9vcGVudnBuL29wZW52cG4tMW5jZS1jbGllbnQuY29uZgojIEdldCBhbmQgU2F2ZSBPcGVuVlBOIGNyZWRlbnRpYWxzIGZpbGUKb3BlbnZwbl9jcmVkZW50aWFscz0kKGF3cyBzZWNyZXRzbWFuYWdlciBnZXQtc2VjcmV0LXZhbHVlIC0tc2VjcmV0LWlkIG9wZW4tc291cmNlLWRldmljZS1vbmJvYXJkaW5nLW9wZW52cG4tY3JlZGVudGlhbHMgfCBqcSAtciAnLlNlY3JldFN0cmluZycpCmVjaG8gLWUgIiQoZWNobyAkb3BlbnZwbl9jcmVkZW50aWFscyB8IGpxIC1yICcudXNlcm5hbWUnKVxuJChlY2hvICRvcGVudnBuX2NyZWRlbnRpYWxzIHwganEgLXIgJy5wYXNzd29yZCcpIiA+IC9ldGMvb3BlbnZwbi9jcmVkZW50aWFscy50eHQKIyBTdGFydCBPcGVuVlBOIGFzIGEgc2VydmljZQpzdWRvIHN5c3RlbWN0bCBzdGFydCBvcGVudnBuQG9wZW52cG4tMW5jZS1jbGllbnQKIyBXYWl0IGZvciB0dW4wIGludGVyZmFjZSB0byBiZSBwcmVzZW50CmlmYz10dW4wCndoaWxlIHRydWU7IGRvCiAgICBpZmNvbmZpZyAkaWZjCiAgICByZXM9JD8KICAgIGlmIFsgJHJlcyAtZXEgMCBdOyB0aGVuCiAgICAgICBlY2hvIEludGVyZmFjZSAkaWZjIGlzIHByZXNlbnQKICAgICAgIGJyZWFrCiAgICBmaQogICAgZWNobyBXYWl0aW5nIGZvciBpbnRlcmZhY2UgJGlmYwogICAgc2xlZXAgMwpkb25lCiMgR2V0IE9wZW5WUE4gdHVubmVsIGlwIGFkZHJlc3MKaXBfdW5jdXQ9YGlmY29uZmlnICRpZmMgfCBncmVwIC1pIG5ldG1hc2tgCmlwX2VuZGN1dD0ke2lwX3VuY3V0IyppbmV0fQppcD0ke2lwX2VuZGN1dCUgIG5ldG1hc2sqfQojIFN0b3JlIE9wZW5WUE4gdHVubmVsIGlwIGFkZHJlc3MgaW4gU1NNCmF3cyBzc20gcHV0LXBhcmFtZXRlciAtLW5hbWUgb3BlbnZwbi1vbmJvYXJkaW5nLXByb3h5LXNlcnZlciAtLXZhbHVlICIkaXA6ODA4MCIgLS10eXBlIFN0cmluZyAtLW92ZXJ3cml0ZQojIEdldCBhbmQgZXhwb3J0IE5naW54IGNvbmZpZyBhcyBlbnYgdmFyaWFibGVzCmV4cG9ydCBPTkJPQVJESU5HX0VORFBPSU5UPSQoYXdzIHNzbSBnZXQtcGFyYW1ldGVyIC0tbmFtZSBvcGVudnBuLW9uYm9hcmRpbmctYXBpLWVuZHBvaW50IHwganEgLXIgJy5QYXJhbWV0ZXIuVmFsdWUnKQpleHBvcnQgT05CT0FSRElOR19YX0FQSV9LRVk9JChhd3MgYXBpZ2F0ZXdheSBnZXQtYXBpLWtleXMgLS1uYW1lLXF1ZXJ5IGRldmljZS1vbmJvYXJkaW5nLWtleSAtLWluY2x1ZGUtdmFsdWVzIHwganEgLXIgJy5pdGVtc1swXS52YWx1ZScpCmV4cG9ydCBOR0lOWF9QT1JUPTgwODAKZXhwb3J0IERPTExBUj0iJCIgIyBuZWVkZWQgdG8gZXNjYXBlIG5naW54IGJ1aWx0LWluIGVudiB2YXJpYWJsZXMKIyBEb3dubG9hZCBOZ2lueCB0ZW1wbGF0ZSBhbmQgc2V0IHRoZSBzZXJ2ZXIKY3VybCBodHRwczovL2RldmljZS1vbmJvYXJkaW5nLXByb2QtY2xvdWRmb3JtYXRpb24tdGVtcGxhdGVzLnMzLmV1LWNlbnRyYWwtMS5hbWF6b25hd3MuY29tL1YxLjAuMC9uZ2lueENvbmZpZy9uZ2lueC5jb25mIC1vIC9ldGMvbmdpbngvY29uZi5kL215LXRlbXBsYXRlLmNvbmYudGVtcGxhdGUKZW52c3Vic3QgPCAvZXRjL25naW54L2NvbmYuZC9teS10ZW1wbGF0ZS5jb25mLnRlbXBsYXRlID4gL2V0Yy9uZ2lueC9zaXRlcy1hdmFpbGFibGUvZGVmYXVsdApzdWRvIGNobW9kIDY0NCAvZXRjL25naW54L3NpdGVzLWF2YWlsYWJsZS9kZWZhdWx0CiMgUnVuIE5naW54IHNlcnZlciBhcyBhIHNlcnZpY2UKc3VkbyBzeXN0ZW1jdGwgc3RvcCBuZ2lueApzdWRvIHN5c3RlbWN0bCBzdGFydCBuZ2lueApzdWRvIHN5c3RlbWN0bCBlbmFibGUgbmdpbngKIyBSZWJvb3QgbWFjaGluZQpyZWJvb3QK + OnboardingApiKeyName: replace_with_onboarding_api_key_name + ApiGatewayUrlSSMParamName: replace_with_ssm_param_name_to_api_gateway_url + OnboardingPathSSMParamName: replace_with_ssm_param_name_to_onboarding_path + ProxyServerSSMParamName: replace_with_ssm_param_name_to_proxy_server + BreakoutRegionSSMParamName: replace_with_ssm_param_name_to_breakout_region + OpenVPNCredentialsSecretName: replace_with_secret_name_to_openvpn_creds + UserDataBase64Script: replace_with_user_data_base_64_script Resources: #======================================================= @@ -72,471 +73,686 @@ Resources: # CloudFormation stacks # #======================================================= + IOTCorePolicyStack: + Type: AWS::CloudFormation::Stack + Properties: + TemplateURL: + Fn::Join: + - "" + - - https:// + - Fn::FindInMap: + - Configuration + - BaseConfiguration + - CodebaseBucket + - .s3- + - Fn::FindInMap: + - Configuration + - BaseConfiguration + - CodebaseBucketRegion + - .amazonaws.com/ + - Fn::FindInMap: + - Configuration + - BaseConfiguration + - CodebaseVersion + - /iot-core-policy.yaml + SQSResourcesStack: - Type: "AWS::CloudFormation::Stack" + Type: AWS::CloudFormation::Stack Properties: - TemplateURL: !Join - - "" - - - https:// - - !FindInMap - - Configuration - - BaseConfiguration - - CodebaseBucket - - .s3- - - !FindInMap - - Configuration - - BaseConfiguration - - CodebaseBucketRegion - - .amazonaws.com/ - - !FindInMap - - Configuration - - BaseConfiguration - - CodebaseVersion - - /sqs.yaml + TemplateURL: + Fn::Join: + - "" + - - https:// + - Fn::FindInMap: + - Configuration + - BaseConfiguration + - CodebaseBucket + - .s3- + - Fn::FindInMap: + - Configuration + - BaseConfiguration + - CodebaseBucketRegion + - .amazonaws.com/ + - Fn::FindInMap: + - Configuration + - BaseConfiguration + - CodebaseVersion + - /sqs.yaml SimTableStack: - Type: "AWS::CloudFormation::Stack" + Type: AWS::CloudFormation::Stack Properties: - TemplateURL: !Join - - "" - - - https:// - - !FindInMap - - Configuration - - BaseConfiguration - - CodebaseBucket - - .s3- - - !FindInMap - - Configuration - - BaseConfiguration - - CodebaseBucketRegion - - .amazonaws.com/ - - !FindInMap - - Configuration - - BaseConfiguration - - CodebaseVersion - - /sim-table.yaml + TemplateURL: + Fn::Join: + - "" + - - https:// + - Fn::FindInMap: + - Configuration + - BaseConfiguration + - CodebaseBucket + - .s3- + - Fn::FindInMap: + - Configuration + - BaseConfiguration + - CodebaseBucketRegion + - .amazonaws.com/ + - Fn::FindInMap: + - Configuration + - BaseConfiguration + - CodebaseVersion + - /sim-table.yaml LambdaSimRetrievalStack: - Type: "AWS::CloudFormation::Stack" + Type: AWS::CloudFormation::Stack Properties: Parameters: - ManagementApiCredentialsSecretARN: !GetAtt SecretsManagerStack.Outputs.ManagementApiCredentialsSecretARN - LambdaCron: !Ref LambdaCron - SimCreateQueueARN: !GetAtt SQSResourcesStack.Outputs.SimCreateQueueARN - SimCreateQueueURL: !GetAtt SQSResourcesStack.Outputs.SimCreateQueueURL - SimDisableQueueARN: !GetAtt SQSResourcesStack.Outputs.SimDisableQueueARN - SimDisableQueueURL: !GetAtt SQSResourcesStack.Outputs.SimDisableQueueURL - SimTableName: !GetAtt SimTableStack.Outputs.SimTableName - SimTableARN: !GetAtt SimTableStack.Outputs.SimTableArn - SnsFailureSummaryTopicARN: !GetAtt SNSResourcesStack.Outputs.SNSFailureTopicArn - S3LocalBucketArn: !GetAtt S3BucketAndLocalFilesStack.Outputs.LocalBucketArn - S3LocalBucketName: !GetAtt S3BucketAndLocalFilesStack.Outputs.LocalBucketName - SimRetrievalLambdaZipPath: !Join + ManagementApiCredentialsSecretARN: + Fn::GetAtt: + - SecretsManagerStack + - Outputs.ManagementApiCredentialsSecretARN + LambdaCron: + Ref: LambdaCron + SimCreateQueueARN: + Fn::GetAtt: + - SQSResourcesStack + - Outputs.SimCreateQueueARN + SimCreateQueueURL: + Fn::GetAtt: + - SQSResourcesStack + - Outputs.SimCreateQueueURL + SimDisableQueueARN: + Fn::GetAtt: + - SQSResourcesStack + - Outputs.SimDisableQueueARN + SimDisableQueueURL: + Fn::GetAtt: + - SQSResourcesStack + - Outputs.SimDisableQueueURL + SimTableName: + Fn::GetAtt: + - SimTableStack + - Outputs.SimTableName + SimTableARN: + Fn::GetAtt: + - SimTableStack + - Outputs.SimTableArn + SnsFailureSummaryTopicARN: + Fn::GetAtt: + - SNSResourcesStack + - Outputs.SNSFailureTopicArn + S3LocalBucketArn: + Fn::GetAtt: + - S3BucketAndLocalFilesStack + - Outputs.LocalBucketArn + S3LocalBucketName: + Fn::GetAtt: + - S3BucketAndLocalFilesStack + - Outputs.LocalBucketName + SimRetrievalLambdaZipPath: + Fn::Join: + - "" + - - Fn::FindInMap: + - Configuration + - BaseConfiguration + - CodebaseVersion + - / + - Fn::FindInMap: + - Configuration + - BaseConfiguration + - SimRetrievalLambdaZipFile + TemplateURL: + Fn::Join: - "" - - - !FindInMap [Configuration, BaseConfiguration, CodebaseVersion] - - / - - !FindInMap [Configuration, BaseConfiguration, SimRetrievalLambdaZipFile] - TemplateURL: !Join - - "" - - - https:// - - !FindInMap - - Configuration - - BaseConfiguration - - CodebaseBucket - - .s3- - - !FindInMap - - Configuration - - BaseConfiguration - - CodebaseBucketRegion - - .amazonaws.com/ - - !FindInMap - - Configuration - - BaseConfiguration - - CodebaseVersion - - /sim-retrieval.yaml + - - https:// + - Fn::FindInMap: + - Configuration + - BaseConfiguration + - CodebaseBucket + - .s3- + - Fn::FindInMap: + - Configuration + - BaseConfiguration + - CodebaseBucketRegion + - .amazonaws.com/ + - Fn::FindInMap: + - Configuration + - BaseConfiguration + - CodebaseVersion + - /sim-retrieval.yaml LambdaCreateSimStack: - Type: "AWS::CloudFormation::Stack" + Type: AWS::CloudFormation::Stack Properties: Parameters: - IotCorePolicyName: !GetAtt IOTCorePolicyStack.Outputs.IOTCorePolicyName - SnsFailureSummaryTopicARN: !GetAtt SNSResourcesStack.Outputs.SNSFailureTopicArn - SnsSuccessSummaryTopicARN: !GetAtt SNSResourcesStack.Outputs.SNSSuccessTopicArn - SimsTableName: !GetAtt SimTableStack.Outputs.SimTableName - SimTableARN: !GetAtt SimTableStack.Outputs.SimTableArn - SqsQueueARN: !GetAtt SQSResourcesStack.Outputs.SimCreateQueueARN - S3LocalBucketArn: !GetAtt S3BucketAndLocalFilesStack.Outputs.LocalBucketArn - S3LocalBucketName: !GetAtt S3BucketAndLocalFilesStack.Outputs.LocalBucketName - CreateSimLambdaZipPath: !Join + IotCorePolicyName: + Fn::GetAtt: + - IOTCorePolicyStack + - Outputs.IOTCorePolicyName + SnsFailureSummaryTopicARN: + Fn::GetAtt: + - SNSResourcesStack + - Outputs.SNSFailureTopicArn + SnsSuccessSummaryTopicARN: + Fn::GetAtt: + - SNSResourcesStack + - Outputs.SNSSuccessTopicArn + SimsTableName: + Fn::GetAtt: + - SimTableStack + - Outputs.SimTableName + SimTableARN: + Fn::GetAtt: + - SimTableStack + - Outputs.SimTableArn + SqsQueueARN: + Fn::GetAtt: + - SQSResourcesStack + - Outputs.SimCreateQueueARN + S3LocalBucketArn: + Fn::GetAtt: + - S3BucketAndLocalFilesStack + - Outputs.LocalBucketArn + S3LocalBucketName: + Fn::GetAtt: + - S3BucketAndLocalFilesStack + - Outputs.LocalBucketName + CreateSimLambdaZipPath: + Fn::Join: + - "" + - - Fn::FindInMap: + - Configuration + - BaseConfiguration + - CodebaseVersion + - / + - Fn::FindInMap: + - Configuration + - BaseConfiguration + - CreateSimLambdaZipFile + TemplateURL: + Fn::Join: - "" - - - !FindInMap [Configuration, BaseConfiguration, CodebaseVersion] - - / - - !FindInMap [Configuration, BaseConfiguration, CreateSimLambdaZipFile] - TemplateURL: !Join - - "" - - - https:// - - !FindInMap - - Configuration - - BaseConfiguration - - CodebaseBucket - - .s3- - - !FindInMap - - Configuration - - BaseConfiguration - - CodebaseBucketRegion - - .amazonaws.com/ - - !FindInMap - - Configuration - - BaseConfiguration - - CodebaseVersion - - /create-sim.yaml + - - https:// + - Fn::FindInMap: + - Configuration + - BaseConfiguration + - CodebaseBucket + - .s3- + - Fn::FindInMap: + - Configuration + - BaseConfiguration + - CodebaseBucketRegion + - .amazonaws.com/ + - Fn::FindInMap: + - Configuration + - BaseConfiguration + - CodebaseVersion + - /create-sim.yaml LambdaDisableSimStack: - Type: "AWS::CloudFormation::Stack" + Type: AWS::CloudFormation::Stack Properties: Parameters: - IotCorePolicyName: !GetAtt IOTCorePolicyStack.Outputs.IOTCorePolicyName - SnsFailureSummaryTopicARN: !GetAtt SNSResourcesStack.Outputs.SNSFailureTopicArn - SnsSuccessSummaryTopicARN: !GetAtt SNSResourcesStack.Outputs.SNSSuccessTopicArn - SimsTableName: !GetAtt SimTableStack.Outputs.SimTableName - SimTableARN: !GetAtt SimTableStack.Outputs.SimTableArn - SqsQueueARN: !GetAtt SQSResourcesStack.Outputs.SimDisableQueueARN - S3LocalBucketArn: !GetAtt S3BucketAndLocalFilesStack.Outputs.LocalBucketArn - S3LocalBucketName: !GetAtt S3BucketAndLocalFilesStack.Outputs.LocalBucketName - DisableSimLambdaZipPath: !Join + SnsFailureSummaryTopicARN: + Fn::GetAtt: + - SNSResourcesStack + - Outputs.SNSFailureTopicArn + SnsSuccessSummaryTopicARN: + Fn::GetAtt: + - SNSResourcesStack + - Outputs.SNSSuccessTopicArn + SimsTableName: + Fn::GetAtt: + - SimTableStack + - Outputs.SimTableName + SimTableARN: + Fn::GetAtt: + - SimTableStack + - Outputs.SimTableArn + SqsQueueARN: + Fn::GetAtt: + - SQSResourcesStack + - Outputs.SimDisableQueueARN + S3LocalBucketArn: + Fn::GetAtt: + - S3BucketAndLocalFilesStack + - Outputs.LocalBucketArn + S3LocalBucketName: + Fn::GetAtt: + - S3BucketAndLocalFilesStack + - Outputs.LocalBucketName + DisableSimLambdaZipPath: + Fn::Join: + - "" + - - Fn::FindInMap: + - Configuration + - BaseConfiguration + - CodebaseVersion + - / + - Fn::FindInMap: + - Configuration + - BaseConfiguration + - DisableSimLambdaZipFile + TemplateURL: + Fn::Join: - "" - - - !FindInMap [Configuration, BaseConfiguration, CodebaseVersion] - - / - - !FindInMap [Configuration, BaseConfiguration, DisableSimLambdaZipFile] - TemplateURL: !Join - - "" - - - https:// - - !FindInMap - - Configuration - - BaseConfiguration - - CodebaseBucket - - .s3- - - !FindInMap - - Configuration - - BaseConfiguration - - CodebaseBucketRegion - - .amazonaws.com/ - - !FindInMap - - Configuration - - BaseConfiguration - - CodebaseVersion - - /disable-sim.yaml + - - https:// + - Fn::FindInMap: + - Configuration + - BaseConfiguration + - CodebaseBucket + - .s3- + - Fn::FindInMap: + - Configuration + - BaseConfiguration + - CodebaseBucketRegion + - .amazonaws.com/ + - Fn::FindInMap: + - Configuration + - BaseConfiguration + - CodebaseVersion + - /disable-sim.yaml IotCoreEndpointProviderStack: - Type: "AWS::CloudFormation::Stack" + Type: AWS::CloudFormation::Stack Properties: - TemplateURL: !Join - - "" - - - https:// - - !FindInMap - - Configuration - - BaseConfiguration - - CodebaseBucket - - .s3- - - !FindInMap - - Configuration - - BaseConfiguration - - CodebaseBucketRegion - - .amazonaws.com/ - - !FindInMap - - Configuration - - BaseConfiguration - - CodebaseVersion - - /iot-core-endpoint-provider.yaml + TemplateURL: + Fn::Join: + - "" + - - https:// + - Fn::FindInMap: + - Configuration + - BaseConfiguration + - CodebaseBucket + - .s3- + - Fn::FindInMap: + - Configuration + - BaseConfiguration + - CodebaseBucketRegion + - .amazonaws.com/ + - Fn::FindInMap: + - Configuration + - BaseConfiguration + - CodebaseVersion + - /iot-core-endpoint-provider.yaml LambdaDeviceOnboardingStack: - Type: "AWS::CloudFormation::Stack" + Type: AWS::CloudFormation::Stack Properties: Parameters: - SimTableName: !GetAtt SimTableStack.Outputs.SimTableName - SimTableARN: !GetAtt SimTableStack.Outputs.SimTableArn - S3LocalBucketArn: !GetAtt S3BucketAndLocalFilesStack.Outputs.LocalBucketArn - S3LocalBucketName: !GetAtt S3BucketAndLocalFilesStack.Outputs.LocalBucketName - IoTCoreEndpointURL: !GetAtt IotCoreEndpointProviderStack.Outputs.IotEndpointAddress - DeviceOnboardingLambdaZipPath: !Join + SimTableName: + Fn::GetAtt: + - SimTableStack + - Outputs.SimTableName + SimTableARN: + Fn::GetAtt: + - SimTableStack + - Outputs.SimTableArn + S3LocalBucketArn: + Fn::GetAtt: + - S3BucketAndLocalFilesStack + - Outputs.LocalBucketArn + S3LocalBucketName: + Fn::GetAtt: + - S3BucketAndLocalFilesStack + - Outputs.LocalBucketName + IoTCoreEndpointURL: + Fn::GetAtt: + - IotCoreEndpointProviderStack + - Outputs.IotEndpointAddress + DeviceOnboardingLambdaZipPath: + Fn::Join: + - "" + - - Fn::FindInMap: + - Configuration + - BaseConfiguration + - CodebaseVersion + - / + - Fn::FindInMap: + - Configuration + - BaseConfiguration + - DeviceOnboardingLambdaZipFile + TemplateURL: + Fn::Join: - "" - - - !FindInMap [Configuration, BaseConfiguration, CodebaseVersion] - - / - - !FindInMap [Configuration, BaseConfiguration, DeviceOnboardingLambdaZipFile] - TemplateURL: !Join - - "" - - - https:// - - !FindInMap - - Configuration - - BaseConfiguration - - CodebaseBucket - - .s3- - - !FindInMap - - Configuration - - BaseConfiguration - - CodebaseBucketRegion - - .amazonaws.com/ - - !FindInMap - - Configuration - - BaseConfiguration - - CodebaseVersion - - /device-onboarding.yaml + - - https:// + - Fn::FindInMap: + - Configuration + - BaseConfiguration + - CodebaseBucket + - .s3- + - Fn::FindInMap: + - Configuration + - BaseConfiguration + - CodebaseBucketRegion + - .amazonaws.com/ + - Fn::FindInMap: + - Configuration + - BaseConfiguration + - CodebaseVersion + - /device-onboarding.yaml ApiGatewayStack: - Type: "AWS::CloudFormation::Stack" + Type: AWS::CloudFormation::Stack Properties: Parameters: - LambdaNameDeviceOnboarding: !GetAtt LambdaDeviceOnboardingStack.Outputs.LambdaNameDeviceOnboarding - LambdaARNDeviceOnboarding: !GetAtt LambdaDeviceOnboardingStack.Outputs.LambdaARNDeviceOnboarding - OnboardingApiKeyName: !FindInMap [Configuration, BaseConfiguration, OnboardingApiKeyName] - APIGatewayStageName: !Ref APIGatewayStageName - SimOnboardingPath: !Ref SimOnboardingPath - PublicIpAddress: !GetAtt NetworkResourcesStack.Outputs.PublicIpAddress - TemplateURL: !Join - - "" - - - https:// - - !FindInMap - - Configuration - - BaseConfiguration - - CodebaseBucket - - .s3- - - !FindInMap - - Configuration - - BaseConfiguration - - CodebaseBucketRegion - - .amazonaws.com/ - - !FindInMap - - Configuration - - BaseConfiguration - - CodebaseVersion - - /api-gateway.yaml + LambdaNameDeviceOnboarding: + Fn::GetAtt: + - LambdaDeviceOnboardingStack + - Outputs.LambdaNameDeviceOnboarding + LambdaARNDeviceOnboarding: + Fn::GetAtt: + - LambdaDeviceOnboardingStack + - Outputs.LambdaARNDeviceOnboarding + OnboardingApiKeyName: + Fn::FindInMap: + - Configuration + - BaseConfiguration + - OnboardingApiKeyName + APIGatewayStageName: + Ref: APIGatewayStageName + SimOnboardingPath: + Ref: SimOnboardingPath + PublicIpAddress: + Fn::GetAtt: + - NetworkResourcesStack + - Outputs.PublicIpAddress + TemplateURL: + Fn::Join: + - "" + - - https:// + - Fn::FindInMap: + - Configuration + - BaseConfiguration + - CodebaseBucket + - .s3- + - Fn::FindInMap: + - Configuration + - BaseConfiguration + - CodebaseBucketRegion + - .amazonaws.com/ + - Fn::FindInMap: + - Configuration + - BaseConfiguration + - CodebaseVersion + - /api-gateway.yaml SSMResourcesStack: - Type: "AWS::CloudFormation::Stack" + Type: AWS::CloudFormation::Stack Properties: Parameters: - OnboardingEndpointUrl: !GetAtt ApiGatewayStack.Outputs.OnboardingEndpointUrl - ApiEndpointSSMParamName: !FindInMap - - Configuration - - BaseConfiguration - - ApiEndpointSSMParamName - ProxyServerSSMParamName: !FindInMap - - Configuration - - BaseConfiguration - - ProxyServerSSMParamName - BreakoutRegionSSMParamName: !FindInMap - - Configuration - - BaseConfiguration - - BreakoutRegionSSMParamName - BreakoutRegion: !Ref BreakoutRegion - TemplateURL: !Join - - "" - - - https:// - - !FindInMap - - Configuration - - BaseConfiguration - - CodebaseBucket - - .s3- - - !FindInMap + ApiGatewayUrl: + Fn::GetAtt: + - ApiGatewayStack + - Outputs.ApiGatewayUrl + ApiGatewayUrlSSMParamName: + Fn::FindInMap: - Configuration - BaseConfiguration - - CodebaseBucketRegion - - .amazonaws.com/ - - !FindInMap - - Configuration - - BaseConfiguration - - CodebaseVersion - - /ssm.yaml - - SNSResourcesStack: - Type: "AWS::CloudFormation::Stack" - Properties: - TemplateURL: !Join - - "" - - - https:// - - !FindInMap + - ApiGatewayUrlSSMParamName + OnboardingPath: + Fn::Sub: ${APIGatewayStageName}/${SimOnboardingPath} + OnboardingPathSSMParamName: + Fn::FindInMap: - Configuration - BaseConfiguration - - CodebaseBucket - - .s3- - - !FindInMap + - OnboardingPathSSMParamName + ProxyServerSSMParamName: + Fn::FindInMap: - Configuration - BaseConfiguration - - CodebaseBucketRegion - - .amazonaws.com/ - - !FindInMap + - ProxyServerSSMParamName + BreakoutRegionSSMParamName: + Fn::FindInMap: - Configuration - BaseConfiguration - - CodebaseVersion - - /sns.yaml + - BreakoutRegionSSMParamName + BreakoutRegion: + Ref: BreakoutRegion + TemplateURL: + Fn::Join: + - "" + - - https:// + - Fn::FindInMap: + - Configuration + - BaseConfiguration + - CodebaseBucket + - .s3- + - Fn::FindInMap: + - Configuration + - BaseConfiguration + - CodebaseBucketRegion + - .amazonaws.com/ + - Fn::FindInMap: + - Configuration + - BaseConfiguration + - CodebaseVersion + - /ssm.yaml - IOTCorePolicyStack: - Type: "AWS::CloudFormation::Stack" + SNSResourcesStack: + Type: AWS::CloudFormation::Stack Properties: - TemplateURL: !Join - - "" - - - https:// - - !FindInMap - - Configuration - - BaseConfiguration - - CodebaseBucket - - .s3- - - !FindInMap - - Configuration - - BaseConfiguration - - CodebaseBucketRegion - - .amazonaws.com/ - - !FindInMap - - Configuration - - BaseConfiguration - - CodebaseVersion - - /iot-core-policy.yaml + TemplateURL: + Fn::Join: + - "" + - - https:// + - Fn::FindInMap: + - Configuration + - BaseConfiguration + - CodebaseBucket + - .s3- + - Fn::FindInMap: + - Configuration + - BaseConfiguration + - CodebaseBucketRegion + - .amazonaws.com/ + - Fn::FindInMap: + - Configuration + - BaseConfiguration + - CodebaseVersion + - /sns.yaml NetworkResourcesStack: - Type: "AWS::CloudFormation::Stack" + Type: AWS::CloudFormation::Stack Properties: - TemplateURL: !Join - - "" - - - https:// - - !FindInMap - - Configuration - - BaseConfiguration - - CodebaseBucket - - .s3- - - !FindInMap - - Configuration - - BaseConfiguration - - CodebaseBucketRegion - - .amazonaws.com/ - - !FindInMap - - Configuration - - BaseConfiguration - - CodebaseVersion - - /network.yaml + TemplateURL: + Fn::Join: + - "" + - - https:// + - Fn::FindInMap: + - Configuration + - BaseConfiguration + - CodebaseBucket + - .s3- + - Fn::FindInMap: + - Configuration + - BaseConfiguration + - CodebaseBucketRegion + - .amazonaws.com/ + - Fn::FindInMap: + - Configuration + - BaseConfiguration + - CodebaseVersion + - /network.yaml AutoScalingAndLaunchTemplateStack: - Type: "AWS::CloudFormation::Stack" + Type: AWS::CloudFormation::Stack Properties: Parameters: - EC2SecurityGroupId: !GetAtt NetworkResourcesStack.Outputs.EC2SecurityGroupId - VPCPrivateSubnetId: !GetAtt NetworkResourcesStack.Outputs.VPCPrivateSubnetId - VPCPrivateSubnetAvailabilityZone: !GetAtt NetworkResourcesStack.Outputs.VPCPrivateSubnetAvailabilityZone - EC2UserData: !FindInMap - - Configuration - - BaseConfiguration - - UserDataBase64Script - TemplateURL: !Join - - "" - - - https:// - - !FindInMap - - Configuration - - BaseConfiguration - - CodebaseBucket - - .s3- - - !FindInMap - - Configuration - - BaseConfiguration - - CodebaseBucketRegion - - .amazonaws.com/ - - !FindInMap - - Configuration - - BaseConfiguration - - CodebaseVersion - - /autoscaling.yaml + EC2SecurityGroupId: + Fn::GetAtt: + - NetworkResourcesStack + - Outputs.EC2SecurityGroupId + VPCPrivateSubnetId: + Fn::GetAtt: + - NetworkResourcesStack + - Outputs.VPCPrivateSubnetId + VPCPrivateSubnetAvailabilityZone: + Fn::GetAtt: + - NetworkResourcesStack + - Outputs.VPCPrivateSubnetAvailabilityZone + EC2UserData: + Fn::FindInMap: + - Configuration + - BaseConfiguration + - UserDataBase64Script + TemplateURL: + Fn::Join: + - "" + - - https:// + - Fn::FindInMap: + - Configuration + - BaseConfiguration + - CodebaseBucket + - .s3- + - Fn::FindInMap: + - Configuration + - BaseConfiguration + - CodebaseBucketRegion + - .amazonaws.com/ + - Fn::FindInMap: + - Configuration + - BaseConfiguration + - CodebaseVersion + - /autoscaling.yaml S3BucketAndLocalFilesStack: - Type: "AWS::CloudFormation::Stack" + Type: AWS::CloudFormation::Stack Properties: Parameters: S3CodeOriginBucket: - !FindInMap [Configuration, BaseConfiguration, CodebaseBucket] - SimRetrievalLambdaZipPath: !Join - - "" - - - !FindInMap [Configuration, BaseConfiguration, CodebaseVersion] - - / - - !FindInMap [Configuration, BaseConfiguration, SimRetrievalLambdaZipFile] - CreateSimLambdaZipPath: !Join - - "" - - - !FindInMap [Configuration, BaseConfiguration, CodebaseVersion] - - / - - !FindInMap [Configuration, BaseConfiguration, CreateSimLambdaZipFile] - DisableSimLambdaZipPath: !Join - - "" - - - !FindInMap [Configuration, BaseConfiguration, CodebaseVersion] - - / - - !FindInMap [Configuration, BaseConfiguration, DisableSimLambdaZipFile] - DeviceOnboardingLambdaZipPath: !Join - - "" - - - !FindInMap [Configuration, BaseConfiguration, CodebaseVersion] - - / - - !FindInMap [Configuration, BaseConfiguration, DeviceOnboardingLambdaZipFile] - TemplateURL: !Join - - "" - - - https:// - - !FindInMap + Fn::FindInMap: - Configuration - BaseConfiguration - CodebaseBucket - - .s3- - - !FindInMap - - Configuration - - BaseConfiguration - - CodebaseBucketRegion - - .amazonaws.com/ - - !FindInMap - - Configuration - - BaseConfiguration - - CodebaseVersion - - /s3-lambda-code.yaml + SimRetrievalLambdaZipPath: + Fn::Join: + - "" + - - Fn::FindInMap: + - Configuration + - BaseConfiguration + - CodebaseVersion + - / + - Fn::FindInMap: + - Configuration + - BaseConfiguration + - SimRetrievalLambdaZipFile + CreateSimLambdaZipPath: + Fn::Join: + - "" + - - Fn::FindInMap: + - Configuration + - BaseConfiguration + - CodebaseVersion + - / + - Fn::FindInMap: + - Configuration + - BaseConfiguration + - CreateSimLambdaZipFile + DisableSimLambdaZipPath: + Fn::Join: + - "" + - - Fn::FindInMap: + - Configuration + - BaseConfiguration + - CodebaseVersion + - / + - Fn::FindInMap: + - Configuration + - BaseConfiguration + - DisableSimLambdaZipFile + DeviceOnboardingLambdaZipPath: + Fn::Join: + - "" + - - Fn::FindInMap: + - Configuration + - BaseConfiguration + - CodebaseVersion + - / + - Fn::FindInMap: + - Configuration + - BaseConfiguration + - DeviceOnboardingLambdaZipFile + TemplateURL: + Fn::Join: + - "" + - - https:// + - Fn::FindInMap: + - Configuration + - BaseConfiguration + - CodebaseBucket + - .s3- + - Fn::FindInMap: + - Configuration + - BaseConfiguration + - CodebaseBucketRegion + - .amazonaws.com/ + - Fn::FindInMap: + - Configuration + - BaseConfiguration + - CodebaseVersion + - /s3-lambda-code.yaml SecretsManagerStack: - Type: "AWS::CloudFormation::Stack" + Type: AWS::CloudFormation::Stack Properties: Parameters: - ManagementApiUsername: !Ref ManagementApiUsername - ManagementApiPassword: !Ref ManagementApiPassword - OpenvpnOnboardingUsername: !Ref OpenvpnOnboardingUsername - OpenvpnOnboardingPassword: !Ref OpenvpnOnboardingPassword - OpenVPNCredentialsSecretName: !FindInMap [Configuration, BaseConfiguration, OpenVPNCredentialsSecretName] - TemplateURL: !Join - - "" - - - https:// - - !FindInMap - - Configuration - - BaseConfiguration - - CodebaseBucket - - .s3- - - !FindInMap - - Configuration - - BaseConfiguration - - CodebaseBucketRegion - - .amazonaws.com/ - - !FindInMap - - Configuration - - BaseConfiguration - - CodebaseVersion - - /secrets-manager.yaml + ManagementApiUsername: + Ref: ManagementApiUsername + ManagementApiPassword: + Ref: ManagementApiPassword + OpenvpnOnboardingUsername: + Ref: OpenvpnOnboardingUsername + OpenvpnOnboardingPassword: + Ref: OpenvpnOnboardingPassword + OpenVPNCredentialsSecretName: + Fn::FindInMap: + - Configuration + - BaseConfiguration + - OpenVPNCredentialsSecretName + TemplateURL: + Fn::Join: + - "" + - - https:// + - Fn::FindInMap: + - Configuration + - BaseConfiguration + - CodebaseBucket + - .s3- + - Fn::FindInMap: + - Configuration + - BaseConfiguration + - CodebaseBucketRegion + - .amazonaws.com/ + - Fn::FindInMap: + - Configuration + - BaseConfiguration + - CodebaseVersion + - /secrets-manager.yaml LambdaInvokeStack: - Type: "AWS::CloudFormation::Stack" + Type: AWS::CloudFormation::Stack Properties: Parameters: - FunctionName: !GetAtt LambdaSimRetrievalStack.Outputs.FunctionName - TemplateURL: !Join - - "" - - - https:// - - !FindInMap - - Configuration - - BaseConfiguration - - CodebaseBucket - - .s3- - - !FindInMap - - Configuration - - BaseConfiguration - - CodebaseBucketRegion - - .amazonaws.com/ - - !FindInMap - - Configuration - - BaseConfiguration - - CodebaseVersion - - /lambda-invoke.yaml + FunctionName: + Fn::GetAtt: + - LambdaSimRetrievalStack + - Outputs.FunctionName + TemplateURL: + Fn::Join: + - "" + - - https:// + - Fn::FindInMap: + - Configuration + - BaseConfiguration + - CodebaseBucket + - .s3- + - Fn::FindInMap: + - Configuration + - BaseConfiguration + - CodebaseBucketRegion + - .amazonaws.com/ + - Fn::FindInMap: + - Configuration + - BaseConfiguration + - CodebaseVersion + - /lambda-invoke.yaml diff --git a/templates/device-onboarding.yaml b/templates/device-onboarding.yaml index 04eacda..fb49c6a 100644 --- a/templates/device-onboarding.yaml +++ b/templates/device-onboarding.yaml @@ -1,5 +1,5 @@ AWSTemplateFormatVersion: "2010-09-09" -Description: "Create lambda to handle the SIM retrieval process" +Description: Create lambda to handle the SIM retrieval process Parameters: #======================================================= @@ -9,7 +9,7 @@ Parameters: #======================================================= LambdaName: Type: String - Default: "device-onboarding" + Default: device-onboarding Description: Lambda name S3LocalBucketArn: Description: Arn for S3 bucket used to store the lambda code on customers account. @@ -29,7 +29,7 @@ Parameters: AmazonRootCaURL: Description: URL of Amazon Root Certifcate Type: String - Default: "https://www.amazontrust.com/repository/AmazonRootCA1.pem" + Default: https://www.amazontrust.com/repository/AmazonRootCA1.pem IoTCoreEndpointURL: Description: URL of IoT Core Endpoint Type: String @@ -41,76 +41,87 @@ Resources: # #======================================================= LambdaDeviceOnboardingIAMRole: - Type: "AWS::IAM::Role" + Type: AWS::IAM::Role Properties: - Path: "/" - RoleName: !Sub "${LambdaName}-role" + Path: / MaxSessionDuration: 3600 AssumeRolePolicyDocument: Version: "2012-10-17" Statement: - - Effect: "Allow" + - Effect: Allow Principal: Service: - - "lambda.amazonaws.com" - Action: "sts:AssumeRole" + - lambda.amazonaws.com + Action: sts:AssumeRole ManagedPolicyArns: - - "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole Policies: - - PolicyName: !Sub "sim-retrieval-policy-${AWS::Region}" + - PolicyName: + Fn::Sub: sim-retrieval-policy-${AWS::Region} PolicyDocument: Version: "2012-10-17" Statement: - - Effect: "Allow" - Action: "dynamodb:GetItem" - Resource: !Ref "SimTableARN" + - Effect: Allow + Action: dynamodb:GetItem + Resource: + Ref: SimTableARN - Effect: Allow Action: - - 's3:GetObject' - - 's3:ListBucket' - - 's3:GetBucketPolicy' - - 'S3:PutObjectTagging' + - s3:GetObject + - s3:ListBucket + - s3:GetBucketPolicy + - S3:PutObjectTagging Resource: - - !Join - - '' - - - !Ref S3LocalBucketArn - - /* - - !Ref S3LocalBucketArn + - Fn::Sub: ${S3LocalBucketArn}/* + - Ref: S3LocalBucketArn LambdaDeviceOnboardingFunction: - Type: "AWS::Lambda::Function" + Type: AWS::Lambda::Function Properties: Timeout: 30 Description: Device Onboarding function Environment: Variables: - AMAZON_ROOTCA_URL: !Ref "AmazonRootCaURL" - SIMS_TABLE: !Ref "SimTableName" - IOT_CORE_ENDPOINT_URL: !Ref "IoTCoreEndpointURL" - FunctionName: !Ref "LambdaName" - Handler: "index.handler" + AMAZON_ROOTCA_URL: + Ref: AmazonRootCaURL + SIMS_TABLE: + Ref: SimTableName + IOT_CORE_ENDPOINT_URL: + Ref: IoTCoreEndpointURL + FunctionName: + Ref: LambdaName + Handler: index.handler Architectures: - - "x86_64" + - x86_64 Code: - S3Bucket: !Ref "S3LocalBucketName" - S3Key: !Ref "DeviceOnboardingLambdaZipPath" + S3Bucket: + Ref: S3LocalBucketName + S3Key: + Ref: DeviceOnboardingLambdaZipPath MemorySize: 128 - Role: !GetAtt "LambdaDeviceOnboardingIAMRole.Arn" - Runtime: "nodejs14.x" + Role: + Fn::GetAtt: + - LambdaDeviceOnboardingIAMRole + - Arn + Runtime: nodejs14.x TracingConfig: - Mode: "PassThrough" + Mode: Active EphemeralStorage: Size: 512 - + Outputs: #======================================================= - # - # OUTPUTS - # + # + # CloudFormation Outputs + # #======================================================= LambdaNameDeviceOnboarding: Description: The name of the Device Onboarding lambda - Value: !Ref LambdaDeviceOnboardingFunction + Value: + Ref: LambdaDeviceOnboardingFunction LambdaARNDeviceOnboarding: Description: Lambda ARN for Device Onboarding - Value: !GetAtt LambdaDeviceOnboardingFunction.Arn + Value: + Fn::GetAtt: + - LambdaDeviceOnboardingFunction + - Arn diff --git a/templates/disable-sim.yaml b/templates/disable-sim.yaml new file mode 100644 index 0000000..26681fa --- /dev/null +++ b/templates/disable-sim.yaml @@ -0,0 +1,137 @@ +AWSTemplateFormatVersion: "2010-09-09" +Description: Create lambda to handle the process of disable SIMs + +Parameters: + #======================================================= + # + # CloudFormation Parameters + # + #======================================================= + LambdaName: + Type: String + Default: disable-sim + Description: Lambda name + SnsFailureSummaryTopicARN: + Type: String + Description: SNS topic to post summary message on failure case + SnsSuccessSummaryTopicARN: + Type: String + Description: SNS topic to post summary message on success case + SimTableARN: + Description: DynamoDB ARN to store SIMs data. + Type: String + SimsTableName: + Type: String + Default: sim-metastore + Description: DynamoDB table name to store SIMs data + S3LocalBucketArn: + Description: Arn for S3 bucket used to store the lambda code on customers account. + Type: String + S3LocalBucketName: + Description: Name of S3 bucket used to store the lambda code on customers account. + Type: String + DisableSimLambdaZipPath: + Description: Zip path for the compressed folder with the lambda code located in local S3. + Type: String + SqsQueueARN: + Description: SQS queue ARN + Type: String + +Resources: + #======================================================= + # + # Lambda resources + # + #======================================================= + LambdaDisableSimIAMRole: + Type: AWS::IAM::Role + Properties: + Path: / + 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 + Policies: + - PolicyName: + Fn::Sub: disable-sim-policy-${AWS::Region} + PolicyDocument: + Version: 2012-10-17 + Statement: + - Effect: Allow + Action: + - sqs:ReceiveMessage + - sqs:DeleteMessage + - sqs:GetQueueAttributes + - sqs:ChangeMessageVisibility + Resource: + Ref: SqsQueueARN + - Effect: Allow + Action: + - sns:Publish + Resource: + - Ref: SnsSuccessSummaryTopicARN + - Ref: SnsFailureSummaryTopicARN + - Effect: Allow + Action: + - dynamodb:UpdateItem + Resource: + Ref: SimTableARN + - Effect: Allow + Action: + - s3:GetObject + - s3:ListBucket + - s3:GetBucketPolicy + - S3:PutObjectTagging + Resource: + - Fn::Sub: ${S3LocalBucketArn}/* + - Ref: S3LocalBucketArn + + LambdaDisableSimFunction: + Type: AWS::Lambda::Function + Properties: + Description: Disable SIMs in database + Environment: + Variables: + SNS_FAILURE_SUMMARY_TOPIC: + Ref: SnsFailureSummaryTopicARN + SNS_SUCCESS_SUMMARY_TOPIC: + Ref: SnsSuccessSummaryTopicARN + SIMS_TABLE: + Ref: SimsTableName + FunctionName: + Ref: LambdaName + Handler: index.handler + Architectures: + - x86_64 + Code: + S3Bucket: + Ref: S3LocalBucketName + S3Key: + Ref: DisableSimLambdaZipPath + MemorySize: 128 + Role: + Fn::GetAtt: + - LambdaDisableSimIAMRole + - Arn + Runtime: nodejs14.x + TracingConfig: + Mode: Active + EphemeralStorage: + Size: 512 + + LambdaDisableSimFunctionEventSourceMapping: + Type: AWS::Lambda::EventSourceMapping + Properties: + BatchSize: 10 + Enabled: true + EventSourceArn: + Ref: SqsQueueARN + FunctionName: + Ref: LambdaName + DependsOn: LambdaDisableSimFunction diff --git a/templates/iot-core-endpoint-provider.yaml b/templates/iot-core-endpoint-provider.yaml index 237bb4a..abbaa01 100644 --- a/templates/iot-core-endpoint-provider.yaml +++ b/templates/iot-core-endpoint-provider.yaml @@ -1,5 +1,5 @@ AWSTemplateFormatVersion: "2010-09-09" -Description: "Contains custom resource lambda which provides IOT Core Endpoint URL as output" +Description: Contains custom resource lambda which provides IOT Core Endpoint URL as output Resources: #======================================================= # @@ -9,7 +9,7 @@ Resources: LambdaExecutionRole: Type: AWS::IAM::Role Properties: - Path: "/" + Path: / AssumeRolePolicyDocument: Version: "2012-10-17" Statement: @@ -20,9 +20,10 @@ Resources: Action: - sts:AssumeRole ManagedPolicyArns: - - "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole Policies: - - PolicyName: !Sub "iot-core-endpoint-provider-policy-${AWS::Region}" + - PolicyName: + Fn::Sub: iot-core-endpoint-provider-policy-${AWS::Region} PolicyDocument: Version: "2012-10-17" Statement: @@ -40,9 +41,12 @@ Resources: Runtime: nodejs14.x MemorySize: 128 Timeout: 15 - Role: !GetAtt LambdaExecutionRole.Arn + Role: + Fn::GetAtt: + - LambdaExecutionRole + - Arn TracingConfig: - Mode: "PassThrough" + Mode: Active Code: ZipFile: | var aws = require("aws-sdk"); @@ -107,16 +111,22 @@ Resources: request.end(); } IotEndpoint: - Type: "Custom::IotEndpoint" + Type: Custom::IotEndpoint Properties: - ServiceToken: !GetAtt IotEndpointProvider.Arn + ServiceToken: + Fn::GetAtt: + - IotEndpointProvider + - Arn Outputs: #======================================================= # - # OUTPUTS + # CloudFormation Outputs # #======================================================= IotEndpointAddress: - Value: !GetAtt IotEndpoint.IotEndpointAddress + Value: + Fn::GetAtt: + - IotEndpoint + - IotEndpointAddress Description: Retrieved Iot Core Endpoint Url diff --git a/templates/iot-core-policy.yaml b/templates/iot-core-policy.yaml index 36ead8a..13eb5aa 100644 --- a/templates/iot-core-policy.yaml +++ b/templates/iot-core-policy.yaml @@ -1,44 +1,132 @@ AWSTemplateFormatVersion: "2010-09-09" -Description: "Generates IOT Core policy with a given name" +Description: Generates IOT Core policy for Device Onboarding Resources: #======================================================= - # - # IOT Core Policy - # + # + # IOT Core Policy + # #======================================================= - IOTCorePolicy: - Type: AWS::IoT::Policy + + LambdaExecutionRole: + Type: AWS::IAM::Role Properties: - PolicyDocument: + Path: / + AssumeRolePolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow + Principal: + Service: + - lambda.amazonaws.com Action: - - iot:Connect - Resource: - - !Sub arn:aws:iot:${AWS::Region}:${AWS::AccountId}:client/${!iot:Connection.Thing.ThingName} - - Effect: Allow - Action: - - iot:Publish - - iot:Receive - Resource: - - !Sub arn:aws:iot:${AWS::Region}:${AWS::AccountId}:topic/${!iot:Connection.Thing.ThingName}/* - - Effect: Allow - Action: - - iot:Subscribe - Resource: - - !Sub arn:aws:iot:${AWS::Region}:${AWS::AccountId}:topicfilter/${!iot:Connection.Thing.ThingName}/* - PolicyName: !Sub "device-onboarding-${AWS::Region}" + - sts:AssumeRole + ManagedPolicyArns: + - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + Policies: + - PolicyName: + Fn::Sub: iot-core-policy-generator-policy-${AWS::Region} + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: + - iot:CreatePolicy + Resource: + - "*" + + IotPolicyGenerator: + Type: AWS::Lambda::Function + Properties: + FunctionName: IotPolicyGenerator + Handler: index.handler + Runtime: nodejs14.x + MemorySize: 128 + Timeout: 15 + Role: + Fn::GetAtt: + - LambdaExecutionRole + - Arn + TracingConfig: + Mode: Active + Code: + ZipFile: | + const aws = require("aws-sdk"); + const response = require('cfn-response'); + exports.handler = function(event, context) { + console.log("REQUEST RECEIVED:\n" + JSON.stringify(event)); + if (event.RequestType == "Delete") { + response.send(event, context, "SUCCESS"); + return; + } + const policyName = "device-onboarding"; + const region = process.env.AWS_REGION; + const awsAccountId = context.invokedFunctionArn.split(':')[4]; + const policyDocument = `{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "iot:Connect" + ], + "Resource": [ + "arn:aws:iot:${region}:${awsAccountId}:client/\${iot:Connection.Thing.ThingName}" + ] + }, + { + "Effect": "Allow", + "Action": [ + "iot:Publish", + "iot:Receive" + ], + "Resource": [ + "arn:aws:iot:${region}:${awsAccountId}:topic/\${iot:Connection.Thing.ThingName}/*" + ] + }, + { + "Effect": "Allow", + "Action": [ + "iot:Subscribe" + ], + "Resource": [ + "arn:aws:iot:${region}:${awsAccountId}:topicfilter/\${iot:Connection.Thing.ThingName}/*" + ] + } + ] + }`; + console.log("Policy document:\n", policyDocument); + const iot = new aws.Iot(); + iot.createPolicy({policyName, policyDocument}, (err, data) => { + let responseData, responseStatus; + if (err) { + responseStatus = "FAILED"; + responseData = { Error: "createIoTPolicy call failed" }; + console.error(responseData.Error + ":\n", err); + } else { + responseStatus = "SUCCESS"; + responseData = { policyName: data.policyName }; + console.log('Response data: ' + JSON.stringify(responseData)); + } + response.send(event, context, responseStatus, responseData); + }); + }; + IoTPolicy: + Type: Custom::IotPolicyGenerator + Properties: + ServiceToken: + Fn::GetAtt: + - IotPolicyGenerator + - Arn Outputs: #======================================================= - # - # OUTPUTS - # + # + # CloudFormation Outputs + # #======================================================= - IOTCorePolicyArn: - Description: The ARN of the newly created IOT Core Policy - Value: !GetAtt IOTCorePolicy.Arn IOTCorePolicyName: Description: The name of the newly created IOT Core Policy - Value: !GetAtt IOTCorePolicy.Id + Value: + Fn::GetAtt: + - IoTPolicy + - policyName diff --git a/templates/lambda-invoke.yaml b/templates/lambda-invoke.yaml index eecb2a2..df8923e 100644 --- a/templates/lambda-invoke.yaml +++ b/templates/lambda-invoke.yaml @@ -1,5 +1,5 @@ AWSTemplateFormatVersion: "2010-09-09" -Description: "Create lambda to handle the SIM retrieval process" +Description: Create lambda to handle the SIM retrieval process Parameters: #======================================================= @@ -18,26 +18,29 @@ Resources: # #======================================================= LambdaInvokeIAMRole: - Type: "AWS::IAM::Role" + Type: AWS::IAM::Role Properties: - Path: "/" + Path: / AssumeRolePolicyDocument: Version: "2012-10-17" Statement: - - Effect: "Allow" + - Effect: Allow Principal: Service: - - "lambda.amazonaws.com" - Action: "sts:AssumeRole" + - lambda.amazonaws.com + Action: sts:AssumeRole ManagedPolicyArns: - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole - arn:aws:iam::aws:policy/service-role/AWSLambdaRole LambdaInvoke: - Type: "AWS::Lambda::Function" + Type: AWS::Lambda::Function Properties: Runtime: nodejs14.x - Role: !GetAtt "LambdaInvokeIAMRole.Arn" + Role: + Fn::GetAtt: + - LambdaInvokeIAMRole + - Arn Handler: index.handler Code: ZipFile: | @@ -54,7 +57,7 @@ Resources: let responseData = {}; let functionName = event.ResourceProperties.FunctionName; let lambda = new aws.Lambda(); - lambda.invoke({ FunctionName: functionName }, function (err) { + lambda.invoke({ FunctionName: functionName, InvocationType: "Event" }, function (err) { if (err) { responseData = { Error: "Invoke call failed" }; console.log(responseData.Error + ":\n", err); @@ -67,7 +70,11 @@ Resources: Description: Invoke a function during stack creation and return cfn-response LambdaFirstInvoke: - Type: "AWS::CloudFormation::CustomResource" + Type: AWS::CloudFormation::CustomResource Properties: - ServiceToken: !GetAtt LambdaInvoke.Arn - FunctionName: !Ref FunctionName + ServiceToken: + Fn::GetAtt: + - LambdaInvoke + - Arn + FunctionName: + Ref: FunctionName diff --git a/templates/network.yaml b/templates/network.yaml index 750cdd7..60722ea 100644 --- a/templates/network.yaml +++ b/templates/network.yaml @@ -1,5 +1,5 @@ AWSTemplateFormatVersion: "2010-09-09" -Description: "Network configuration for openvpn onboarding" +Description: Network configuration for openvpn onboarding Resources: #======================================================= # @@ -7,119 +7,141 @@ Resources: # #======================================================= EC2VPC: - Type: "AWS::EC2::VPC" + Type: AWS::EC2::VPC Properties: - CidrBlock: "10.0.0.0/24" + CidrBlock: 10.0.0.0/24 EnableDnsSupport: true EnableDnsHostnames: false - InstanceTenancy: "default" + InstanceTenancy: default Tags: - - Key: "Name" - Value: "openvpn-onboarding-vpc" + - Key: Name + Value: openvpn-onboarding-vpc EC2SubnetPrivate: - Type: "AWS::EC2::Subnet" + Type: AWS::EC2::Subnet Properties: - CidrBlock: "10.0.0.0/25" - VpcId: !Ref EC2VPC + CidrBlock: 10.0.0.0/25 + VpcId: + Ref: EC2VPC MapPublicIpOnLaunch: false Tags: - - Key: "Name" - Value: "openvpn-onboarding-private-subnet" + - Key: Name + Value: openvpn-onboarding-private-subnet EC2SubnetPublic: - Type: "AWS::EC2::Subnet" + Type: AWS::EC2::Subnet Properties: - AvailabilityZone: !GetAtt EC2SubnetPrivate.AvailabilityZone - CidrBlock: "10.0.0.128/25" - VpcId: !Ref EC2VPC + AvailabilityZone: + Fn::GetAtt: + - EC2SubnetPrivate + - AvailabilityZone + CidrBlock: 10.0.0.128/25 + VpcId: + Ref: EC2VPC MapPublicIpOnLaunch: false Tags: - - Key: "Name" - Value: "openvpn-onboarding-public-subnet" + - Key: Name + Value: openvpn-onboarding-public-subnet EC2RouteTablePrivate: - Type: "AWS::EC2::RouteTable" + Type: AWS::EC2::RouteTable Properties: - VpcId: !Ref EC2VPC + VpcId: + Ref: EC2VPC Tags: - - Key: "Name" - Value: "openvpn-onboarding-private-rtb" + - Key: Name + Value: openvpn-onboarding-private-rtb EC2RouteTablePublic: - Type: "AWS::EC2::RouteTable" + Type: AWS::EC2::RouteTable Properties: - VpcId: !Ref EC2VPC + VpcId: + Ref: EC2VPC Tags: - - Key: "Name" - Value: "openvpn-onboarding-public-rtb" + - Key: Name + Value: openvpn-onboarding-public-rtb EC2RoutePrivate: - Type: "AWS::EC2::Route" + Type: AWS::EC2::Route Properties: - DestinationCidrBlock: "0.0.0.0/0" - NatGatewayId: !Ref EC2NatGateway - RouteTableId: !Ref EC2RouteTablePrivate + DestinationCidrBlock: 0.0.0.0/0 + NatGatewayId: + Ref: EC2NatGateway + RouteTableId: + Ref: EC2RouteTablePrivate EC2RoutePublic: - Type: "AWS::EC2::Route" - DependsOn : AttachGateway + Type: AWS::EC2::Route + DependsOn: AttachGateway Properties: - DestinationCidrBlock: "0.0.0.0/0" - GatewayId: !Ref EC2InternetGateway - RouteTableId: !Ref EC2RouteTablePublic + DestinationCidrBlock: 0.0.0.0/0 + GatewayId: + Ref: EC2InternetGateway + RouteTableId: + Ref: EC2RouteTablePublic EC2EIP: - Type: "AWS::EC2::EIP" + Type: AWS::EC2::EIP Properties: - Domain: "vpc" + Domain: vpc Tags: - - Key: "Name" - Value: "openvpn-onboarding-elastic-ip" + - Key: Name + Value: openvpn-onboarding-elastic-ip EC2NatGateway: - Type: "AWS::EC2::NatGateway" + Type: AWS::EC2::NatGateway Properties: - SubnetId: !Ref EC2SubnetPublic + SubnetId: + Ref: EC2SubnetPublic Tags: - - Key: "Name" - Value: "openvpn-onboarding-nat-gw" - AllocationId: !GetAtt EC2EIP.AllocationId + - Key: Name + Value: openvpn-onboarding-nat-gw + AllocationId: + Fn::GetAtt: + - EC2EIP + - AllocationId EC2InternetGateway: - Type: "AWS::EC2::InternetGateway" + Type: AWS::EC2::InternetGateway Properties: Tags: - - Key: "Name" - Value: "openvpn-onboarding-igw" + - Key: Name + Value: openvpn-onboarding-igw AttachGateway: Type: AWS::EC2::VPCGatewayAttachment Properties: - VpcId: !Ref EC2VPC - InternetGatewayId: !Ref EC2InternetGateway + VpcId: + Ref: EC2VPC + InternetGatewayId: + Ref: EC2InternetGateway EC2SecurityGroup: - Type: "AWS::EC2::SecurityGroup" + Type: AWS::EC2::SecurityGroup Properties: - GroupDescription: "Security group for openvpn onboarding" - GroupName: "openvpn-onboarding-security-group" - VpcId: !Ref EC2VPC + GroupDescription: Security group for openvpn onboarding + GroupName: openvpn-onboarding-security-group + VpcId: + Ref: EC2VPC SecurityGroupEgress: - - CidrIp: "0.0.0.0/0" - IpProtocol: "-1" + - CidrIp: 0.0.0.0/0 + IpProtocol: -1 - EC2SubnetRouteTableAssociation: - Type: "AWS::EC2::SubnetRouteTableAssociation" + EC2SubnetRouteTableAssociationPrivate: + Type: AWS::EC2::SubnetRouteTableAssociation Properties: - RouteTableId: !Ref EC2RouteTablePrivate - SubnetId: !Ref EC2SubnetPrivate + RouteTableId: + Ref: EC2RouteTablePrivate + SubnetId: + Ref: EC2SubnetPrivate - EC2SubnetRouteTableAssociation2: - Type: "AWS::EC2::SubnetRouteTableAssociation" + EC2SubnetRouteTableAssociationPublic: + Type: AWS::EC2::SubnetRouteTableAssociation Properties: - RouteTableId: !Ref EC2RouteTablePublic - SubnetId: !Ref EC2SubnetPublic + RouteTableId: + Ref: EC2RouteTablePublic + SubnetId: + Ref: EC2SubnetPublic Outputs: #======================================================= # @@ -127,26 +149,26 @@ Outputs: # #======================================================= EC2SecurityGroupId: - Description: "Allow access for ssh and http port 8080" + Description: Security group with no specific rules Value: Fn::GetAtt: - - "EC2SecurityGroup" - - "GroupId" + - EC2SecurityGroup + - GroupId VPCPrivateSubnetId: - Description: "OpenVPN Private Subnet" + Description: OpenVPN Private Subnet Value: Fn::GetAtt: - - "EC2SubnetPrivate" - - "SubnetId" + - EC2SubnetPrivate + - SubnetId VPCPrivateSubnetAvailabilityZone: - Description: "OpenVPN Private Subnet Availability Zone" + Description: OpenVPN Private Subnet Availability Zone Value: Fn::GetAtt: - - "EC2SubnetPrivate" - - "AvailabilityZone" + - EC2SubnetPrivate + - AvailabilityZone PublicIpAddress: - Description: "Public IP address assigned to this VPC" - Value: + Description: Public IP address assigned to this VPC + Value: Fn::GetAtt: - - "EC2EIP" - - "PublicIp" + - EC2EIP + - PublicIp diff --git a/templates/s3-lambda-code.yaml b/templates/s3-lambda-code.yaml index f0a7a38..4934170 100644 --- a/templates/s3-lambda-code.yaml +++ b/templates/s3-lambda-code.yaml @@ -1,5 +1,5 @@ AWSTemplateFormatVersion: "2010-09-09" -Description: "S3 Bucket for openvpn onboarding resources" +Description: S3 Bucket for openvpn onboarding resources Parameters: #======================================================= # @@ -16,6 +16,9 @@ Parameters: CreateSimLambdaZipPath: Description: Zip path for the compressed folder with the create sim lambda code. Type: String + DisableSimLambdaZipPath: + Description: Zip path for the compressed folder with the disable sim lambda code. + Type: String DeviceOnboardingLambdaZipPath: Description: Zip path for the compressed folder with the device onboarding lambda code. Type: String @@ -26,7 +29,7 @@ Resources: # #======================================================= CodeBucket: - Type: "AWS::S3::Bucket" + Type: AWS::S3::Bucket DeletionPolicy: Delete Properties: BucketEncryption: @@ -35,56 +38,48 @@ Resources: SSEAlgorithm: AES256 DownloadCodeIAMRole: - Type: "AWS::IAM::Role" + Type: AWS::IAM::Role Properties: - Path: "/" + Path: / AssumeRolePolicyDocument: Version: "2012-10-17" Statement: - - Effect: "Allow" + - Effect: Allow Principal: Service: - - "lambda.amazonaws.com" - Action: "sts:AssumeRole" + - lambda.amazonaws.com + Action: sts:AssumeRole ManagedPolicyArns: - - "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole Policies: - - PolicyName: "name" + - PolicyName: name PolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Action: - - "s3:DeleteObject" - - "s3:GetObject" - - "s3:ListBucket" - - "s3:PutObject" - - "s3:GetBucketPolicy" - - "S3:PutObjectTagging" + - s3:DeleteObject + - s3:GetObject + - s3:ListBucket + - s3:PutObject + - s3:GetBucketPolicy + - S3:PutObjectTagging Resource: - - !Join - - "" - - - !GetAtt "CodeBucket.Arn" - - /* - - !GetAtt "CodeBucket.Arn" + - Fn::Sub: ${CodeBucket.Arn}/* + - Fn::GetAtt: + - CodeBucket + - Arn - Effect: Allow Action: - - "s3:GetObject" - - "S3:GetObjectTagging" - - "s3:ListBucket" + - s3:GetObject + - S3:GetObjectTagging + - s3:ListBucket Resource: - - !Join - - "" - - - "arn:aws:s3:::" - - !Ref S3CodeOriginBucket - - !Join - - "" - - - "arn:aws:s3:::" - - !Ref S3CodeOriginBucket - - /* + - Fn::Sub: arn:aws:s3:::${S3CodeOriginBucket} + - Fn::Sub: arn:aws:s3:::${S3CodeOriginBucket}/* DownloadCodeLambdaFunction: - Type: "AWS::Lambda::Function" + Type: AWS::Lambda::Function Properties: Code: ZipFile: | @@ -134,38 +129,48 @@ Resources: timer.cancel() cfnresponse.send(event, context, status, {}, None) Handler: index.handler - MemorySize: 256 - Role: !GetAtt "DownloadCodeIAMRole.Arn" + MemorySize: 128 + Role: + Fn::GetAtt: + - DownloadCodeIAMRole + - Arn Runtime: python3.9 Timeout: 240 TracingConfig: Mode: Active LambdaCodeDownloadService: - Type: "AWS::CloudFormation::CustomResource" + Type: AWS::CloudFormation::CustomResource DependsOn: DownloadCodeIAMRole - Version: "1.0" + Version: 1.0 Properties: - ServiceToken: !GetAtt "DownloadCodeLambdaFunction.Arn" - DestBucket: !Ref CodeBucket - SourceBucket: !Ref S3CodeOriginBucket + ServiceToken: + Fn::GetAtt: + - DownloadCodeLambdaFunction + - Arn + DestBucket: + Ref: CodeBucket + SourceBucket: + Ref: S3CodeOriginBucket Objects: - - !Ref "SimRetrievalLambdaZipPath" - - !Ref "CreateSimLambdaZipPath" - - !Ref "DeviceOnboardingLambdaZipPath" + - Ref: SimRetrievalLambdaZipPath + - Ref: CreateSimLambdaZipPath + - Ref: DisableSimLambdaZipPath + - Ref: DeviceOnboardingLambdaZipPath Outputs: #======================================================= # - # S3 Buckets Outputs + # CloudFormation Outputs # #======================================================= LocalBucketArn: Description: Arn for Code Bucket S3 Value: Fn::GetAtt: - - "CodeBucket" - - "Arn" + - CodeBucket + - Arn LocalBucketName: Description: Name for Code Bucket S3 - Value: !Ref CodeBucket + Value: + Ref: CodeBucket diff --git a/templates/secrets-manager.yaml b/templates/secrets-manager.yaml index 70a51fd..85c35f0 100644 --- a/templates/secrets-manager.yaml +++ b/templates/secrets-manager.yaml @@ -1,5 +1,5 @@ AWSTemplateFormatVersion: "2010-09-09" -Description: "Generates Secrets Manager entries" +Description: Generates Secrets Manager entries Parameters: #======================================================= # @@ -9,21 +9,21 @@ Parameters: ManagementApiUsername: Type: String Description: Management API username - MinLength: "1" + MinLength: 1 ManagementApiPassword: Type: String Description: Management API password - MinLength: "1" + MinLength: 1 NoEcho: true OpenvpnOnboardingUsername: Type: String Description: Username for OpenVPN - MinLength: "1" + MinLength: 1 OpenvpnOnboardingPassword: Type: String NoEcho: true Description: Password for OpenVPN - MinLength: "1" + MinLength: 1 OpenVPNCredentialsSecretName: Type: String Description: OpenVPN credentials secret name @@ -38,13 +38,16 @@ Resources: Properties: Name: open-source-device-onboarding-management-api-credentials Description: Management API credentials - SecretString: !Sub '{"username":"${ManagementApiUsername}", "password": "${ManagementApiPassword}"}' + SecretString: + Fn::Sub: '{"username":"${ManagementApiUsername}", "password": "${ManagementApiPassword}"}' OpenvpnOnboardingCredentialsSecret: Type: AWS::SecretsManager::Secret Properties: - Name: !Ref OpenVPNCredentialsSecretName + Name: + Ref: OpenVPNCredentialsSecretName Description: OpenVPN credentials - SecretString: !Sub '{"username":"${OpenvpnOnboardingUsername}", "password": "${OpenvpnOnboardingPassword}"}' + SecretString: + Fn::Sub: '{"username":"${OpenvpnOnboardingUsername}", "password": "${OpenvpnOnboardingPassword}"}' Outputs: #======================================================= # @@ -52,9 +55,11 @@ Outputs: # #======================================================= ManagementApiCredentialsSecretARN: - Description: "Secret ARN of Management API credentials" - Value: !Ref ManagementApiCredentialsSecret + Description: Secret ARN of Management API credentials + Value: + Ref: ManagementApiCredentialsSecret OpenvpnOnboardingCredentialsSecretARN: - Description: "Secret ARN of OpenVpn credentials" - Value: !Ref OpenvpnOnboardingCredentialsSecret + Description: Secret ARN of OpenVpn credentials + Value: + Ref: OpenvpnOnboardingCredentialsSecret diff --git a/templates/sim-retrieval.yaml b/templates/sim-retrieval.yaml index 8fb53af..eb212af 100644 --- a/templates/sim-retrieval.yaml +++ b/templates/sim-retrieval.yaml @@ -1,5 +1,5 @@ AWSTemplateFormatVersion: "2010-09-09" -Description: "Create lambda to handle the SIM retrieval process" +Description: Create lambda to handle the SIM retrieval process Parameters: #======================================================= @@ -9,11 +9,11 @@ Parameters: #======================================================= LambdaName: Type: String - Default: "sim-retrieval" + Default: sim-retrieval Description: Lambda name ManagementApiURL: Type: String - Default: "https://api-prod.1nce.com/management-api" + Default: https://api-prod.1nce.com/management-api Description: Management API URL ManagementApiCredentialsSecretARN: Type: String @@ -34,27 +34,21 @@ Parameters: SimCreateQueueARN: Description: SQS queue ARN to create SIMs Type: String - Default: "" SimCreateQueueURL: Description: SQS queue URL to create SIMs Type: String - Default: "" - SimDeleteQueueARN: - Description: SQS queue ARN to delete SIMs + SimDisableQueueARN: + Description: SQS queue ARN to disable SIMs Type: String - Default: "" - SimDeleteQueueURL: - Description: SQS queue URL to delete SIMs + SimDisableQueueURL: + Description: SQS queue URL to disable SIMs Type: String - Default: "" SimTableARN: Description: Dynamo DB SIMs table ARN Type: String - Default: "" SimTableName: Description: Dynamo DB SIMs table name Type: String - Default: "" SnsFailureSummaryTopicARN: Type: String Description: SNS topic to post summary message on failure case @@ -62,39 +56,39 @@ Parameters: Mappings: RegionToLayerArnMap: us-east-1: - LayerArn: "arn:aws:lambda:us-east-1:177933569100:layer:AWS-Parameters-and-Secrets-Lambda-Extension:4" + LayerArn: arn:aws:lambda:us-east-1:177933569100:layer:AWS-Parameters-and-Secrets-Lambda-Extension:4 us-east-2: - LayerArn: "arn:aws:lambda:us-east-2:590474943231:layer:AWS-Parameters-and-Secrets-Lambda-Extension:4" + LayerArn: arn:aws:lambda:us-east-2:590474943231:layer:AWS-Parameters-and-Secrets-Lambda-Extension:4 us-west-1: - LayerArn: "arn:aws:lambda:us-west-1:997803712105:layer:AWS-Parameters-and-Secrets-Lambda-Extension:4" + LayerArn: arn:aws:lambda:us-west-1:997803712105:layer:AWS-Parameters-and-Secrets-Lambda-Extension:4 us-west-2: - LayerArn: "arn:aws:lambda:us-west-2:345057560386:layer:AWS-Parameters-and-Secrets-Lambda-Extension:4" + LayerArn: arn:aws:lambda:us-west-2:345057560386:layer:AWS-Parameters-and-Secrets-Lambda-Extension:4 ap-south-1: - LayerArn: "arn:aws:lambda:ap-south-1:176022468876:layer:AWS-Parameters-and-Secrets-Lambda-Extension:4" + LayerArn: arn:aws:lambda:ap-south-1:176022468876:layer:AWS-Parameters-and-Secrets-Lambda-Extension:4 ap-northeast-3: - LayerArn: "arn:aws:lambda:ap-northeast-3:576959938190:layer:AWS-Parameters-and-Secrets-Lambda-Extension:4" + LayerArn: arn:aws:lambda:ap-northeast-3:576959938190:layer:AWS-Parameters-and-Secrets-Lambda-Extension:4 ap-northeast-2: - LayerArn: "arn:aws:lambda:ap-northeast-2:738900069198:layer:AWS-Parameters-and-Secrets-Lambda-Extension:4" + LayerArn: arn:aws:lambda:ap-northeast-2:738900069198:layer:AWS-Parameters-and-Secrets-Lambda-Extension:4 ap-southeast-1: - LayerArn: "arn:aws:lambda:ap-southeast-1:044395824272:layer:AWS-Parameters-and-Secrets-Lambda-Extension:4" + LayerArn: arn:aws:lambda:ap-southeast-1:044395824272:layer:AWS-Parameters-and-Secrets-Lambda-Extension:4 ap-southeast-2: - LayerArn: "arn:aws:lambda:ap-southeast-2:665172237481:layer:AWS-Parameters-and-Secrets-Lambda-Extension:4" + LayerArn: arn:aws:lambda:ap-southeast-2:665172237481:layer:AWS-Parameters-and-Secrets-Lambda-Extension:4 ap-northeast-1: - LayerArn: "arn:aws:lambda:ap-northeast-1:133490724326:layer:AWS-Parameters-and-Secrets-Lambda-Extension:4" + LayerArn: arn:aws:lambda:ap-northeast-1:133490724326:layer:AWS-Parameters-and-Secrets-Lambda-Extension:4 ca-central-1: - LayerArn: "arn:aws:lambda:ca-central-1:200266452380:layer:AWS-Parameters-and-Secrets-Lambda-Extension:4" + LayerArn: arn:aws:lambda:ca-central-1:200266452380:layer:AWS-Parameters-and-Secrets-Lambda-Extension:4 eu-central-1: - LayerArn: "arn:aws:lambda:eu-central-1:187925254637:layer:AWS-Parameters-and-Secrets-Lambda-Extension:4" + LayerArn: arn:aws:lambda:eu-central-1:187925254637:layer:AWS-Parameters-and-Secrets-Lambda-Extension:4 eu-west-1: - LayerArn: "arn:aws:lambda:eu-west-1:015030872274:layer:AWS-Parameters-and-Secrets-Lambda-Extension:4" + LayerArn: arn:aws:lambda:eu-west-1:015030872274:layer:AWS-Parameters-and-Secrets-Lambda-Extension:4 eu-west-2: - LayerArn: "arn:aws:lambda:eu-west-2:133256977650:layer:AWS-Parameters-and-Secrets-Lambda-Extension:4" + LayerArn: arn:aws:lambda:eu-west-2:133256977650:layer:AWS-Parameters-and-Secrets-Lambda-Extension:4 eu-west-3: - LayerArn: "arn:aws:lambda:eu-west-3:780235371811:layer:AWS-Parameters-and-Secrets-Lambda-Extension:4" + LayerArn: arn:aws:lambda:eu-west-3:780235371811:layer:AWS-Parameters-and-Secrets-Lambda-Extension:4 eu-north-1: - LayerArn: "arn:aws:lambda:eu-north-1:427196147048:layer:AWS-Parameters-and-Secrets-Lambda-Extension:4" + LayerArn: arn:aws:lambda:eu-north-1:427196147048:layer:AWS-Parameters-and-Secrets-Lambda-Extension:4 sa-east-1: - LayerArn: "arn:aws:lambda:sa-east-1:933737806257:layer:AWS-Parameters-and-Secrets-Lambda-Extension:4" + LayerArn: arn:aws:lambda:sa-east-1:933737806257:layer:AWS-Parameters-and-Secrets-Lambda-Extension:4 Resources: #======================================================= @@ -103,102 +97,121 @@ Resources: # #======================================================= LambdaSimRetrievalIAMRole: - Type: "AWS::IAM::Role" + Type: AWS::IAM::Role Properties: - Path: "/" + Path: / AssumeRolePolicyDocument: Version: "2012-10-17" Statement: - - Effect: "Allow" + - Effect: Allow Principal: Service: - - "lambda.amazonaws.com" - Action: "sts:AssumeRole" + - lambda.amazonaws.com + Action: sts:AssumeRole ManagedPolicyArns: - - "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole Policies: - - PolicyName: !Sub "sim-retrieval-policy-${AWS::Region}" + - PolicyName: + Fn::Sub: sim-retrieval-policy-${AWS::Region} PolicyDocument: Version: "2012-10-17" Statement: - - Effect: "Allow" - Action: "sqs:SendMessage" + - Effect: Allow + Action: sqs:SendMessage + Resource: + - Ref: SimCreateQueueARN + - Ref: SimDisableQueueARN + - Effect: Allow + Action: dynamodb:Scan Resource: - - !Ref "SimCreateQueueARN" - - !Ref "SimDeleteQueueARN" - - Effect: "Allow" - Action: "dynamodb:Scan" - Resource: !Ref "SimTableARN" + Ref: SimTableARN - Effect: Allow Action: - - "s3:GetObject" - - "s3:ListBucket" - - "s3:GetBucketPolicy" - - "S3:PutObjectTagging" + - s3:GetObject + - s3:ListBucket + - s3:GetBucketPolicy + - S3:PutObjectTagging + Resource: + - Fn::Sub: ${S3LocalBucketArn}/* + - Ref: S3LocalBucketArn + - Effect: Allow + Action: secretsmanager:GetSecretValue Resource: - - !Join - - "" - - - !Ref S3LocalBucketArn - - /* - - !Ref S3LocalBucketArn - - Effect: "Allow" - Action: "secretsmanager:GetSecretValue" - Resource: !Ref "ManagementApiCredentialsSecretARN" + Ref: ManagementApiCredentialsSecretARN - Effect: Allow Action: - sns:Publish Resource: - - !Ref SnsFailureSummaryTopicARN + - Ref: SnsFailureSummaryTopicARN LambdaSimRetrievalSchedule: - Type: "AWS::Events::Rule" + Type: AWS::Events::Rule Properties: Description: A crontab schedule for the Lambda function - ScheduleExpression: !Ref "LambdaCron" + ScheduleExpression: + Ref: LambdaCron State: ENABLED Targets: - - Arn: !Sub ${LambdaSimRetrievalFunction.Arn} + - Arn: + Fn::Sub: ${LambdaSimRetrievalFunction.Arn} Id: LambdaSimRetrievalSchedule LambdaSimRetrievalSchedulePermission: - Type: "AWS::Lambda::Permission" + Type: AWS::Lambda::Permission Properties: - Action: "lambda:InvokeFunction" - FunctionName: !Sub ${LambdaSimRetrievalFunction.Arn} - Principal: "events.amazonaws.com" - SourceArn: !Sub ${LambdaSimRetrievalSchedule.Arn} + Action: lambda:InvokeFunction + FunctionName: + Fn::Sub: ${LambdaSimRetrievalFunction.Arn} + Principal: events.amazonaws.com + SourceArn: + Fn::Sub: ${LambdaSimRetrievalSchedule.Arn} LambdaSimRetrievalFunction: - Type: "AWS::Lambda::Function" + Type: AWS::Lambda::Function Properties: Timeout: 900 Description: SIMs retrieval function from 1nce API Environment: Variables: - MANAGEMENT_API_URL: !Ref "ManagementApiURL" - MANAGEMENT_API_CREDENTIALS_SECRET_ARN: !Ref "ManagementApiCredentialsSecretARN" - SIM_CREATE_QUEUE_URL: !Ref "SimCreateQueueURL" - SIM_DELETE_QUEUE_URL: !Ref "SimDeleteQueueURL" - SIMS_TABLE: !Ref "SimTableName" + MANAGEMENT_API_URL: + Ref: ManagementApiURL + MANAGEMENT_API_CREDENTIALS_SECRET_ARN: + Ref: ManagementApiCredentialsSecretARN + SIM_CREATE_QUEUE_URL: + Ref: SimCreateQueueURL + SIM_DISABLE_QUEUE_URL: + Ref: SimDisableQueueURL + SIMS_TABLE: + Ref: SimTableName PARAMETERS_SECRETS_EXTENSION_CACHE_ENABLED: true # setting to false should force to refetch secrets SECRETS_MANAGER_TTL: 300 # TTL of a secret in the cache in seconds - SNS_FAILURE_SUMMARY_TOPIC: !Ref "SnsFailureSummaryTopicARN" - FunctionName: !Ref "LambdaName" - Handler: "index.handler" + SNS_FAILURE_SUMMARY_TOPIC: + Ref: SnsFailureSummaryTopicARN + FunctionName: + Ref: LambdaName + Handler: index.handler Architectures: - - "x86_64" + - x86_64 Code: - S3Bucket: !Ref "S3LocalBucketName" - S3Key: !Ref "SimRetrievalLambdaZipPath" + S3Bucket: + Ref: S3LocalBucketName + S3Key: + Ref: SimRetrievalLambdaZipPath MemorySize: 128 - Role: !GetAtt "LambdaSimRetrievalIAMRole.Arn" - Runtime: "nodejs14.x" + Role: + Fn::GetAtt: + - LambdaSimRetrievalIAMRole + - Arn + Runtime: nodejs14.x TracingConfig: - Mode: "PassThrough" + Mode: Active EphemeralStorage: Size: 512 Layers: - - !FindInMap [RegionToLayerArnMap, !Ref "AWS::Region", LayerArn] + - Fn::FindInMap: + - RegionToLayerArnMap + - Ref: AWS::Region + - LayerArn Outputs: #======================================================= @@ -207,5 +220,6 @@ Outputs: # #======================================================= FunctionName: - Description: "Sim Retrieval Function Name" - Value: !Ref LambdaSimRetrievalFunction + Description: Sim Retrieval Function Name + Value: + Ref: LambdaSimRetrievalFunction diff --git a/templates/sim-table.yaml b/templates/sim-table.yaml index abd7786..cff399a 100644 --- a/templates/sim-table.yaml +++ b/templates/sim-table.yaml @@ -1,45 +1,50 @@ AWSTemplateFormatVersion: "2010-09-09" -Description: "Generates SIM table with a given name" +Description: Generates SIM table with a given name Parameters: - ########################################################################## - # # - # PARAMETERS # - # # - ########################################################################## + #======================================================= + # + # CloudFormation Parameters + # + #======================================================= SimTableName: Type: String - Default: "sim-metastore" + Default: sim-metastore Description: SIM Table Name Resources: - ########################################################################## - # # - # DYNAMO TABLE # - # # - ########################################################################## + #======================================================= + # + # Dynamo SIM table resources + # + #======================================================= SimTable: Type: AWS::DynamoDB::Table Properties: AttributeDefinitions: - - AttributeName: "PK" - AttributeType: "S" - - AttributeName: "SK" - AttributeType: "S" + - AttributeName: PK + AttributeType: S + - AttributeName: SK + AttributeType: S KeySchema: - - AttributeName: "PK" - KeyType: "HASH" - - AttributeName: "SK" - KeyType: "RANGE" - BillingMode: "PAY_PER_REQUEST" - TableName: !Ref SimTableName + - AttributeName: PK + KeyType: HASH + - AttributeName: SK + KeyType: RANGE + BillingMode: PAY_PER_REQUEST + TableName: + Ref: SimTableName Outputs: - ########################################################################## - # # - # OUTPUTS # - # # - ########################################################################## + #======================================================= + # + # CloudFormation Outputs + # + #======================================================= SimTableName: Description: The name of the newly create SIM Table - Value: !Ref SimTable + Value: + Ref: SimTable SimTableArn: Description: The ARN of the newly create SIM Table - Value: !GetAtt SimTable.Arn + Value: + Fn::GetAtt: + - SimTable + - Arn diff --git a/templates/sns.yaml b/templates/sns.yaml index de28e81..fb7b64d 100644 --- a/templates/sns.yaml +++ b/templates/sns.yaml @@ -1,5 +1,5 @@ AWSTemplateFormatVersion: "2010-09-09" -Description: "SNS topics for openvpn onboarding notifications" +Description: SNS topics for openvpn onboarding notifications Resources: #======================================================= # @@ -7,23 +7,23 @@ Resources: # #======================================================= SNSFailureTopic: - Type: "AWS::SNS::Topic" + Type: AWS::SNS::Topic Properties: - TopicName: "onboarding-failure" + TopicName: onboarding-failure SNSSuccessTopic: - Type: "AWS::SNS::Topic" + Type: AWS::SNS::Topic Properties: - TopicName: "onboarding-success" + TopicName: onboarding-success SNSTopicPolicy: - Type: "AWS::SNS::TopicPolicy" + Type: AWS::SNS::TopicPolicy Properties: PolicyDocument: Version: "2012-10-17" Statement: - - Effect: "Allow" - Sid: "SNS Failure Topic Sid" + - Effect: Allow + Sid: SNS Failure Topic Sid Action: - SNS:GetTopicAttributes - SNS:SetTopicAttributes @@ -34,14 +34,16 @@ Resources: - SNS:ListSubscriptionsByTopic - SNS:Publish Principal: - AWS: !Sub "${AWS::AccountId}" + AWS: + Fn::Sub: ${AWS::AccountId} Resource: - - !Ref SNSFailureTopic + - Ref: SNSFailureTopic Condition: StringEquals: - AWS:SourceOwner: !Sub "${AWS::AccountId}" - - Effect: "Allow" - Sid: "SNS Success Topic Sid" + AWS:SourceOwner: + Fn::Sub: ${AWS::AccountId} + - Effect: Allow + Sid: SNS Success Topic Sid Action: - SNS:GetTopicAttributes - SNS:SetTopicAttributes @@ -52,42 +54,44 @@ Resources: - SNS:ListSubscriptionsByTopic - SNS:Publish Principal: - AWS: !Sub "${AWS::AccountId}" + AWS: + Fn::Sub: ${AWS::AccountId} Resource: - - !Ref SNSSuccessTopic + - Ref: SNSSuccessTopic Condition: StringEquals: - AWS:SourceOwner: !Sub "${AWS::AccountId}" + AWS:SourceOwner: + Fn::Sub: ${AWS::AccountId} Topics: - - !Ref SNSFailureTopic - - !Ref SNSSuccessTopic + - Ref: SNSFailureTopic + - Ref: SNSSuccessTopic Outputs: #======================================================= # - # SNS Outputs + # CloudFormation Outputs # #======================================================= SNSFailureTopicArn: Description: Arn for SNS where the onboarding failures will be published Value: Fn::GetAtt: - - "SNSFailureTopic" - - "TopicArn" + - SNSFailureTopic + - TopicArn SNSSuccessTopicArn: Description: Arn for SNS where the onboarding success events will be published Value: Fn::GetAtt: - - "SNSSuccessTopic" - - "TopicArn" + - SNSSuccessTopic + - TopicArn SNSFailureTopicName: Description: Name for SNS where the onboarding failures will be published Value: Fn::GetAtt: - - "SNSFailureTopic" - - "TopicName" + - SNSFailureTopic + - TopicName SNSSuccessTopicName: Description: Name for SNS where the onboarding success events will be published Value: Fn::GetAtt: - - "SNSSuccessTopic" - - "TopicName" + - SNSSuccessTopic + - TopicName diff --git a/templates/sqs.yaml b/templates/sqs.yaml index 450bb70..b2d9c50 100644 --- a/templates/sqs.yaml +++ b/templates/sqs.yaml @@ -1,69 +1,69 @@ AWSTemplateFormatVersion: "2010-09-09" -Description: "Generates sim create and delete SQS queues" +Description: Generates sim create and disable SQS queues Resources: #======================================================= # # SQS Queue resources # #======================================================= - SimDeleteQueue: - Type: "AWS::SQS::Queue" + SimDisableQueue: + Type: AWS::SQS::Queue Properties: - ContentBasedDeduplication: "false" - DelaySeconds: "0" - FifoQueue: "true" - MaximumMessageSize: "262144" - MessageRetentionPeriod: "345600" - ReceiveMessageWaitTimeSeconds: "0" - VisibilityTimeout: "30" - QueueName: "sims-delete.fifo" + ContentBasedDeduplication: false + DelaySeconds: 0 + FifoQueue: true + MaximumMessageSize: 262144 + MessageRetentionPeriod: 345600 + ReceiveMessageWaitTimeSeconds: 0 + VisibilityTimeout: 30 + QueueName: sims-disable.fifo SimCreateQueue: - Type: "AWS::SQS::Queue" + Type: AWS::SQS::Queue Properties: - ContentBasedDeduplication: "false" - DelaySeconds: "0" - FifoQueue: "true" - MaximumMessageSize: "262144" - MessageRetentionPeriod: "345600" - ReceiveMessageWaitTimeSeconds: "0" - VisibilityTimeout: "30" - QueueName: "sims-create.fifo" + ContentBasedDeduplication: false + DelaySeconds: 0 + FifoQueue: true + MaximumMessageSize: 262144 + MessageRetentionPeriod: 345600 + ReceiveMessageWaitTimeSeconds: 0 + VisibilityTimeout: 30 + QueueName: sims-create.fifo Outputs: #======================================================= # # CloudFormation Outputs # #======================================================= - SimDeleteQueueURL: - Description: "URL of Sim Delete SQS Queue" + SimDisableQueueURL: + Description: URL of Sim Disable SQS Queue Value: - Ref: "SimDeleteQueue" - SimDeleteQueueARN: - Description: "ARN of Sim Delete Queue" + Ref: SimDisableQueue + SimDisableQueueARN: + Description: ARN of Sim Disable Queue Value: Fn::GetAtt: - - "SimDeleteQueue" - - "Arn" - SimDeleteQueueName: - Description: "Name of Sim Delete Queue" + - SimDisableQueue + - Arn + SimDisableQueueName: + Description: Name of Sim Disable Queue Value: Fn::GetAtt: - - "SimDeleteQueue" - - "QueueName" + - SimDisableQueue + - QueueName SimCreateQueueURL: - Description: "URL of Sim Create SQS Queue" + Description: URL of Sim Create SQS Queue Value: - Ref: "SimCreateQueue" + Ref: SimCreateQueue SimCreateQueueARN: - Description: "ARN of Sim Create Queue" + Description: ARN of Sim Create Queue Value: Fn::GetAtt: - - "SimCreateQueue" - - "Arn" + - SimCreateQueue + - Arn SimCreateQueueName: - Description: "Name of Sim Create Queue" + Description: Name of Sim Create Queue Value: Fn::GetAtt: - - "SimCreateQueue" - - "QueueName" + - SimCreateQueue + - QueueName diff --git a/templates/ssm.yaml b/templates/ssm.yaml index d2dad9e..ad117be 100644 --- a/templates/ssm.yaml +++ b/templates/ssm.yaml @@ -1,17 +1,23 @@ AWSTemplateFormatVersion: "2010-09-09" -Description: "SSM parameters for openvpn onboarding" +Description: SSM parameters for openvpn onboarding Parameters: #======================================================= # # CloudFormation Parameters # #======================================================= - OnboardingEndpointUrl: + ApiGatewayUrl: Type: String - Description: Onboarding Endpoint URL - ApiEndpointSSMParamName: - Type: String - Description: Name of the SSM Param where api endpoint is stored + Description: API gateway URL + ApiGatewayUrlSSMParamName: + Type: String + Description: Name of the SSM Param where api gateway URL is stored + OnboardingPath: + Type: String + Description: API gateway onboarding path + OnboardingPathSSMParamName: + Type: String + Description: Name of the SSM Param where onboarding path is stored ProxyServerSSMParamName: Type: String Description: Name of the SSM Param where proxy server address is stored @@ -28,30 +34,46 @@ Resources: # #======================================================= SSMParameterApiEndpoint: - Type: "AWS::SSM::Parameter" + Type: AWS::SSM::Parameter + Properties: + Name: + Ref: ApiGatewayUrlSSMParamName + Type: String + Value: + Ref: ApiGatewayUrl + DataType: text + Tier: Standard + + SSMParameterOnboardingEndpoint: + Type: AWS::SSM::Parameter Properties: - Name: !Ref ApiEndpointSSMParamName - Type: "String" - Value: !Ref "OnboardingEndpointUrl" - DataType: "text" - Tier: "Standard" + Name: + Ref: OnboardingPathSSMParamName + Type: String + Value: + Ref: OnboardingPath + DataType: text + Tier: Standard SSMParameterProxyServer: - Type: "AWS::SSM::Parameter" + Type: AWS::SSM::Parameter Properties: - Name: !Ref ProxyServerSSMParamName - Type: "String" - Value: "placeholder" - DataType: "text" - Description: "Onboarding proxy server" - Tier: "Standard" + Name: + Ref: ProxyServerSSMParamName + Type: String + Value: placeholder + DataType: text + Description: Onboarding proxy server + Tier: Standard SSMParameterBreakoutRegion: - Type: "AWS::SSM::Parameter" + Type: AWS::SSM::Parameter Properties: - Name: !Ref BreakoutRegionSSMParamName - Type: "String" - Value: !Ref BreakoutRegion - DataType: "text" - Description: "Breakout Region in 1NCE Portal" - Tier: "Standard" + Name: + Ref: BreakoutRegionSSMParamName + Type: String + Value: + Ref: BreakoutRegion + DataType: text + Description: Breakout Region in 1NCE Portal + Tier: Standard