diff --git a/backend/src/controllers/terraform.ts b/backend/src/controllers/terraform.ts index dcdd41b..ea9924d 100644 --- a/backend/src/controllers/terraform.ts +++ b/backend/src/controllers/terraform.ts @@ -10,26 +10,40 @@ import updateHead from "../githubapi/updateHead"; import {getModeNumber} from "../githubapi/util"; import {NamedAwsBackend} from "../terraform/awsBackend"; import {AwsProvider} from "../terraform/awsProvider"; +import {DynamoDb} from "../terraform/DynamoDb"; import {Ec2} from "../terraform/ec2"; import {Gce} from "../terraform/gce"; import {GlacierVault} from "../terraform/glacierVault"; import {NamedGoogleBackend} from "../terraform/googleBackend"; import {GoogleProvider} from "../terraform/googleProvider"; import {lambdaFunction} from "../terraform/lambdaFunction"; +import {prefabNetworkFromArr, splitForPrefab} from "../terraform/prefab"; import {S3} from "../terraform/s3"; import {rootBlockSplitBackend} from "../terraform/terraform"; import {internalErrorHandler} from "../types/errorHandler"; import {TerraformResource} from "../types/terraform"; +import {jsonToHcl} from "../util"; export const createTerraformSettings = (req: Request, res: Response): void => { const provider = req.body.settings?.provider as "aws" | "google" | "azure"; + const secure = req.body.settings.secure ?? false; + const allowSsh = req.body.settings.allowSsh ?? false; + const allowEgressWeb = req.body.settings.allowEgressWeb ?? false; + const allowIngressWeb = req.body.settings.allowIngressWeb ?? false; + //Only needed for google const project = provider === "google" ? (req.body.settings?.project as string) : ""; const resourcesRaw = req.body.settings?.resources as (TerraformResource & { - type: "ec2" | "gce" | "s3" | "glacierVault" | "lambdaFunction"; + type: + | "ec2" + | "gce" + | "s3" + | "glacierVault" + | "lambdaFunction" + | "dynamoDb"; })[]; const repo = req.body.repo as string; const token = req.headers?.token as string; @@ -49,6 +63,13 @@ export const createTerraformSettings = (req: Request, res: Response): void => { } else if (resource.type === "glacierVault") { const glacierVault: GlacierVault = resource as GlacierVault; return new GlacierVault(glacierVault.id, glacierVault.autoIam); + } else if (resource.type === "dynamoDb") { + const dynamoDb: DynamoDb = resource as DynamoDb; + return new DynamoDb( + dynamoDb.id, + dynamoDb.attributes, + dynamoDb.autoIam + ); } else if (resource.type === "lambdaFunction") { const lambdaFunc: lambdaFunction = resource as lambdaFunction; return new lambdaFunction( @@ -67,12 +88,25 @@ export const createTerraformSettings = (req: Request, res: Response): void => { return; } + const [gce, lambda, networkedResources] = splitForPrefab(resources); + + const network = + networkedResources.length > 0 && provider === "aws" + ? prefabNetworkFromArr(networkedResources, { + allEgress: !secure, + allIngress: !secure, + ssh: secure && allowSsh, + webEgress: secure && allowEgressWeb, + webIngress: secure && allowIngressWeb + }) + : networkedResources; + const [root, backend] = rootBlockSplitBackend( provider === "aws" ? new AwsProvider() : new GoogleProvider(project), provider === "aws" ? new NamedAwsBackend() : new NamedGoogleBackend(project), - resources + [...gce, ...lambda, ...network] ); getHead(token, repo, "main") @@ -81,7 +115,7 @@ export const createTerraformSettings = (req: Request, res: Response): void => { const blobRoot = await postBlob( token, repo, - JSON.stringify(root, null, 2) + "\n" + jsonToHcl(root) + "\n" ); /* @@ -90,14 +124,14 @@ export const createTerraformSettings = (req: Request, res: Response): void => { const blobBackend = await postBlob( token, repo, - JSON.stringify(backend, null, 2) + "\n" + jsonToHcl(backend) + "\n" ); */ // Create a new branch to post our commit to const branchName = "DevXP-Configuration"; const newBranch = await createBranch( - `refs/heads/${branchName}`, + branchName, token, repo, head.sha @@ -115,7 +149,7 @@ export const createTerraformSettings = (req: Request, res: Response): void => { //Create a new tree within that one const newTree = await createTree(token, repo, tree.sha, [ { - path: "terraform.tf.json", + path: "terraform.tf", mode: getModeNumber("blob"), type: "blob", sha: blobRoot.sha, @@ -125,7 +159,7 @@ export const createTerraformSettings = (req: Request, res: Response): void => { /* Removed for M1 presentation. We'll solve the chicken and egg for milestone 2 { - path: "backend.tf.json", + path: "backend.tf", mode: getModeNumber("blob"), type: "blob", sha: blobBackend.sha, diff --git a/backend/src/githubapi/createBranch.ts b/backend/src/githubapi/createBranch.ts index c792b7b..cca6591 100644 --- a/backend/src/githubapi/createBranch.ts +++ b/backend/src/githubapi/createBranch.ts @@ -1,21 +1,24 @@ /* eslint-disable prettier/prettier */ import axios from "axios"; import {GithubBranch, isGithubBranch} from "../types/github"; +import getHead from "./getHead"; import {GITHUB_BASE_URL, createGithubHeader} from "./util"; export default ( - ref: string, + branchName: string, token: string, repo: string, treeSha: string ): Promise => new Promise((resolve, reject) => { + let errCache: any; + axios .post( `${GITHUB_BASE_URL}/repos/${repo}/git/refs`, { sha: treeSha, - ref: ref + ref: `refs/heads/${branchName}` }, createGithubHeader(token) ) @@ -31,5 +34,15 @@ export default ( reject(new Error("Invalid response from github")); } }) - .catch(reject); + .catch(err => { + errCache = err; + //Maybe the branch exists so we should try to retrieve it + return getHead(token, repo, branchName); + + //TODO: Refactor this + }) + .then(head => resolve(head as GithubBranch)) + .catch(() => { + reject(errCache); + }); }); diff --git a/backend/src/index.ts b/backend/src/index.ts index a84c01b..eb03af9 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -17,9 +17,44 @@ server.route("/", mainRouter); // import {testToFileAws} from "./util"; // import {Ec2} from "./terraform/ec2"; -// testToFileAws("/home/brennan/aws_test/devxp.tf.json", [ -// new Ec2("AUTO_UBUNTU", "t2.medium", "myinstance", true) -// ]); +// import {prefabNetwork} from "./terraform/prefab"; +// import {S3} from "./terraform/s3"; +// import {GlacierVault} from "./terraform/glacierVault"; +// import {DynamoDb} from "./terraform/DynamoDb"; + +// testToFileAws( +// "/home/brennan/aws_test/devxp.tf", +// prefabNetwork( +// { +// ec2: [new Ec2("AUTO_UBUNTU", "t2.micro", "instance_a", true)], +// s3: [ +// new S3( +// "devxp_test_bucket_a", +// false, +// false, +// "devxp-test-bucket-a" +// ) +// ], +// glacier: new GlacierVault( +// "devxp_test_vault", +// false, +// "devxp-test-vault" +// ), +// dynamo: new DynamoDb("devxp_test_dynamo_db", [ +// { +// name: "field1", +// type: "S", +// isHash: true +// } +// ]) +// }, +// { +// ssh: true, +// webEgress: true, +// webIngress: true +// } +// ) +// ); mongoose.connection.on( "error", diff --git a/backend/src/terraform/AwsIamInstanceProfile.ts b/backend/src/terraform/AwsIamInstanceProfile.ts new file mode 100644 index 0000000..4a01aca --- /dev/null +++ b/backend/src/terraform/AwsIamInstanceProfile.ts @@ -0,0 +1,23 @@ +import {jsonRoot} from "./util"; +import {Resource} from "./resource"; + +export interface AwsIamInstanceProfile { + role: string; +} +export class AwsIamInstanceProfile + extends Resource + implements AwsIamInstanceProfile +{ + constructor(id: string, role: string, name?: string) { + super(id, "AwsIamInstanceProfile", false, name); + this.role = role; + } + + //Returns an array of resource blocks + toJSON() { + return jsonRoot("aws_iam_instance_profile", this.id, { + name: this.name, + role: `\${aws_iam_role.${this.role}.name}` + }); + } +} diff --git a/backend/src/terraform/AwsInternetGateway.ts b/backend/src/terraform/AwsInternetGateway.ts new file mode 100644 index 0000000..db577de --- /dev/null +++ b/backend/src/terraform/AwsInternetGateway.ts @@ -0,0 +1,22 @@ +import {jsonRoot} from "./util"; +import {Resource} from "./resource"; + +export interface AwsInternetGateway { + vpc: string; +} +export class AwsInternetGateway + extends Resource + implements AwsInternetGateway +{ + constructor(id: string, vpc: string, name?: string) { + super(id, "AwsInternetGateway", false, name); + this.vpc = vpc; + } + + //Returns a resource block + toJSON() { + return jsonRoot("aws_internet_gateway", this.id, { + vpc_id: `\${aws_vpc.${this.vpc}.id}` + }); + } +} diff --git a/backend/src/terraform/AwsRoute.ts b/backend/src/terraform/AwsRoute.ts new file mode 100644 index 0000000..d3597da --- /dev/null +++ b/backend/src/terraform/AwsRoute.ts @@ -0,0 +1,30 @@ +import {jsonRoot} from "./util"; +import {Resource} from "./resource"; + +export interface AwsRoute { + route_table_id: string; + cidr_block: string; + gateway_id: string; +} +export class AwsRoute extends Resource implements AwsRoute { + constructor( + id: string, + route_table_id: string, + cidr_block: string, + gateway_id: string + ) { + super(id, "AwsRoute", false); + this.route_table_id = route_table_id; + this.cidr_block = cidr_block; + this.gateway_id = gateway_id; + } + + //Returns a resource block + toJSON() { + return jsonRoot("aws_route", this.id, { + route_table_id: `\${aws_route_table.${this.route_table_id}.id}`, + destination_cidr_block: this.cidr_block, + gateway_id: `\${aws_internet_gateway.${this.gateway_id}.id}` + }); + } +} diff --git a/backend/src/terraform/AwsRouteTable.ts b/backend/src/terraform/AwsRouteTable.ts new file mode 100644 index 0000000..d369c5d --- /dev/null +++ b/backend/src/terraform/AwsRouteTable.ts @@ -0,0 +1,47 @@ +import {jsonRoot} from "./util"; +import {Resource} from "./resource"; +import {AwsRoute} from "../types/terraform"; + +export interface AwsRouteTable { + vpc: string; + routes: AwsRoute[]; + defaultTable: boolean; +} +export class AwsRouteTable + extends Resource + implements AwsRouteTable +{ + constructor( + id: string, + vpc: string, + routes: AwsRoute[], + defaultTable = false, + name?: string + ) { + super(id, "AwsRouteTable", false, name); + this.vpc = vpc; + this.routes = routes; + this.defaultTable = defaultTable; + } + + //Returns a resource block + toJSON() { + const resource = this.defaultTable + ? "aws_default_route_table" + : "aws_route_table"; + + const json: Record = {}; + + if (this.routes.length > 0) { + json.route = this.routes; + } + + if (this.defaultTable) { + json.default_route_table_id = `\${aws_vpc.${this.vpc}.default_route_table_id}`; + } else { + json.vpc_id = `\${aws_vpc.${this.vpc}.id}`; + } + + return jsonRoot(resource, this.id, json); + } +} diff --git a/backend/src/terraform/AwsSecurityGroup.ts b/backend/src/terraform/AwsSecurityGroup.ts new file mode 100644 index 0000000..aa29773 --- /dev/null +++ b/backend/src/terraform/AwsSecurityGroup.ts @@ -0,0 +1,42 @@ +import {jsonRoot} from "./util"; +import {Resource} from "./resource"; +import {Firewall} from "../types/terraform"; + +const removeType = (f: any) => { + delete f.type; + return f; +}; + +export interface AwsSecurityGroup { + vpc: string; + firewalls: Firewall[]; +} +export class AwsSecurityGroup + extends Resource + implements AwsSecurityGroup +{ + constructor( + id: string, + vpc: string, + firewalls: Firewall[] = [], + name?: string + ) { + super(id, "AwsSecurityGroup", false, name); + this.vpc = vpc; + this.firewalls = firewalls; + } + + //Returns a resource block + toJSON() { + return jsonRoot("aws_security_group", this.id, { + vpc_id: `\${aws_vpc.${this.vpc}.id}`, + name: this.name, + ingress: this.firewalls + .filter(f => f.type === "ingress") + .map(removeType), + egress: this.firewalls + .filter(f => f.type === "egress") + .map(removeType) + }); + } +} diff --git a/backend/src/terraform/AwsVpcEndpoint.ts b/backend/src/terraform/AwsVpcEndpoint.ts new file mode 100644 index 0000000..f69e0a3 --- /dev/null +++ b/backend/src/terraform/AwsVpcEndpoint.ts @@ -0,0 +1,84 @@ +import {jsonRoot} from "./util"; +import {Resource} from "./resource"; + +export interface AwsVpcEndpoint { + vpc: string; + service: string; + security_group_ids: string[]; + vpc_endpoint_type: string; + route_table: { + id?: string; + isDefault: boolean; + }; + privateDns: boolean; +} +export class AwsVpcEndpoint + extends Resource + implements AwsVpcEndpoint +{ + constructor( + id: string, + vpc: string, + service: string, + security_group_ids: string[] = [], + route_table: { + id?: string; + isDefault: boolean; + }, + vpc_endpoint_type = "Gateway", + privateDns = false + ) { + super(id, "AwsVpcEndpoint"); + this.vpc = vpc; + this.service = service; + this.security_group_ids = security_group_ids ?? []; + this.vpc_endpoint_type = vpc_endpoint_type; + this.route_table = route_table; + this.privateDns = privateDns; + } + + //Returns a resource block + toJSON() { + const internal: Record = { + vpc_id: `\${aws_vpc.${this.vpc}.id}`, + service_name: this.service, + vpc_endpoint_type: this.vpc_endpoint_type, + private_dns_enabled: this.privateDns ?? false + }; + + if (this.security_group_ids && this.security_group_ids.length > 0) { + internal.security_group_ids = this.security_group_ids.map( + id => `\${aws_security_group.${id}.id}` + ); + } + + let json = [jsonRoot("aws_vpc_endpoint", this.id, internal)]; + + const tableParent = this.route_table.isDefault + ? "aws_default_route_table" + : "aws_route_table"; + if (!this.route_table.id) { + json = [ + ...json, + jsonRoot(tableParent, `${this.id}_route_table`, { + vpc_id: `\${aws_vpc.${this.vpc}.id}` + }) + ]; + this.route_table.id = `${this.id}_route_table`; + } + + json = [ + ...json, + jsonRoot( + "aws_vpc_endpoint_route_table_association", + `${this.id}_route_association`, + { + route_table_id: `\${${tableParent}.${this.route_table.id}.id}`, + vpc_endpoint_id: `\${aws_vpc_endpoint.${this.id}.id}` + } + ) + ]; + + return json; + } +} diff --git a/backend/src/terraform/DynamoDb.ts b/backend/src/terraform/DynamoDb.ts new file mode 100644 index 0000000..acc08a2 --- /dev/null +++ b/backend/src/terraform/DynamoDb.ts @@ -0,0 +1,71 @@ +import {jsonRoot} from "./util"; +import {ResourceWithIam} from "./resource"; +import {db_attribute} from "../types/terraform"; + +export interface DynamoDb { + attributes: db_attribute[]; +} +export class DynamoDb extends ResourceWithIam implements DynamoDb { + constructor( + id: string, + attributes: db_attribute[], + autoIam?: boolean, + name?: string + ) { + super(id, "DynamoDb", autoIam, name); + this.attributes = attributes; + } + + //Returns a resource block + toJSON() { + return jsonRoot("aws_dynamodb_table", this.id, { + name: this.name, + hash_key: this.attributes.filter(a => a.isHash)[0]?.name, + billing_mode: "PAY_PER_REQUEST", + ttl: { + attribute_name: "TimeToExist", + enabled: true + }, + attribute: this.attributes.map(a => { + if ("isHash" in a) { + delete a.isHash; + } + return a; + }) + }); + } + + //https://asecure.cloud/l/iam/ + getPolicyDocument() { + return [ + ResourceWithIam.policyStatement( + [ + "dynamodb:DescribeTable", + "dynamodb:Query", + "dynamodb:Scan", + "dynamodb:BatchGet*", + "dynamodb:DescribeStream", + "dynamodb:DescribeTable", + "dynamodb:Get*", + "dynamodb:Query", + "dynamodb:Scan", + "dynamodb:BatchWrite*", + "dynamodb:CreateTable", + "dynamodb:Delete*", + "dynamodb:Update*", + "dynamodb:PutItem" + ], + `\${aws_dynamodb_table.${this.id}.arn}` + ), + ResourceWithIam.policyStatement( + [ + "dynamodb:List*", + "dynamodb:DescribeReservedCapacity*", + "dynamodb:DescribeLimits", + "dynamodb:DescribeTimeToLive" + ], + `*` + ) + ]; + } +} diff --git a/backend/src/terraform/Eip.ts b/backend/src/terraform/Eip.ts new file mode 100644 index 0000000..4345d88 --- /dev/null +++ b/backend/src/terraform/Eip.ts @@ -0,0 +1,22 @@ +import {jsonRoot} from "./util"; +import {Resource} from "./resource"; + +export interface Eip { + instance: string; + vpc: boolean; +} +export class Eip extends Resource implements Eip { + constructor(id: string, instance: string, vpc: boolean) { + super(id, "Eip"); + this.instance = instance; + this.vpc = vpc; + } + + //Returns a resource block + toJSON() { + return jsonRoot("aws_eip", this.id, { + instance: `\${aws_instance.${this.instance}.id}`, + vpc: this.vpc + }); + } +} diff --git a/backend/src/terraform/README.md b/backend/src/terraform/README.md index e7ed645..ef78942 100644 --- a/backend/src/terraform/README.md +++ b/backend/src/terraform/README.md @@ -1,3 +1,51 @@ +```ts +import {testToFileAws} from "./util"; +import {Ec2} from "./terraform/ec2"; +import {AwsVpc} from "./terraform/awsVpc"; +import {AwsSecurityGroup} from "./terraform/AwsSecurityGroup"; + +const vpc = "my_vpc_for_devxp"; +const securityGroup = "securitygroup_for_devp"; +const cidr = "10.0.0.0/24"; + +testToFileAws("/home/brennan/aws_test/devxp.tf", [ + new Ec2( + "AUTO_UBUNTU", + "t2.medium", + "myinstance", + false, + 2, + `${vpc}_subnet`, + securityGroup + ), + new AwsVpc(cidr, true, vpc), + new AwsSecurityGroup(securityGroup, vpc, [ + { + type: "ingress", + from_port: 433, + to_port: 433, + protocol: "tcp", + cidr_blocks: [cidr] + }, + { + type: "ingress", + from_port: 80, + to_port: 80, + protocol: "tcp", + cidr_blocks: [cidr] + }, + { + type: "egress", + from_port: 0, + to_port: 0, + protocol: "-1", + cidr_blocks: ["0.0.0.0/0"] + } + ]) +]); +``` + + ```hcl terraform { diff --git a/backend/src/terraform/awsIamRolePolicyAttachment.ts b/backend/src/terraform/awsIamRolePolicyAttachment.ts new file mode 100644 index 0000000..d6f2cba --- /dev/null +++ b/backend/src/terraform/awsIamRolePolicyAttachment.ts @@ -0,0 +1,29 @@ +import {jsonRoot} from "./util"; +import {Resource} from "./resource"; + +export interface AwsIamRolePolicyAttachment { + role: string; + policy: string; +} +export class AwsIamRolePolicyAttachment + extends Resource + implements AwsIamRolePolicyAttachment +{ + constructor(id: string, policy: string, role: string, name?: string) { + super(id, "AwsIamRolePolicyAttachment", false, name); + + this.policy = policy; + this.role = role; + } + + //Returns an array of resource blocks + toJSON() { + return [ + //The iam user block itself + jsonRoot("aws_iam_role_policy_attachment", this.id, { + policy_arn: `\${aws_iam_policy.${this.policy}.arn}`, + role: `\${aws_iam_role.${this.role}.name}` + }) + ]; + } +} diff --git a/backend/src/terraform/awsSubnet.ts b/backend/src/terraform/awsSubnet.ts index f5c250e..443c9ab 100644 --- a/backend/src/terraform/awsSubnet.ts +++ b/backend/src/terraform/awsSubnet.ts @@ -14,7 +14,7 @@ export class AwsSubnet extends Resource implements AwsSubnet { cidr_block: string, map_public_ip_on_launch: boolean, id: string, - availability_zone = "us-west-2", + availability_zone = "us-west-2a", autoIam?: boolean, name?: string ) { @@ -29,7 +29,7 @@ export class AwsSubnet extends Resource implements AwsSubnet { toJSON() { return [ jsonRoot("aws_subnet", this.id, { - vpc: this.vpc, + vpc_id: `\${aws_vpc.${this.vpc}.id}`, cidr_block: this.cidr_block, map_public_ip_on_launch: this.map_public_ip_on_launch, availability_zone: this.availability_zone diff --git a/backend/src/terraform/awsVpc.ts b/backend/src/terraform/awsVpc.ts index a033956..d383413 100644 --- a/backend/src/terraform/awsVpc.ts +++ b/backend/src/terraform/awsVpc.ts @@ -1,27 +1,101 @@ import {jsonRoot} from "./util"; import {Resource} from "./resource"; +import {AwsSubnet} from "./awsSubnet"; +import {AwsInternetGateway} from "./AwsInternetGateway"; +import {AwsRouteTable} from "./AwsRouteTable"; +import {AwsRoute} from "./AwsRoute"; export interface AwsVpc { cidr_block: string; + private_cidr: string; + public_cidr: string; + privateDns: boolean; } export class AwsVpc extends Resource implements AwsVpc { constructor( cidr_block: string, + private_cidr: string, + public_cidr: string, id: string, autoIam?: boolean, - name?: string + name?: string, + privateDns = false ) { super(id, "awsVpc", autoIam, name); this.cidr_block = cidr_block; + this.private_cidr = private_cidr; + this.public_cidr = public_cidr; + this.privateDns = privateDns; } //Returns an array of resource blocks toJSON() { + const gatewayId = `${this.id}_internetgateway`; + const publicRouteTableId = `${this.id}_routetable_pub`; + const privateRouteTableId = `${this.id}_routetable_priv`; + const publicSubetId = `${this.id}_subnet_public`; + const privateSubnetId = `${this.id}_subnet_private`; + return [ + //PRIVATE + new AwsSubnet( + this.id, + this.private_cidr, + false, + privateSubnetId + ).toJSON(), + new AwsRouteTable(privateRouteTableId, this.id, [], false).toJSON(), + jsonRoot( + "aws_route_table_association", + `${this.id}_subnet_private_assoc`, + { + subnet_id: `\${aws_subnet.${privateSubnetId}.id}`, + route_table_id: `\${aws_route_table.${privateRouteTableId}.id}` + } + ), + + //----------------------------------------------------------------// + + //PUBLIC + new AwsSubnet( + this.id, + this.public_cidr, + true, + publicSubetId + ).toJSON(), + new AwsInternetGateway(gatewayId, this.id).toJSON(), + new AwsRouteTable( + publicRouteTableId, + this.id, + [ + { + cidr_block: "0.0.0.0/0", + gateway_id: `\${aws_internet_gateway.${gatewayId}.id}` + } + ], + false + ).toJSON(), + new AwsRoute( + `${this.id}_internet_route`, + publicRouteTableId, + "0.0.0.0/0", + gatewayId + ).toJSON(), + jsonRoot( + "aws_route_table_association", + `${this.id}_subnet_public_assoc`, + { + subnet_id: `\${aws_subnet.${publicSubetId}.id}`, + route_table_id: `\${aws_route_table.${publicRouteTableId}.id}` + } + ), + jsonRoot("aws_vpc", this.id, { - cidr_block: this.cidr_block + cidr_block: this.cidr_block, + enable_dns_support: this.privateDns, + enable_dns_hostnames: this.privateDns }) - ]; + ].flat(); } } diff --git a/backend/src/terraform/ec2.ts b/backend/src/terraform/ec2.ts index 102e48f..9b0e283 100644 --- a/backend/src/terraform/ec2.ts +++ b/backend/src/terraform/ec2.ts @@ -1,21 +1,35 @@ import {ec2InstanceType, amiType, TerraformJson} from "../types/terraform"; import {jsonRoot} from "./util"; import {ResourceWithIam} from "./resource"; +import {Eip} from "./Eip"; +import {arr} from "../util"; export interface Ec2 { ami: amiType; instance_type: ec2InstanceType; + eip: number; + subnet?: string; + securityGroups?: string[] | string; + iam_instance_profile?: string; } export class Ec2 extends ResourceWithIam implements Ec2 { constructor( ami: amiType, instance_type: ec2InstanceType, id: string, - autoIam?: boolean + autoIam?: boolean, + eip?: number, + subnet?: string, + securityGroups?: string[] | string, + iam_instance_profile?: string ) { super(id, "Ec2", autoIam); this.ami = ami; this.instance_type = instance_type; + this.eip = eip ?? 0; + this.subnet = subnet; + this.securityGroups = securityGroups; + this.iam_instance_profile = iam_instance_profile; } //Returns a resource block @@ -38,7 +52,32 @@ export class Ec2 extends ResourceWithIam implements Ec2 { ]; } - return jsonRoot("aws_instance", this.id, json); + if (this.subnet) { + json.subnet_id = `\${aws_subnet.${this.subnet}.id}`; + json.associate_public_ip_address = true; + } + + if (this.securityGroups) { + json.vpc_security_group_ids = arr(this.securityGroups).map( + g => `\${aws_security_group.${g}.id}` + ); + } + + if (this.iam_instance_profile) { + json.iam_instance_profile = `\${aws_iam_instance_profile.${this.iam_instance_profile}.name}`; + } + + let output = [jsonRoot("aws_instance", this.id, json)]; + + if (this.eip > 0) { + output = [ + ...output, + + new Eip(`${this.id}_eip`, this.id, this.eip === 2) + ]; + } + + return output; } static latestAmiMap: Record = { @@ -55,8 +94,17 @@ export class Ec2 extends ResourceWithIam implements Ec2 { if (/^AUTO_(UBUNTU|WINDOWS|AMAZON)$/.test(this.ami)) { const os = this.ami.slice(5).toLowerCase(); - if ("data" in json && Array.isArray(json.data)) { + for (let i = 0; i < json.data.length; i++) { + if ( + "aws_ami" in json.data[i] && + Array.isArray(json.data[i].aws_ami) && + json.data[i].aws_ami.length > 0 && + `${os}_latest` in json.data[i].aws_ami[0] + ) { + return json; + } + } json.data = [ ...json.data, jsonRoot("aws_ami", `${os}_latest`, { @@ -82,6 +130,7 @@ export class Ec2 extends ResourceWithIam implements Ec2 { //An array of policy statements for IAM //These need to be researched from //https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_examples.html + //https://asecure.cloud/l/iam/ getPolicyDocument() { return [ ResourceWithIam.policyStatement( diff --git a/backend/src/terraform/glacierVault.ts b/backend/src/terraform/glacierVault.ts index f732808..4b03f24 100644 --- a/backend/src/terraform/glacierVault.ts +++ b/backend/src/terraform/glacierVault.ts @@ -33,17 +33,22 @@ export class GlacierVault //An array of policy statements for IAM //These need to be researched from //https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_examples.html + //https://asecure.cloud/l/iam/ getPolicyDocument() { - return ResourceWithIam.policyStatement( - [ - "glacier:InitiateJob", - "glacier:GetJobOutput", - "glacier:UploadArchive", - "glacier:InitiateMultipartUpload", - "glacier:AbortMultipartUpload", - "glacier:CompleteMultipartUpload" - ], - `\${aws_glacier_vault.${this.id}.arn}` - ); + return [ + ResourceWithIam.policyStatement( + [ + "glacier:InitiateJob", + "glacier:GetJobOutput", + "glacier:UploadArchive", + "glacier:InitiateMultipartUpload", + "glacier:AbortMultipartUpload", + "glacier:CompleteMultipartUpload", + "glacier:DescribeVault" + ], + `\${aws_glacier_vault.${this.id}.arn}` + ), + ResourceWithIam.policyStatement(["glacier:ListVaults"], "*") + ]; } } diff --git a/backend/src/terraform/iamRole.ts b/backend/src/terraform/iamRole.ts index 61cc64e..70eaa09 100644 --- a/backend/src/terraform/iamRole.ts +++ b/backend/src/terraform/iamRole.ts @@ -1,19 +1,36 @@ import {jsonRoot} from "./util"; import {Resource} from "./resource"; -import {arr} from "../util"; -export interface IamRole {} +export interface IamRole { + service: string; +} export class IamRole extends Resource implements IamRole { - constructor(id: string) { - super(id, "IamRole"); + constructor(id: string, service: string, name?: string) { + super(id, "IamRole", false, name); + this.service = service; } //Returns an array of resource blocks toJSON() { return jsonRoot("aws_iam_role", this.id, { - name: this.id, - assume_role_policy: - '{\n "Version": "2012-10-17",\n "Statement": [\n {\n "Action": "sts:AssumeRole",\n "Principal": {\n "Service": "lambda.amazonaws.com"\n },\n "Effect": "Allow",\n "Sid": ""\n }\n ]\n}\n' + name: this.name, + assume_role_policy: JSON.stringify( + { + Version: "2012-10-17", + Statement: [ + { + Action: "sts:AssumeRole", + Principal: { + Service: this.service + }, + Effect: "Allow", + Sid: "" + } + ] + }, + null, + 2 + ) }); } } diff --git a/backend/src/terraform/lambdaFunction.ts b/backend/src/terraform/lambdaFunction.ts index 06b4907..f5d5f23 100644 --- a/backend/src/terraform/lambdaFunction.ts +++ b/backend/src/terraform/lambdaFunction.ts @@ -27,7 +27,10 @@ export class lambdaFunction //Returns an array of resource blocks toJSON() { - const iamRole = new IamRole("iam_for_lambda_" + this.functionName); + const iamRole = new IamRole( + "iam_for_lambda_" + this.functionName, + "lambda.amazonaws.com" + ); return [ iamRole.toJSON(), jsonRoot("aws_lambda_function", this.id, { diff --git a/backend/src/terraform/prefab.ts b/backend/src/terraform/prefab.ts new file mode 100644 index 0000000..693fac9 --- /dev/null +++ b/backend/src/terraform/prefab.ts @@ -0,0 +1,248 @@ +import {Firewall, TerraformResource} from "../types/terraform"; +import {arr} from "../util"; +import {AwsIamInstanceProfile} from "./AwsIamInstanceProfile"; +import {AwsIamRolePolicyAttachment} from "./awsIamRolePolicyAttachment"; +import {AwsSecurityGroup} from "./AwsSecurityGroup"; +import {AwsVpc} from "./awsVpc"; +//import {AwsVpcEndpoint} from "./AwsVpcEndpoint"; +import {DynamoDb} from "./DynamoDb"; +import {Ec2} from "./ec2"; +import {Gce} from "./gce"; +import {GlacierVault} from "./glacierVault"; +import {IamRole} from "./iamRole"; +import {lambdaFunction} from "./lambdaFunction"; +import {S3} from "./s3"; + +export type PrefabSupports = Ec2 | S3 | GlacierVault | DynamoDb; + +export const splitForPrefab = ( + resources: TerraformResource[] +): [Gce[], lambdaFunction[], PrefabSupports[]] => { + let gce: Gce[] = []; + let lambda: lambdaFunction[] = []; + let prefabSupports: PrefabSupports[] = []; + + resources.forEach(r => { + if (r.type.toLowerCase() === "gce") { + gce = [...gce, r as Gce]; + } else if (r.type.toLowerCase() === "lambdafunction") { + lambda = [...lambda, r as lambdaFunction]; + } else { + prefabSupports = [...prefabSupports, r as PrefabSupports]; + } + }); + + return [gce, lambda, prefabSupports]; +}; + +export const prefabNetworkFromArr = ( + resources: PrefabSupports[], + rules: { + ssh?: boolean; + sshCidr?: string[]; + allEgress?: boolean; + allIngress?: boolean; + webEgress?: boolean; + webIngress?: boolean; + webCidr?: string[]; + }, + vpc_cidr = "10.0.0.0/16", + public_cidr = "10.0.0.0/24", + private_cidr = "10.0.128.0/24", + vpc = "devxp_vpc", + securityGroup = "devxp_security_group" +) => + prefabNetwork( + { + ec2: resources.filter(r => r.type.toLowerCase() === "ec2") as Ec2[], + s3: resources.filter(r => r.type.toLowerCase() === "s3") as S3[], + dynamo: resources.filter( + r => r.type.toLowerCase() === "dynamodb" + ) as DynamoDb[], + glacier: resources.filter( + r => r.type.toLowerCase() === "glaciervault" + ) as GlacierVault[] + }, + rules, + vpc_cidr, + public_cidr, + private_cidr, + vpc, + securityGroup + ); + +export const prefabNetwork = ( + resources: { + ec2?: Ec2[] | Ec2; + s3?: S3[] | S3; + glacier?: GlacierVault[] | GlacierVault; + dynamo?: DynamoDb[] | DynamoDb; + }, + rules: { + ssh?: boolean; + sshCidr?: string[]; + allEgress?: boolean; + allIngress?: boolean; + webEgress?: boolean; + webIngress?: boolean; + webCidr?: string[]; + }, + vpc_cidr = "10.0.0.0/16", + public_cidr = "10.0.0.0/24", + private_cidr = "10.0.128.0/24", + vpc = "devxp_vpc", + securityGroup = "devxp_security_group" +): TerraformResource[] => { + const instances = arr(resources.ec2 ?? []).map( + (ec2: Ec2) => + new Ec2( + ec2.ami, + ec2.instance_type, + ec2.id, + ec2.autoIam, + 2, + + //TODO: Find a way to put this in the private subnet + `${vpc}_subnet_public`, + //`${vpc}_subnet_private`, + securityGroup + ) + ); + const buckets = arr(resources.s3 ?? []).map( + (bucket: S3) => new S3(bucket.id, true, true, bucket.name) + ); + const vaults = arr(resources.glacier ?? []).map( + (vault: GlacierVault) => new GlacierVault(vault.id, true) + ); + const dbs = arr(resources.dynamo ?? []).map( + (db: DynamoDb) => new DynamoDb(db.id, db.attributes, true, db.name) + ); + + const policies = [...buckets, ...vaults, ...dbs].map( + bucket => `${bucket.id}_iam_policy0` + ); + const iamRoles = instances.map( + ec2 => new IamRole(`${ec2.id}_iam_role`, "ec2.amazonaws.com") + ); + + let attachments: AwsIamRolePolicyAttachment[] = []; + policies.forEach(p => { + iamRoles.forEach(r => { + attachments = [ + ...attachments, + new AwsIamRolePolicyAttachment( + `${r.id}_${p}_attachment`, + p, + r.id + ) + ]; + }); + }); + + const instanceProfiles = iamRoles.map( + r => new AwsIamInstanceProfile(`${r.id}_instance_profile`, r.id) + ); + instanceProfiles.forEach((p, i) => { + instances[i].iam_instance_profile = p.id; + }); + + let firewalls: Firewall[] = []; + if (rules.allEgress) { + firewalls = [ + ...firewalls, + { + type: "egress", + from_port: 0, + to_port: 0, + protocol: "-1", + cidr_blocks: ["0.0.0.0/0"] + } + ]; + } + if (rules.allIngress) { + firewalls = [ + ...firewalls, + { + type: "ingress", + from_port: 0, + to_port: 0, + protocol: "-1", + cidr_blocks: ["0.0.0.0/0"] + } + ]; + } + if (rules.ssh) { + firewalls = [ + ...firewalls, + { + type: "ingress", + from_port: 22, + to_port: 22, + protocol: "tcp", + cidr_blocks: rules.sshCidr ?? ["0.0.0.0/0"] + } + ]; + } + if (rules.webIngress) { + firewalls = [ + ...firewalls, + { + type: "ingress", + from_port: 80, + to_port: 80, + protocol: "tcp", + cidr_blocks: rules.webCidr ?? ["0.0.0.0/0"] + }, + { + type: "ingress", + from_port: 443, + to_port: 443, + protocol: "tcp", + cidr_blocks: rules.webCidr ?? ["0.0.0.0/0"] + } + ]; + } + if (rules.webEgress) { + firewalls = [ + ...firewalls, + { + type: "egress", + from_port: 80, + to_port: 80, + protocol: "tcp", + cidr_blocks: rules.webCidr ?? ["0.0.0.0/0"] + }, + { + type: "egress", + from_port: 443, + to_port: 443, + protocol: "tcp", + cidr_blocks: rules.webCidr ?? ["0.0.0.0/0"] + } + ]; + } + + return [ + ...instances, + ...buckets, + ...vaults, + ...dbs, + ...instanceProfiles, + ...iamRoles, + ...attachments, + // new AwsVpcEndpoint(`${vpc}_endpoint`, vpc, `com.amazonaws.us-west-2.s3`, [], { + // isDefault: false, + // id: `${vpc}_routetable_pub` + // }), + new AwsVpc( + vpc_cidr, + private_cidr, + public_cidr, + vpc, + false, + undefined, + true + ), + new AwsSecurityGroup(securityGroup, vpc, firewalls) + ]; +}; diff --git a/backend/src/terraform/resource.ts b/backend/src/terraform/resource.ts index 346ba3d..9c274ca 100644 --- a/backend/src/terraform/resource.ts +++ b/backend/src/terraform/resource.ts @@ -60,7 +60,7 @@ export abstract class ResourceWithIam extends Resource { json = super.postProcess(json); if (this.autoIam) { - json.data = [...json.data, this.toPolicyDocument()]; + json.data = [...json.data, this.toPolicyDocument()].flat(); } return json; } diff --git a/backend/src/terraform/s3.ts b/backend/src/terraform/s3.ts index fa4d485..085b2fe 100644 --- a/backend/src/terraform/s3.ts +++ b/backend/src/terraform/s3.ts @@ -1,23 +1,49 @@ -import {acl} from "../types/terraform"; import {jsonRoot} from "./util"; import {ResourceWithIam} from "./resource"; -export interface S3 {} +export interface S3 { + isPrivate: boolean; +} export class S3 extends ResourceWithIam implements S3 { - constructor(id: string, autoIam?: boolean, name?: string) { + constructor( + id: string, + isPrivate = false, + autoIam?: boolean, + name?: string + ) { super(id, "S3", autoIam, name); + this.isPrivate = isPrivate; } //Returns a resource block toJSON() { - return jsonRoot("aws_s3_bucket", this.id, { - bucket: this.name - }); + let json = [ + jsonRoot("aws_s3_bucket", this.id, { + bucket: this.name + }) + ]; + + if (this.isPrivate) { + json = [ + ...json, + jsonRoot( + "aws_s3_bucket_public_access_block", + `${this.id}_access`, + { + bucket: `\${aws_s3_bucket.${this.id}.id}`, + block_public_acls: true, + block_public_policy: true + } + ) + ]; + } + return json; } //An array of policy statements for IAM //These need to be researched from //https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_examples.html + //https://asecure.cloud/l/iam/ getPolicyDocument() { return [ ResourceWithIam.policyStatement( diff --git a/backend/src/terraform/terraform.ts b/backend/src/terraform/terraform.ts index 706509c..87d52e1 100644 --- a/backend/src/terraform/terraform.ts +++ b/backend/src/terraform/terraform.ts @@ -13,7 +13,6 @@ import {GoogleProvider} from "./googleProvider"; import {namedDestructure} from "./util"; import {arr} from "../util"; import {IamUserForId} from "./awsIamUser"; -import {ResourceWithIam} from "./resource"; export const terraformBlock = ( providers: NamedRequiredProvider[] | NamedRequiredProvider, diff --git a/backend/src/types/terraform.ts b/backend/src/types/terraform.ts index 3e27c64..bd615fc 100644 --- a/backend/src/types/terraform.ts +++ b/backend/src/types/terraform.ts @@ -10,8 +10,20 @@ import {DatabaseModel, generateSchemaInternals} from "./database"; import {arr} from "../util"; import {IamUser} from "../terraform/awsIamUser"; import {GlacierVault} from "../terraform/glacierVault"; -import {Resource} from "../terraform/resource"; import {lambdaFunction} from "../terraform/lambdaFunction"; +import {AwsVpc} from "../terraform/awsVpc"; +import {AwsInternetGateway} from "../terraform/AwsInternetGateway"; +import {AwsRouteTable} from "../terraform/AwsRouteTable"; +import {AwsSecurityGroup} from "../terraform/AwsSecurityGroup"; +import {Eip} from "../terraform/Eip"; +import {SnsTopic} from "../terraform/awsSnsTopic"; +import {AwsSubnet} from "../terraform/awsSubnet"; +import {AwsIamInstanceProfile} from "../terraform/AwsIamInstanceProfile"; +import {AwsIamRolePolicyAttachment} from "../terraform/awsIamRolePolicyAttachment"; +import {IamRole} from "../terraform/iamRole"; +import {AwsRoute as AwsRouteResource} from "../terraform/AwsRoute"; +import {AwsVpcEndpoint} from "../terraform/AwsVpcEndpoint"; +import {DynamoDb} from "../terraform/DynamoDb"; // ---------------------------------Variable---------------------------------- // export type VariableType = @@ -163,7 +175,18 @@ export type source_image = export type acl = "private" | "public-read" | "public-read-write"; -// ----------------------------------LambdaFunction-------------------------------------- // +// -------------------------------DynamoDb----------------------------------- // + +export type billing_mode = "PROVISIONED" | "PAY_PER_REQUEST"; +export interface db_attribute { + name: string; + type: "S" | "N" | "B"; + + //TODO: Add sort key + isHash?: boolean; +} + +// ----------------------------LambdaFunction-------------------------------- // export type runtime = | "nodejs" @@ -232,7 +255,20 @@ export type TerraformResource = | S3 | IamUser | GlacierVault - | lambdaFunction; + | lambdaFunction + | AwsVpc + | AwsInternetGateway + | AwsRouteTable + | AwsSecurityGroup + | SnsTopic + | AwsSubnet + | Eip + | AwsIamInstanceProfile + | AwsIamRolePolicyAttachment + | IamRole + | AwsVpcEndpoint + | AwsRouteResource + | DynamoDb; export interface PolicyStatement { actions: string[]; @@ -247,6 +283,19 @@ export interface TerraformJson { resource: Record[]; } +export interface AwsRoute { + gateway_id: string; + cidr_block: string; +} + +export interface Firewall { + type: "egress" | "ingress"; + from_port: number | "icmp"; + to_port: number | "icmp"; + protocol: string; + cidr_blocks?: string[]; +} + // ----------------------------Terraform Root-------------------------------- // export interface Terraform { diff --git a/backend/src/util.ts b/backend/src/util.ts index d07b25f..1f5beb6 100644 --- a/backend/src/util.ts +++ b/backend/src/util.ts @@ -8,6 +8,10 @@ import { TerraformResource } from "./types/terraform"; +/* eslint-disable @typescript-eslint/ban-ts-comment */ +// @ts-ignore +import HCL from "js-hcl-parser"; + export const arr = (data: T | T[]) => (Array.isArray(data) ? data : [data]); export const testToFile = ( @@ -22,7 +26,12 @@ export const testToFile = ( resources ); - fs.writeFileSync(filename, JSON.stringify(root, null, 2), { + /* + fs.writeFileSync(`${filename}.json`, JSON.stringify(root, null, 2), { + flag: "w" + }); + */ + fs.writeFileSync(filename, jsonToHcl(root), { flag: "w" }); }; @@ -30,3 +39,83 @@ export const testToFileAws = ( filename: string, resources: TerraformResource[] = [] ) => testToFile(filename, new AwsProvider(), new NamedAwsBackend(), resources); + +export const jsonToHcl = (json: string | Record) => { + if (typeof json !== "string") { + json = JSON.stringify(json, null, 2); + } + let hcl: string = HCL.stringify(json); + + //Unpack the opening resource and data block + hcl = hcl.replace( + /"(resource|data)" = {\n {2}"([^"]+)" = {\n {4}"([^"]+)" = {/g, + (_match, $1, $2, $3) => `${$1} "${$2}" "${$3}" {` + ); + + //Remove the hanging closing tags + hcl = hcl.replace(/ {4}}\n {2}}/g, ""); + + //Unpack the opening provider + hcl = hcl.replace( + /"provider" = {\n {2}"([^"]+)" = {/g, + (_match, $1) => `provider "${$1}" {` + ); + + //Remove the hanging closing tags + hcl = hcl.replace(/ {2}}\n}/g, "}"); + + //Formatting + hcl = hcl.replace(/\n\n/g, "\n"); + hcl = hcl.replace(/^}$/gm, "}\n"); + + //Unpack references + hcl = hcl.replace(/"\${([^}]+)}"/g, (_match, $1) => $1); + + //Remove variable quotes + hcl = hcl.replace(/"([^"]+)" = /g, (_match, $1) => `${$1} = `); + + //Fix up required providers block + hcl = hcl.replace( + /terraform = {\n {2}"([^"]+)" "([^"]+)"([^}]+)}/g, + (_match, $1, $2, $3) => + `terraform {\n ${$1} {\n ${$2} = ${$3}}\n}\n}` + ); + + //Remove incorrect block as attribute styles + hcl = hcl.replace( + /(lifecycle|ingress|egress|statement|filter|route|notification|ttl|attribute) = {/g, + (_match, $1) => `${$1} {` + ); + + //Remove incorrect ignore quotes + hcl = hcl.replace(/(ignore_changes = \[[^\]]+\])/g, (_match, $1) => + $1.replace(/"/g, "") + ); + + //Cleanup + hcl = hcl.replace(/}\n}\n}/g, " }\n }\n}"); + + /* + //Merge duplicate blocks into arrays + let matches: Record = {} + hcl = hcl.replace(/([a-zA-Z0-9_-]+) = ({[^}]+})/g, (_match, $1, $2) => { + + matches[$1] = [...(matches[$1] ?? []), $2]; + return `MARKER_${$1}`;//`${$1} = [${$2}]` + }); + + Object.keys(matches).forEach(key => { + let json; + if(matches[key].length === 1){ + json = `${key} = ${matches[key]}`; + } + else{ + json = `${key} = [${matches[key].reduce((acc, obj) => `${acc}, ${obj}`)}]`; + } + hcl = hcl.replace(new RegExp(`MARKER_${key}`), json); + hcl = hcl.replace(new RegExp(`MARKER_${key}`, "g"), ""); + }); + */ + + return hcl; +}; diff --git a/backend/src/validators/resourceValidator.ts b/backend/src/validators/resourceValidator.ts index cee58f6..a3b2895 100644 --- a/backend/src/validators/resourceValidator.ts +++ b/backend/src/validators/resourceValidator.ts @@ -1,7 +1,7 @@ import {CustomValidator} from "express-validator"; -import {isRuntime} from "../types/terraform"; +import {db_attribute, isRuntime} from "../types/terraform"; -export const resourceTypes = /^(ec2|gce|s3|lambdaFunc)$/; +export const resourceTypes = /^(ec2|gce|s3|lambdaFunc|glacierVault|dynamoDb)$/; const validId = (id: string): boolean => { return /^[a-z]([-a-z0-9]*[a-z0-9])?$/.test(id); @@ -108,6 +108,32 @@ const resourceValidator: CustomValidator = (resource: any) => { if (!/^[a-z][-a-z0-9]*[a-z0-9]$/.test(resource.id)) { return false; } + } else if (resource.type === "dynamoDb") { + if (!hasAllKeys(resource, ["id", "attributes"])) { + return false; + } + if (!/^[a-z][-a-z0-9]*[a-z0-9]$/.test(resource.id)) { + return false; + } + if (!Array.isArray(resource.attributes)) { + return false; + } + for (let i = 0; i < resource.attributes.length; i++) { + if (!hasAllKeys(resource.attributes[i], ["name", "type"])) { + return false; + } + if (typeof resource.attributes[i].name !== "string") { + return false; + } + if (!/^(S|N|B)$/.test(resource.attributes[i].type)) { + return false; + } + } + if ( + resource.attributes.filter((a: db_attribute) => a.isHash).length < 1 + ) { + return false; + } } return true; diff --git a/backend/src/validators/terraformValidator.ts b/backend/src/validators/terraformValidator.ts index 016dce0..59a05a0 100644 --- a/backend/src/validators/terraformValidator.ts +++ b/backend/src/validators/terraformValidator.ts @@ -31,6 +31,30 @@ export const settingsValidator = [ .isLength({min: 1}) .matches(/^(aws|google|azure)$/) .withMessage("Provider must be aws, google, or azure at this time"), + body("settings.secure") + .if(body("tool").equals("terraform")) + .optional() + .isBoolean() + .default(false) + .withMessage("secure flag must be boolean"), + body("settings.allowSsh") + .if(body("tool").equals("terraform")) + .optional() + .isBoolean() + .default(false) + .withMessage("allowSsh flag must be boolean"), + body("settings.allowIngressWeb") + .if(body("tool").equals("terraform")) + .optional() + .isBoolean() + .default(false) + .withMessage("allowIngressWeb flag must be boolean"), + body("settings.allowEgressWeb") + .if(body("tool").equals("terraform")) + .optional() + .isBoolean() + .default(false) + .withMessage("allowEgressWeb flag must be boolean"), body("settings.project") .if(body("tool").equals("terraform")) .if(body("settings.provider").equals("google"))