diff --git a/package-lock.json b/package-lock.json index 243195ce5..63f4834f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -223,6 +223,10 @@ "resolved": "plugins/dotnet-db-sqlserver", "link": true }, + "node_modules/@amplication/plugin-dotnet-deployment-github-actions-aws-ecs": { + "resolved": "plugins/dotnet-deployment-github-actions-aws-ecs", + "link": true + }, "node_modules/@amplication/plugin-dotnet-provisioning-terraform-aws-core": { "resolved": "plugins/dotnet-provisioning-terraform-aws-core", "link": true @@ -16853,6 +16857,330 @@ "node": ">=4.2.0" } }, + "plugins/dotnet-deployment-github-actions-aws-ecs": { + "version": "0.0.1", + "license": "Apache-2.0", + "devDependencies": { + "@amplication/code-gen-types": "2.0.37", + "@amplication/code-gen-utils": "^0.0.9", + "@amplication/csharp-ast": "^0.0.4", + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/lodash": "^4.14.199", + "@typescript-eslint/eslint-plugin": "^5.33.0", + "@typescript-eslint/parser": "^5.33.0", + "copy-webpack-plugin": "^12.0.2", + "eslint": "^8.21.0", + "jest-mock-extended": "^3.0.1", + "lodash": "^4.17.21", + "prettier": "^2.6.2", + "rimraf": "^4.4.1", + "ts-loader": "^9.4.2", + "typescript": "^4.9.3", + "webpack": "^5.76.0", + "webpack-cli": "^5.0.1", + "webpack-node-externals": "^3.0.0" + } + }, + "plugins/dotnet-deployment-github-actions-aws-ecs/node_modules/@amplication/code-gen-types": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/@amplication/code-gen-types/-/code-gen-types-2.0.37.tgz", + "integrity": "sha512-wCQZtj+q/bByN+2D+A96aI3qXaIVcaA5JeYgNZdkzPQqmDAZWSQwp0DlY8i6356E1Y/RpG55q/oziWmQdaqCJw==", + "dev": true, + "dependencies": { + "ast-types": "^0.14.2", + "json-schema": "^0.4.0", + "prisma-schema-dsl-types": "^1.1.2", + "tslib": "^2.6.2", + "type-fest": "^3.11.0" + }, + "peerDependencies": { + "@amplication/csharp-ast": "*" + } + }, + "plugins/dotnet-deployment-github-actions-aws-ecs/node_modules/@amplication/csharp-ast": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@amplication/csharp-ast/-/csharp-ast-0.0.4.tgz", + "integrity": "sha512-sLF/vodThhrs4OmSL0/f0WMHm6CG4g3eVFKSr+BZ43XH9b3Oj1AGVSUlps7UZQqsspMxi5fa8Vtz7gQgdyGKXw==", + "dev": true, + "dependencies": { + "tslib": "^2.3.0" + } + }, + "plugins/dotnet-deployment-github-actions-aws-ecs/node_modules/@typescript-eslint/eslint-plugin": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz", + "integrity": "sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==", + "dev": true, + "dependencies": { + "@eslint-community/regexpp": "^4.4.0", + "@typescript-eslint/scope-manager": "5.62.0", + "@typescript-eslint/type-utils": "5.62.0", + "@typescript-eslint/utils": "5.62.0", + "debug": "^4.3.4", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "natural-compare-lite": "^1.4.0", + "semver": "^7.3.7", + "tsutils": "^3.21.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^5.0.0", + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "plugins/dotnet-deployment-github-actions-aws-ecs/node_modules/@typescript-eslint/parser": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz", + "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==", + "dev": true, + "dependencies": { + "@typescript-eslint/scope-manager": "5.62.0", + "@typescript-eslint/types": "5.62.0", + "@typescript-eslint/typescript-estree": "5.62.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "plugins/dotnet-deployment-github-actions-aws-ecs/node_modules/@typescript-eslint/scope-manager": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz", + "integrity": "sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "5.62.0", + "@typescript-eslint/visitor-keys": "5.62.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "plugins/dotnet-deployment-github-actions-aws-ecs/node_modules/@typescript-eslint/type-utils": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.62.0.tgz", + "integrity": "sha512-xsSQreu+VnfbqQpW5vnCJdq1Z3Q0U31qiWmRhr98ONQmcp/yhiPJFPq8MXiJVLiksmOKSjIldZzkebzHuCGzew==", + "dev": true, + "dependencies": { + "@typescript-eslint/typescript-estree": "5.62.0", + "@typescript-eslint/utils": "5.62.0", + "debug": "^4.3.4", + "tsutils": "^3.21.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "plugins/dotnet-deployment-github-actions-aws-ecs/node_modules/@typescript-eslint/types": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.62.0.tgz", + "integrity": "sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "plugins/dotnet-deployment-github-actions-aws-ecs/node_modules/@typescript-eslint/typescript-estree": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz", + "integrity": "sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "5.62.0", + "@typescript-eslint/visitor-keys": "5.62.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "semver": "^7.3.7", + "tsutils": "^3.21.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "plugins/dotnet-deployment-github-actions-aws-ecs/node_modules/@typescript-eslint/utils": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.62.0.tgz", + "integrity": "sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@types/json-schema": "^7.0.9", + "@types/semver": "^7.3.12", + "@typescript-eslint/scope-manager": "5.62.0", + "@typescript-eslint/types": "5.62.0", + "@typescript-eslint/typescript-estree": "5.62.0", + "eslint-scope": "^5.1.1", + "semver": "^7.3.7" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "plugins/dotnet-deployment-github-actions-aws-ecs/node_modules/@typescript-eslint/visitor-keys": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz", + "integrity": "sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "5.62.0", + "eslint-visitor-keys": "^3.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "plugins/dotnet-deployment-github-actions-aws-ecs/node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "plugins/dotnet-deployment-github-actions-aws-ecs/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "plugins/dotnet-deployment-github-actions-aws-ecs/node_modules/glob": { + "version": "9.3.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-9.3.5.tgz", + "integrity": "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "minimatch": "^8.0.2", + "minipass": "^4.2.4", + "path-scurry": "^1.6.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "plugins/dotnet-deployment-github-actions-aws-ecs/node_modules/minimatch": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-8.0.4.tgz", + "integrity": "sha512-W0Wvr9HyFXZRGIDgCicunpQ299OKXs9RgZfaukz4qAW/pJhcpUfupc9c+OObPOFueNy8VSrZgEmDtk6Kh4WzDA==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "plugins/dotnet-deployment-github-actions-aws-ecs/node_modules/rimraf": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-4.4.1.tgz", + "integrity": "sha512-Gk8NlF062+T9CqNGn6h4tls3k6T1+/nXdOcSZVikNVtlRdYpA7wRJJMoXmuvOnLW844rPjdQ7JgXCYM6PPC/og==", + "dev": true, + "dependencies": { + "glob": "^9.2.0" + }, + "bin": { + "rimraf": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "plugins/dotnet-deployment-github-actions-aws-ecs/node_modules/typescript": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, "plugins/dotnet-deployment-helm-chart": { "name": "@amplication/dotnet-plugin-deployment-helm-chart", "version": "0.0.1", diff --git a/plugins/dotnet-deployment-github-actions-aws-ecs/.amplicationrc.json b/plugins/dotnet-deployment-github-actions-aws-ecs/.amplicationrc.json new file mode 100644 index 000000000..db9bfa328 --- /dev/null +++ b/plugins/dotnet-deployment-github-actions-aws-ecs/.amplicationrc.json @@ -0,0 +1,19 @@ +{ + "settings": { + "region_identifier": "eu-west-1", + "account_identifier": "012345678901", + "ecr_repository_name": "repository-name", + "ecr_image_tag": "${{ github.sha }}", + "ecs_cluster_name": "development-cluster", + "ecs_role_name": "task-execution-role-name", + "sm_secret_name": "secret-name", + "resources": { + "cpu": "1024", + "memory": "2048" + }, + "runtime": { + "cpu_architecture": "X86_64", + "os_family": "LINUX" + } + } +} diff --git a/plugins/dotnet-deployment-github-actions-aws-ecs/.eslintrc.json b/plugins/dotnet-deployment-github-actions-aws-ecs/.eslintrc.json new file mode 100644 index 000000000..052a5874e --- /dev/null +++ b/plugins/dotnet-deployment-github-actions-aws-ecs/.eslintrc.json @@ -0,0 +1,4 @@ +{ + "extends": ["../../.eslintrc.json"], + "ignorePatterns": ["!**/*"] +} diff --git a/plugins/dotnet-deployment-github-actions-aws-ecs/.npmignore b/plugins/dotnet-deployment-github-actions-aws-ecs/.npmignore new file mode 100644 index 000000000..99ba36b26 --- /dev/null +++ b/plugins/dotnet-deployment-github-actions-aws-ecs/.npmignore @@ -0,0 +1,2 @@ +.prettierignore +.gitignore \ No newline at end of file diff --git a/plugins/dotnet-deployment-github-actions-aws-ecs/.prettierignore b/plugins/dotnet-deployment-github-actions-aws-ecs/.prettierignore new file mode 100644 index 000000000..53c37a166 --- /dev/null +++ b/plugins/dotnet-deployment-github-actions-aws-ecs/.prettierignore @@ -0,0 +1 @@ +dist \ No newline at end of file diff --git a/plugins/dotnet-deployment-github-actions-aws-ecs/README.md b/plugins/dotnet-deployment-github-actions-aws-ecs/README.md new file mode 100644 index 000000000..6437962ec --- /dev/null +++ b/plugins/dotnet-deployment-github-actions-aws-ecs/README.md @@ -0,0 +1,178 @@ +# @amplication/plugin-deployment-github-actions-aws-ecs + +[![NPM Downloads](https://img.shields.io/npm/dt/@amplication/plugin-dotnet-deployment-github-actions-aws-ecs)](https://www.npmjs.com/package/@amplication/plugin-dotnet-deployment-github-actions-aws-ecs) + +Adds a Github Actions workflow file & Amazon Elastic Container Service task definition for the generated service. + +## Purpose + +Adds a Github Actions workflow file for building and pushing a container image to Amazon Elastic Container Registry. In addition it creates a task definition for deploying the previously built image onto an Amazon Elastic Container Service cluster. It requires infrastructure to deploy on to be present - how to create this infrastructure can be found in the configuration part. + +## Configuration + +Compared to other plugins, this plugin requires some additional upfront work to get the generated code working as expected. These prerequisites can be found below, after which the settings for the plugin are explained. As the plugin's settings are very dependent on the configuration done in the prerequisites, it is advised to read both. + +### Configuration - Pre-requisites + +As mentioned there are some pre-requisites to be able to starting using this plugin. The end goal of the plugin is that it deploys a newer version of the generated service onto Amazon Elastic Container Service. This however requires some of the Amazon Web Services services to be configured ahead of time. In this paragraph we'll go through the different services and how to configure them. + +`Network: Amazon Virtual Private Cloud (VPC)`: When creating a Amazon Web Services account, it comes with a [`default vpc`](https://docs.aws.amazon.com/vpc/latest/userguide/default-vpc.html) the other services below can be deployed onto this. Although it is advised to create a seperate VPC from the default one and use that. + +`Access & permissions: Amazon Identity Access Management (IAM)`: In the process of getting a new version of the service to start running on the Amazon Elastic Container Service we need various permissions. Permissions for the whole process can be divided into two catogories, the first is the process of getting the container image pushed to the Amazon Elastic Container Registry aswell as deploying the task definition onto the Amazon Elastic Container Service. Create a role with the following permissions: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["ecr:CompleteLayerUpload", "ecr:UploadLayerPart", "ecr:InitiateLayerUpload", "ecr:BatchCheckLayerAvailability", "ecr:PutImage"], + "Resource": "arn:aws:ecr:region:111122223333:repository/repository-name" + }, + { + "Effect": "Allow", + "Action": "ecr:GetAuthorizationToken", + "Resource": "*" + } + ] +} +``` + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "RegisterTaskDefinition", + "Effect": "Allow", + "Action": ["ecs:RegisterTaskDefinition"], + "Resource": "*" + }, + { + "Sid": "PassRolesInTaskDefinition", + "Effect": "Allow", + "Action": ["iam:PassRole"], + "Resource": ["arn:aws:iam:::role/", "arn:aws:iam:::role/"] + }, + { + "Sid": "DeployService", + "Effect": "Allow", + "Action": ["ecs:UpdateService", "ecs:DescribeServices"], + "Resource": ["arn:aws:ecs:::service//"] + } + ] +} +``` + +In addition a user need to be created on which the above permissions can be assigned. Also create access credentials for this user and store them in the repository secrets. These are ` AWS_ACCESS_KEY_ID` & `AWS_SECRET_ACCESS_KEY`. + +The second scope of permissions is related to the execution of the task. When running the generated service in the container on Amazon Elastic Container Service, permissions are assigned to the running task/service in the form of a role. The first policy to attach is the default Amazon managed `AmazonECSTaskExecutionRolePolicy` + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["ecr:GetAuthorizationToken", "ecr:BatchCheckLayerAvailability", "ecr:GetDownloadUrlForLayer", "ecr:BatchGetImage", "logs:CreateLogStream", "logs:PutLogEvents"], + "Resource": "*" + } + ] +} +``` + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "FetchSecret", + "Effect": "Allow", + "Action": [ + "secretsmanager:GetSecretValue", + "kms:Decrypt", + ], + "Resource": [ + "arn:aws:secretsmanager:::secret:" + "arn:aws:kms:::key/key_id" + ] + }, + { + "Sid": "CreateLogGroup", + "Effect": "Allow", + "Action": [ + "logs:CreateLogGroup" + ], + "Resource": [ + "*" + ] + } + ] +} +``` + +`Database: Amazon Relation Database Service (RDS)`: The generated service needs to connect to a database. One of the suggested choices for this is using the Amazon Relation Database Service, here the applicable database type can be created. Make sure that the database in question is accessible from the private subnet the Amazon Elastic Container Service cluster will be deployed onto, preferrably deployed in a seperate database subnet. The database configuration needs to be passed in the next step under the `DB_URL` in the format shown. + +> **Note** +> Before connecting the generated service to the database make sure that the database is instrumented by applying any pending `prisma migrations` to the database in question. + +`Secret: AWS Secret Manager`: As our generated service needs some secrets propagated in the form of environment variables, we store the values in a form of a json structure under secrets manager: + +```json +{ + "BCRYPT_SALT": "10", + "JWT_EXPIRATION": "2d", + "JWT_SECRET_KEY": "abcdef123456", + "DB_URL": "postgres://user:password@database-instance-identifier.abcdef123456.eu-west-1.rds.amazonaws.com:5432/dabase-name" +} +``` + +After creating the secret for the `arn` it will be suffixed with a random hash, grab this secret name with the additional suffix and pass it in the settings as `sm_secret_name`, e.g. `secret-name-JzvSgm`. Pointers to the different variables in this secret will subsequently automatically be made in the task definition. Add onto this where desired. + +`Cluster & Service: Amazon Elastic Container Service (ECS)`: The last step is to create the resources within the service, where the GitHub Actions will actually deploy onto. In this service we need to create two resources, a `cluster` and within that cluster a `service`. Make sure that both the `ecs_cluster_name` and `ecs_service_name` are passed through the plugin settings. For the service to be able to be created you have to temporarily assign another task definition family. + +### Configuration - Settings + +The `region_identifier` settings requires the region identifier of the Amazon Web Services region where the created infrastructure exists in. + +The `account_identifier` setting requires the account identifier of the Amazon Web Services account to deploy the task definition and container image to. + +The `ecr_repository_name` setting requires the name of the repository for the container image created in the pre-requisites. + +The `ecr_image_tag` setting is set to `latest` by default. Another suggestion is to pass in `${{ github.sha }}` where each image will be tagged as the last git commit, allowing for returning to a previous version. + +The `ecs_cluster_name` setting requires the name of the cluster that was created in the pre-requisites. + +The `ecs_role_name` setting requires the name of the role that will be used when executing the task definition on the cluster. This means that there are some permissions that are needed based on the use-case. As was shown in the previous paragraph there are some permission that are required by default when running the generated service. + +The `sm_secret_name` setting requires the name + automatically added suffix - e.g. `secret-name-JzvSgm"` - of the secret created in AWS Secret manager. As was shown in the previous paragraph, the secret should contain the four secrets that are required for the generated service to start: `BCRYPT_SALT`, `JWT_EXPIRATION`, `JWT_SECRET_KEY`, `DB_URL`. + +The `resources` sub-category allows the user to specify the `cpu` and `memory` to be allocated to the task/service in question. As there are some constraints between the two it would be adviced to look at the different task sizes in the [documentation](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task_definition_parameters.html#task_size). + +The `runtime` sub-category allows the user to specify the `os_family` with their applicable `cpu_architecture`. Examples for the `os_family` are `WINDOWS` & `LINUX`. As our generated service is almost always ran on the latter, this has been selected as the default. Examples for `cpu_architecture` within the `os_family` > `LINUX` are `X86_64`, but also `ARM64` could be used if desired. + +```json +{ + "settings": { + "region_identifier": "eu-west-1", + "account_identifier": "012345678901", + "ecr_repository_name": "repository-name", + "ecr_image_tag": "latest", + "ecs_cluster_name": "development-cluster", + "ecs_role_name": "task-execution-role-name", + "sm_secret_name": "secret-name-JzvSgm", + "resources": { + "cpu": "1024", + "memory": "2048" + }, + "runtime": { + "cpu_architecture": "X86_64", + "os_family": "LINUX" + } + } +} +``` + +## Usage + +As this is an addition to the code base, where non of the other code is touched, using the plugin won't impact the final build. diff --git a/plugins/dotnet-deployment-github-actions-aws-ecs/package.json b/plugins/dotnet-deployment-github-actions-aws-ecs/package.json new file mode 100644 index 000000000..90eb08bef --- /dev/null +++ b/plugins/dotnet-deployment-github-actions-aws-ecs/package.json @@ -0,0 +1,37 @@ +{ + "name": "@amplication/plugin-dotnet-deployment-github-actions-aws-ecs", + "version": "0.0.1", + "description": "Adds a GitHub Actions workflow for the packaging to AWS Elastic Container Registry (ECR) and publishing to AWS Elastic Container Service (ECS). This plugin requires the ECR & ECS to be present and passed as configuration.", + "main": "dist/index.js", + "nx": {}, + "scripts": { + "prepublishOnly": "npm run build", + "dev": "webpack --watch", + "build": "webpack", + "prebuild": "rimraf dist" + }, + "author": "Haim Bell", + "license": "Apache-2.0", + "dependencies": {}, + "devDependencies": { + "@amplication/csharp-ast": "^0.0.4", + "@amplication/code-gen-types": "2.0.37", + "@amplication/code-gen-utils": "^0.0.9", + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/lodash": "^4.14.199", + "lodash": "^4.17.21", + "@typescript-eslint/eslint-plugin": "^5.33.0", + "@typescript-eslint/parser": "^5.33.0", + "copy-webpack-plugin": "^12.0.2", + "eslint": "^8.21.0", + "jest-mock-extended": "^3.0.1", + "prettier": "^2.6.2", + "rimraf": "^4.4.1", + "ts-loader": "^9.4.2", + "typescript": "^4.9.3", + "webpack": "^5.76.0", + "webpack-cli": "^5.0.1", + "webpack-node-externals": "^3.0.0" + } +} diff --git a/plugins/dotnet-deployment-github-actions-aws-ecs/project.json b/plugins/dotnet-deployment-github-actions-aws-ecs/project.json new file mode 100644 index 000000000..1407e4eb5 --- /dev/null +++ b/plugins/dotnet-deployment-github-actions-aws-ecs/project.json @@ -0,0 +1,6 @@ +{ + "targets": { + "lint": {}, + "npm:publish": {} + } +} diff --git a/plugins/dotnet-deployment-github-actions-aws-ecs/src/constants.ts b/plugins/dotnet-deployment-github-actions-aws-ecs/src/constants.ts new file mode 100644 index 000000000..cc1020db4 --- /dev/null +++ b/plugins/dotnet-deployment-github-actions-aws-ecs/src/constants.ts @@ -0,0 +1,21 @@ +export const serviceNameKey = "${{ SERVICE_NAME }}"; + +export const ecrRepositoryNameKey = "${{ ECR_REPOSITORY_NAME }}"; +export const ecrImageTagKey = "${{ ECR_IMAGE_TAG }}"; + +export const ecsClusterNameKey = "${{ ECS_CLUSTER_NAME }}"; +export const ecsRoleNameKey = "${{ ECS_ROLE_NAME }}"; +export const ecsTaskDefinitionPathKey = "${{ ECS_TASK_DEFINITION_PATH }}"; + +export const smSecretNameKey = "${{ SM_SECRET_NAME }}"; + +export const accountIdentifierKey = "${{ ACCOUNT_IDENTIFIER }}"; +export const regionIdentifierKey = "${{ REGION_IDENTIFIER }}"; + +export const resourcesCpuKey = "${{ RESOURCES_CPU }}"; +export const resourcesMemoryKey = "${{ RESOURCES_MEMORY }}"; + +export const runtimeCpuArchitectureKey = "${{ RUNTIME_CPU_ARCHITECTURE }}"; +export const runtimeOsFamilyKey = "${{ RUNTIME_OS_FAMILY }}"; + +export const dockerFilePathKey = "${{ DOCKERFILE_PATH }}"; diff --git a/plugins/dotnet-deployment-github-actions-aws-ecs/src/index.ts b/plugins/dotnet-deployment-github-actions-aws-ecs/src/index.ts new file mode 100644 index 000000000..1f4584d1e --- /dev/null +++ b/plugins/dotnet-deployment-github-actions-aws-ecs/src/index.ts @@ -0,0 +1,127 @@ +import { + dotnetPluginEventsTypes, + dotnetPluginEventsParams as dotnet, + dotnetTypes, + FileMap, + IFile, +} from "@amplication/code-gen-types"; +import { CodeBlock } from "@amplication/csharp-ast"; + +import { + regionIdentifierKey, + accountIdentifierKey, + ecrImageTagKey, + ecrRepositoryNameKey, + serviceNameKey, + ecsClusterNameKey, + ecsRoleNameKey, + ecsTaskDefinitionPathKey, + smSecretNameKey, + resourcesCpuKey, + resourcesMemoryKey, + runtimeCpuArchitectureKey, + runtimeOsFamilyKey, + dockerFilePathKey, +} from "./constants"; +import { getPluginSettings } from "./utils"; +import { resolve } from "path"; +import { kebabCase } from "lodash"; + +class GithubActionsAwsEcsPlugin implements dotnetTypes.AmplicationPlugin { + register(): dotnetPluginEventsTypes.DotnetEvents { + return { + LoadStaticFiles: { + after: this.afterLoadStaticFiles, + }, + }; + } + async afterLoadStaticFiles( + context: dotnetTypes.DsgContext, + eventParams: dotnet.LoadStaticFilesParams, + files: FileMap + ): Promise> { + context.logger.info( + "Generating Github Actions deploy to Amazon ECS workflow ..." + ); + + // determine the name of the service which will be used as the name for the workflow + // workflow names must be lower case letters and numbers. words may be separated with dashes (-): + const serviceName = kebabCase(context.resourceInfo?.name); + + if (!serviceName) { + throw new Error("Service name is undefined"); + } + + // template file names + const templateWorkflowFileName = "workflow.yaml"; + const templateTaskDefinitionFileName = "task-definition.json"; + + // output file name prefix & suffixes + const fileNamePrefix = "cd-"; + const workflowFileNameSuffix = "-aws-ecs.yaml"; + const taskDefinitionFileNameSuffix = "-aws-ecs.json"; + + // ouput directory base & file specific suffix + const outputDirectoryBase = ".github/workflows"; + const outputSuffixWorkflow: string = + "/" + fileNamePrefix + serviceName + workflowFileNameSuffix; + const outputSuffixTaskDefinition: string = + "/configuration/" + + fileNamePrefix + + serviceName + + taskDefinitionFileNameSuffix; + + // getPluginSettings: fetch user settings + merge with default settings + const settings = getPluginSettings(context.pluginInstallations); + const staticPath = resolve(__dirname, "./static"); + + const staticFiles = await context.utils.importStaticFiles( + staticPath, + "./" + outputDirectoryBase + ); + const fileMap = new FileMap(context.logger); + for (const item of staticFiles.getAll()) { + const newCode = item.code + .replaceAll(serviceNameKey, serviceName) + .replaceAll(regionIdentifierKey, settings.region_identifier) + .replaceAll(accountIdentifierKey, settings.account_identifier) + .replaceAll(ecrRepositoryNameKey, settings.ecr_repository_name) + .replaceAll(ecrImageTagKey, settings.ecr_image_tag) + .replaceAll(ecsClusterNameKey, settings.ecs_cluster_name) + .replaceAll(ecsRoleNameKey, settings.ecs_role_name) + .replaceAll( + ecsTaskDefinitionPathKey, + outputDirectoryBase + outputSuffixTaskDefinition + ) + .replaceAll(smSecretNameKey, settings.sm_secret_name) + .replaceAll(resourcesCpuKey, settings.resources.cpu) + .replaceAll(resourcesMemoryKey, settings.resources.memory) + .replaceAll(runtimeOsFamilyKey, settings.runtime.os_family) + .replaceAll( + runtimeCpuArchitectureKey, + settings.runtime.cpu_architecture + ) + .replaceAll(dockerFilePathKey, context.serverDirectories.baseDirectory); + + const newPath = item.path + .replace(templateWorkflowFileName, outputSuffixWorkflow) + .replace(templateTaskDefinitionFileName, outputSuffixTaskDefinition); + const file: IFile = { + path: newPath, + code: new CodeBlock({ + code: newCode, + }), + }; + fileMap.set(file); + } + + context.logger.info( + "Generated Github Actions deploy to Amazon ECS workflow..." + ); + + await files.merge(fileMap); + return files; + } +} + +export default GithubActionsAwsEcsPlugin; diff --git a/plugins/dotnet-deployment-github-actions-aws-ecs/src/static/task-definition.json b/plugins/dotnet-deployment-github-actions-aws-ecs/src/static/task-definition.json new file mode 100644 index 000000000..f6e4a361d --- /dev/null +++ b/plugins/dotnet-deployment-github-actions-aws-ecs/src/static/task-definition.json @@ -0,0 +1,58 @@ +{ + "family": "${{ SERVICE_NAME }}", + "containerDefinitions": [ + { + "name": "${{ SERVICE_NAME }}", + "image": "${{ ACCOUNT_IDENTIFIER }}.dkr.ecr.${{ REGION_IDENTIFIER }}.amazonaws.com/${{ ECR_REPOSITORY_NAME }}:latest", + "essential": true, + "portMappings": [ + { + "name": "${{ SERVICE_NAME }}-3000-tcp", + "containerPort": 3000, + "hostPort": 3000, + "protocol": "tcp" + } + ], + "secrets": [ + { + "name": "BCRYPT_SALT", + "valueFrom": "arn:aws:secretsmanager:${{ REGION_IDENTIFIER }}:${{ ACCOUNT_IDENTIFIER }}:secret:${{ SM_SECRET_NAME }}:BCRYPT_SALT::" + }, + { + "name": "JWT_EXPIRATION", + "valueFrom": "arn:aws:secretsmanager:${{ REGION_IDENTIFIER }}:${{ ACCOUNT_IDENTIFIER }}:secret:${{ SM_SECRET_NAME }}:JWT_EXPIRATION::" + }, + { + "name": "JWT_SECRET_KEY", + "valueFrom": "arn:aws:secretsmanager:${{ REGION_IDENTIFIER }}:${{ ACCOUNT_IDENTIFIER }}:secret:${{ SM_SECRET_NAME }}:JWT_SECRET_KEY::" + }, + { + "name": "DB_URL", + "valueFrom": "arn:aws:secretsmanager:${{ REGION_IDENTIFIER }}:${{ ACCOUNT_IDENTIFIER }}:secret:${{ SM_SECRET_NAME }}:DB_URL::" + } + ], + "logConfiguration": { + "logDriver": "awslogs", + "options": { + "awslogs-create-group": "true", + "awslogs-group": "/ecs/${{ SERVICE_NAME }}", + "awslogs-region": "${{ REGION_IDENTIFIER }}", + "awslogs-stream-prefix": "ecs" + } + }, + "mountPoints": [], + "volumesFrom": [] + } + ], + "executionRoleArn": "arn:aws:iam::${{ ACCOUNT_IDENTIFIER }}:role/${{ ECS_ROLE_NAME }}", + "networkMode": "awsvpc", + "requiresCompatibilities": [ + "FARGATE" + ], + "cpu": "${{ RESOURCES_CPU }}", + "memory": "${{ RESOURCES_MEMORY }}", + "runtimePlatform": { + "cpuArchitecture": "${{ RUNTIME_CPU_ARCHITECTURE }}", + "operatingSystemFamily": "${{ RUNTIME_OS_FAMILY }}" + } +} \ No newline at end of file diff --git a/plugins/dotnet-deployment-github-actions-aws-ecs/src/static/workflow.yaml b/plugins/dotnet-deployment-github-actions-aws-ecs/src/static/workflow.yaml new file mode 100644 index 000000000..fd5ed3fa3 --- /dev/null +++ b/plugins/dotnet-deployment-github-actions-aws-ecs/src/static/workflow.yaml @@ -0,0 +1,69 @@ +# Introduction: +# +# This workflow is responsible for both creating and pushing the container image of the generated service to +# Amazon Elastic Container Registry (ECR), aswell as rendering and deploying an task definition to an Amazon +# Elastic Container Service (ECS) cluster. Lastly the execution of a task definition requires as IAM role which +# can be consumed. + +name: ${{ SERVICE_NAME }}-deploy-to-aws-ecs +concurrency: ${{ github.ref }} + +on: + workflow_dispatch: {} + pull_request: + types: [closed] + +env: + AWS_REGION: ${{ REGION_IDENTIFIER }} + ECR_REPOSITORY: ${{ ECR_REPOSITORY_NAME }} + ECS_SERVICE: ${{ SERVICE_NAME }} + ECS_CLUSTER: ${{ ECS_CLUSTER_NAME }} + ECS_TASK_DEFINITION: ${{ ECS_TASK_DEFINITION_PATH }} + CONTAINER_NAME: ${{ SERVICE_NAME }} + +jobs: + package: + name: deploy + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v4 + + - name: configure aws credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ env.AWS_REGION }} + + - name: login to amazon ecr + id: registry + uses: aws-actions/amazon-ecr-login@v2 + + - name: build, tag and push image + id: image + env: + ECR_REGISTRY: ${{ steps.registry.outputs.registry }} + IMAGE_TAG: ${{ ECR_IMAGE_TAG }} + run: | + docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG ${{ DOCKERFILE_PATH }} + docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG + echo "image=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT + + # https://github.com/aws-actions/amazon-ecs-render-task-definition + - name: render amazon ecs task definition + id: task-definition + uses: aws-actions/amazon-ecs-render-task-definition@v1 + with: + task-definition: ${{ env.ECS_TASK_DEFINITION }} + container-name: ${{ env.CONTAINER_NAME }} + image: ${{ steps.image.outputs.image }} + + # https://github.com/aws-actions/amazon-ecs-deploy-task-definition + - name: deploy amazon ecs task definition + uses: aws-actions/amazon-ecs-deploy-task-definition@v1 + with: + task-definition: ${{ steps.task-definition.outputs.task-definition }} + service: ${{ env.ECS_SERVICE }} + cluster: ${{ env.ECS_CLUSTER }} + wait-for-service-stability: false diff --git a/plugins/dotnet-deployment-github-actions-aws-ecs/src/types.ts b/plugins/dotnet-deployment-github-actions-aws-ecs/src/types.ts new file mode 100644 index 000000000..c2a762226 --- /dev/null +++ b/plugins/dotnet-deployment-github-actions-aws-ecs/src/types.ts @@ -0,0 +1,17 @@ +export interface Settings { + region_identifier: string; + account_identifier: string; + ecr_repository_name: string; + ecr_image_tag: string; + ecs_cluster_name: string; + ecs_role_name: string; + sm_secret_name: string; + resources: { + cpu: string; + memory: string; + }; + runtime: { + cpu_architecture: string; + os_family: string; + }; +} diff --git a/plugins/dotnet-deployment-github-actions-aws-ecs/src/utils.ts b/plugins/dotnet-deployment-github-actions-aws-ecs/src/utils.ts new file mode 100644 index 000000000..a94bfe69f --- /dev/null +++ b/plugins/dotnet-deployment-github-actions-aws-ecs/src/utils.ts @@ -0,0 +1,21 @@ +import { PluginInstallation } from "@amplication/code-gen-types"; +import { name as PackageName } from "../package.json"; +import { Settings } from "./types"; +import defaultSettings from "../.amplicationrc.json"; + +export const getPluginSettings = ( + pluginInstallations: PluginInstallation[] +): Settings => { + const plugin = pluginInstallations.find( + (plugin) => plugin.npm === PackageName + ); + + const userSettings = plugin?.settings ?? {}; + + const settings: Settings = { + ...defaultSettings.settings, + ...userSettings, + }; + + return settings; +}; diff --git a/plugins/dotnet-deployment-github-actions-aws-ecs/tsconfig.json b/plugins/dotnet-deployment-github-actions-aws-ecs/tsconfig.json new file mode 100644 index 000000000..6646f44f5 --- /dev/null +++ b/plugins/dotnet-deployment-github-actions-aws-ecs/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/index.ts"], + "exclude": ["node_modules"] +} diff --git a/plugins/dotnet-deployment-github-actions-aws-ecs/webpack.config.js b/plugins/dotnet-deployment-github-actions-aws-ecs/webpack.config.js new file mode 100644 index 000000000..07eadf115 --- /dev/null +++ b/plugins/dotnet-deployment-github-actions-aws-ecs/webpack.config.js @@ -0,0 +1,43 @@ +const path = require("path"); +const webpack = require("webpack"); +const CopyWebpackPlugin = require("copy-webpack-plugin"); + +/** @type {import("webpack").Configuration} */ +module.exports = { + mode: "production", + target: "node", + entry: "./src/index.ts", + externals: ["@amplication/code-gen-utils", "@amplication/code-gen-types"], + plugins: [ + new webpack.SourceMapDevToolPlugin({ + filename: "[name].js.map", + }), + new CopyWebpackPlugin({ + patterns: [ + { from: "src/static", to: "static", noErrorOnMissing: true }, + { from: "src/templates", to: "templates", noErrorOnMissing: true }, + ], + }), + ], + module: { + rules: [ + { + test: /\.tsx?$/, + use: "ts-loader", + exclude: /node_modules/, + }, + ], + }, + resolve: { + extensions: [".ts", ".js", ".json"], + }, + optimization: { + minimize: false, + }, + output: { + filename: "index.js", + path: path.resolve(__dirname, "dist"), + libraryTarget: "commonjs2", + clean: true, + }, +};