Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,111 @@ resource "aws_dynamodb_table" "deployments" {
projection_type = "INCLUDE"
non_key_attributes = ["CreateDate", "DeploymentAlias", "DeploymentId", "Status"]
}

tags = var.tags
}

#####################
# CloudFormation Role
#####################

# Policy that controls which actions can be performed when CloudFormation
# creates a substack (from CDK)
data "aws_iam_policy_document" "cloudformation_permission" {
# Allow CloudFormation to publish status changes to the SNS queue
statement {
effect = "Allow"
actions = [
"sns:Publish"
]
resources = [module.deploy_controller.sns_topic_arn]
}

# Allow CloudFormation to access the lambda content
statement {
effect = "Allow"
actions = [
"s3:GetObject"
]
resources = [
module.statics_deploy.static_bucket_arn,
"${module.statics_deploy.static_bucket_arn}/*"
]
}

# Stack creation
statement {
effect = "Allow"
actions = [
# TODO: Restrict the API Gateway action more
"apigateway:*",
"iam:CreateRole",
"iam:GetRole",
"iam:GetRolePolicy",
"iam:PassRole",
"iam:PutRolePolicy",
"iam:TagRole",
"lambda:AddPermission",
"lambda:CreateFunction",
"lambda:CreateFunctionUrlConfig",
"lambda:GetFunctionUrlConfig",
"lambda:GetFunction",
"lambda:TagResource",
"logs:CreateLogGroup",
"logs:PutRetentionPolicy",
"logs:TagLogGroup"
]
resources = ["*"]
}

# Stack deletion
statement {
effect = "Allow"
actions = [
"apigateway:*",
"iam:DeleteRole",
"iam:DeleteRolePolicy",
"iam:UntagRole",
"lambda:DeleteFunction",
"lambda:DeleteFunctionUrlConfig",
"lambda:RemovePermission",
"lambda:UntagResource",
"logs:DeleteLogGroup",
"logs:DeleteRetentionPolicy",
"logs:UntagLogGroup"
]
resources = ["*"]
}
}

data "aws_iam_policy_document" "cloudformation_permission_assume_role" {
statement {
effect = "Allow"
actions = ["sts:AssumeRole"]

principals {
type = "Service"
identifiers = ["cloudformation.amazonaws.com"]
}
}
}

resource "aws_iam_policy" "cloudformation_permission" {
name = "cloudformation-control"
path = "/${var.deployment_name}/"
description = "Managed by Terraform Next.js"
policy = data.aws_iam_policy_document.cloudformation_permission.json

tags = var.tags
}

resource "aws_iam_role" "cloudformation_permission" {
name = "cloudformation-control"
path = "/${var.deployment_name}/"
assume_role_policy = data.aws_iam_policy_document.cloudformation_permission_assume_role.json
managed_policy_arns = [
aws_iam_policy.cloudformation_permission.arn
]
}

###################
Expand Down Expand Up @@ -125,6 +230,8 @@ module "statics_deploy" {
dynamodb_table_deployments_arn = aws_dynamodb_table.deployments.arn
dynamodb_table_deployments_name = aws_dynamodb_table.deployments.id

cloudformation_role_arn = aws_iam_role.cloudformation_permission.arn

lambda_role_permissions_boundary = var.lambda_role_permissions_boundary

deployment_name = var.deployment_name
Expand Down
16 changes: 15 additions & 1 deletion modules/api/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,19 @@ data "aws_iam_policy_document" "access_upload_bucket" {
}
}

# Initiate deletion of CloudFormation stacks
data "aws_iam_policy_document" "delete_cloudformation_stack" {
statement {
effect = "Allow"
actions = [
"cloudformation:DeleteStack"
]
resources = [
"arn:aws:cloudformation:*:*:stack/*/*"
]
}
}

module "lambda" {
source = "../lambda-worker"

Expand All @@ -49,10 +62,11 @@ module "lambda" {
memory_size = 128

attach_policy_jsons = true
number_of_policy_jsons = 2
number_of_policy_jsons = 3
policy_jsons = [
data.aws_iam_policy_document.access_dynamodb_tables.json,
data.aws_iam_policy_document.access_upload_bucket.json,
data.aws_iam_policy_document.delete_cloudformation_stack.json,
]

environment_variables = {
Expand Down
3 changes: 2 additions & 1 deletion modules/deploy-controller/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ data "aws_iam_policy_document" "access_dynamodb_tables" {
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:Query",
"dynamodb:UpdateItem"
"dynamodb:UpdateItem",
"dynamodb:DeleteItem"
]
resources = [
var.dynamodb_table_deployments_arn,
Expand Down
51 changes: 13 additions & 38 deletions modules/statics-deploy/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -112,46 +112,20 @@ data "aws_iam_policy_document" "access_static_deploy" {
resources = [var.cloudfront_arn]
}

# Permissions for CloudFormation to create resources
# Create new substacks from CDK templates
statement {
actions = [
"apigateway:*",
"cloudformation:CreateStack",
"iam:CreateRole",
"iam:DeleteRole",
"iam:DeleteRolePolicy",
"iam:GetRole",
"iam:GetRolePolicy",
"iam:PassRole",
"iam:PutRolePolicy",
"iam:TagRole",
"iam:UntagRole",
"lambda:AddPermission",
"lambda:CreateFunction",
"lambda:DeleteFunction",
"lambda:CreateFunctionUrlConfig",
"lambda:GetFunctionUrlConfig",
"lambda:GetFunction",
"lambda:RemovePermission",
"lambda:TagResource",
"lambda:UntagResource",
"logs:CreateLogGroup",
"logs:DeleteLogGroup",
"logs:DeleteRetentionPolicy",
"logs:PutRetentionPolicy",
"logs:TagLogGroup",
"logs:UntagLogGroup",
"cloudformation:CreateStack"
]
resources = ["*"]
}

# Allow CloudFormation to publish status changes to the SNS queue
# Allow to pass the cloudfront role to the cloudformation stack
statement {
effect = "Allow"
actions = [
"sns:Publish"
"iam:PassRole"
]
resources = [var.deploy_status_sns_topic_arn]
resources = [var.cloudformation_role_arn]
}
}

Expand Down Expand Up @@ -254,13 +228,14 @@ module "deploy_trigger" {
]

environment_variables = {
NODE_ENV = "production"
TARGET_BUCKET = aws_s3_bucket.static_deploy.id
DISTRIBUTION_ID = var.cloudfront_id
SQS_QUEUE_URL = aws_sqs_queue.this.id
DEPLOY_STATUS_SNS_ARN = var.deploy_status_sns_topic_arn
TABLE_REGION = var.dynamodb_region
TABLE_NAME_DEPLOYMENTS = var.dynamodb_table_deployments_name
NODE_ENV = "production"
TARGET_BUCKET = aws_s3_bucket.static_deploy.id
DISTRIBUTION_ID = var.cloudfront_id
SQS_QUEUE_URL = aws_sqs_queue.this.id
DEPLOY_STATUS_SNS_ARN = var.deploy_status_sns_topic_arn
TABLE_REGION = var.dynamodb_region
TABLE_NAME_DEPLOYMENTS = var.dynamodb_table_deployments_name
CLOUDFORMATION_ROLE_ARN = var.cloudformation_role_arn
}

event_source_mapping = {
Expand Down
9 changes: 9 additions & 0 deletions modules/statics-deploy/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@ variable "lambda_role_permissions_boundary" {
default = null
}

################
# CloudFormation
################

variable "cloudformation_role_arn" {
description = "Role ARN that should be assigned to the CloudFormation substacks created by CDK."
type = string
}

#####################
# Deployment database
#####################
Expand Down
11 changes: 9 additions & 2 deletions packages/api/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,13 @@ export interface paths {
};
};
responses: {
/** Successful response. */
/** Deletion successfully requested. */
200: {
content: {
'application/json': components['schemas']['Deployment'];
};
};
/** Successful deletion. */
204: never;
400: components['responses']['InvalidParamsError'];
};
Expand Down Expand Up @@ -156,7 +162,8 @@ export interface components {
| 'CREATE_FAILED'
| 'FINISHED'
| 'DESTROY_IN_PROGRESS'
| 'DESTROY_FAILED';
| 'DESTROY_FAILED'
| 'DESTROY_REQUESTED';
DeploymentInitialized: {
id: string;
status: components['schemas']['DeploymentStatus'];
Expand Down
9 changes: 8 additions & 1 deletion packages/api/schema.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ components:
- FINISHED
- DESTROY_IN_PROGRESS
- DESTROY_FAILED
- DESTROY_REQUESTED

DeploymentInitialized:
type: object
Expand Down Expand Up @@ -271,7 +272,13 @@ paths:
required: true
description: The id of the deployment to delete.
responses:
'200':
description: Deletion successfully requested.
content:
application/json:
schema:
$ref: '#/components/schemas/Deployment'
'204':
description: Successful response.
description: Successful deletion.
'400':
$ref: '#/components/responses/InvalidParamsError'
18 changes: 12 additions & 6 deletions packages/api/src/actions/deployment/delete-deployment-by-id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@ import {
getDeploymentById,
deleteDeploymentById as dynamoDBdeleteDeploymentById,
deleteAliasById,
updateDeploymentStatusDestroyInProgress,
updateDeploymentStatusDestroyRequested,
} from '@millihq/tfn-dynamodb-actions';

import { paths } from '../../../schema';
import { DynamoDBServiceType } from '../../services/dynamodb';
import { CloudFormationServiceType } from '../../services/cloudformation';
import { deploymentDefaultSerializer } from '../../serializers/deployment';

type SuccessResponse =
paths['/deployments/{deploymentId}']['delete']['responses']['200']['content']['application/json'];
type ErrorResponse =
paths['/deployments/{deploymentId}']['delete']['responses']['400']['content']['application/json'];

Expand All @@ -24,7 +27,7 @@ const RESPONSE_DEPLOYMENT_DELETION_FAILED: ErrorResponse = {
async function deleteDeploymentById(
req: Request,
res: Response
): Promise<void> {
): Promise<SuccessResponse | void> {
const cloudFormationService = req.namespace
.cloudFormation as CloudFormationServiceType;
const dynamoDB = req.namespace.dynamoDB as DynamoDBServiceType;
Expand Down Expand Up @@ -122,18 +125,20 @@ async function deleteDeploymentById(

// Trigger stack deletion
if (deployment.CFStack) {
await cloudFormationService.deleteStack(deployment.CFStack);

await updateDeploymentStatusDestroyInProgress({
const updatedDeployment = await updateDeploymentStatusDestroyRequested({
dynamoDBClient: dynamoDB.getDynamoDBClient(),
deploymentTableName: dynamoDB.getDeploymentTableName(),
deploymentId: {
PK: deployment.PK,
SK: deployment.SK,
},
});
await cloudFormationService.deleteStack(deployment.CFStack);

return res.sendStatus(204);
// Deployment status is updated by deployment-controller when
// CloudFormation triggers a `DELETE_IN_PROGRESS` event on the stack
res.status(200);
return deploymentDefaultSerializer(updatedDeployment);
}

// No CloudFormation Stack present, so we can delete it from the database
Expand All @@ -152,6 +157,7 @@ async function deleteDeploymentById(

return res.sendStatus(204);

case 'DESTROY_REQUESTED':
case 'DESTROY_IN_PROGRESS': {
const errorResponse: ErrorResponse = {
code: 'DEPLOYMENT_DESTROY_IN_PROGRESS',
Expand Down
Loading