diff --git a/python/privatelink-rds/PrivatelinkRdsDemoNlbUpdater.zip b/python/privatelink-rds/PrivatelinkRdsDemoNlbUpdater.zip new file mode 100644 index 0000000000..a3931b9365 Binary files /dev/null and b/python/privatelink-rds/PrivatelinkRdsDemoNlbUpdater.zip differ diff --git a/python/privatelink-rds/README.md b/python/privatelink-rds/README.md new file mode 100644 index 0000000000..902c24434c --- /dev/null +++ b/python/privatelink-rds/README.md @@ -0,0 +1,117 @@ + +--- + +![Stability: Stable](https://img.shields.io/badge/stability-Stable-success.svg?style=for-the-badge) + +> **This is a stable example. It should successfully build out of the box** +> +> This example is built on Construct Libraries marked "Stable" and does not have any infrastructure prerequisites to build. +--- + + + +# Cross-account RDS access using AWS Privatelink demo + +This demo shows how to leverage AWS Privatelink to publish a Relational Database Service (RDS) database from one account to other accounts. This method allows for point-to-point connectivity between accounts without relying on routing subnets. It leverages a Lambda function to keep the Privatelink's associated Network Load Balancer (NLB) updated with the RDS endpoint which is triggered whenever the RDS cluster enters a failover state. + +Reasons this demo was created: +1. A use case was presented where routing between VPCs was not allowed and the database team and application team were in separate accounts. This enabled the resources to remain where they were but still be connected. +2. Another use case where the application and database were in separate VPCs that had overlapping CIDR blocks. This meant VPC peering could not be used (not without some fancy NATing). This architecture does not rely on routing so enabled communication between the application and database. + +Check the AWS documentation for NLB features. As of publishing this demo, an NLB only supports an EC2 instance or IP address as targets, hence the need for the associated Lambda function. Should NLB ever support DNS entries as targets, the Lambda won't be needed. + +Running this CDK code generates the left side of the below architecture diagram. + +![Architecture diagram](architecture.png) + +This demo creates the following: +1. A Virtual Private Cloud (VPC) with two isolated (no Internet) subnets +2. An RDS MySQL multi-availability zone cluster +3. A Simple Notification Service (SNS) topic for receiving RDS failover events +4. An RDS event subscription filtered by failover notices and published to the SNS topic +5. An NLB and associated PrivateLink (aka VPC Endpoint Service) endpoint +6. A Lambda function, triggered by SNS, that resolves the RDS endpoint DNS name to the IP address of the active instance and updates the NLB's target group accordingly + +**Terms to be aware of** +- VPC Endpoint Service: This is the AWS Console and CDK name for a PrivateLink connection. In the console, you'll see this under VPC / Endpoint Services. An Endpoint Service is what you create to share out. Endpoint Services need an associated NLB. +- VPC Endpoint: This is where you create an endpoint to use, as opposed to above for sharing. An Endpoint can be to an AWS service (such as to access S3 or other services without Internet access in your VPC) or a custom share you've been granted permission to use, such as in this demo. +- PUBLIC (subnet): In the CDK code, this refers to a subnet that is created with an Internet Gateway and NAT Gateways to allow for Internet access (not used in this demo). +- PRIVATE (subnet): In the CDK code, this refers to a subnet that is created with a route to NAT Gateways in the PUBLIC subnet for egress traffic (not used in this demo). +- ISOLATED (subnet): In the CDK code, this refers to a subnet that is created without a route for egress traffic. + + +
+

Beware: CDK, Lambda, and CFT steps/code are for example/learning purposes and not production-quality code.

+

Please be aware of the caveats and limitations in the Appendix.

+
+ +## Pre-requisites + +To use this demo, you need: +1. The AWS Cloud Development Kit (CDK)[[1]](#fn1) installed. See Getting Started With AWS CDK[[2]](#fn2) for installation and usage. +2. An AWS account to deploy this demo in. +3. Another AWS account to consume the database from. +4. This other account will need a VPC in the same region as where you deploy the CDK stack, and subnets created. Ideally, subnets should exist in all Availabity Zones, but at a minimum will need them in the same AZ-IDs as the RDS account's. You can see subnet AZ-IDs in the AWS Console under / VPC / Subnets in the column "Availability Zone IDs" and will, for us-east, look like "use-azN". + +## Usage + +### RDS Account +1. Clone this repo locally. +2. Open app.py and adjust the "props" section with appropriate values. At least "principals_to_share_with" needs to be adjusted. +3. Deploy the CDK stack: `cdk deploy --all`. +4. Watch the output for "function ARN:" and for "Endpoint service ID: " and make note of the values. +5. Once deployed, kick off the Lambda function for initially populating the target group, either by logging into the AWS Console and executing the function or running `run_lambda.py [arn from output]`. + +### Consumer Account +1. From the AWS account listed in "principals_to_share_with", log in and go to VPC / Endpoints and create a new Endpoint. +2. Choose custom and search by the "Endpoint service ID" from the output. +3. Scroll down and ensure appropriate subnets are selected. Note: you will need a VPC already created with subnets in the same Availability Zones as the RDS account. The security group you assign should allow for the DB port from the instances/applications you'll be querying RDS. + +## Testing + +**Connectivity**: In the AWS account listed under "principals_to_share_with", you can create an EC2 instance that can route to the Endpoint you created and use the MySql cli to connect. + +**Failover**: In the AWS console, reboot the RDS instance and choose "reboot with failover". Watch the target group's members in another tab and in a few minutes it should update to a new IP address. + + + + +## Appendix + +### Caveats and Limitations + +- This code is as-is and is not endorsed or supported by AWS. Use at your own risk. +- The AWS NLB has a start delay on performing a health check on targets. This doesn't appear to be configurable and can add a minute or so until traffic is directed to the new IP. This means, using RDS natively, a failover may take ~30 seconds to be active but in this architecture, may take a few minutes. +- Your RDS, PrivateLink, and the consuming accounts must be in the same region and same underlying AWS Availability Zones. Ensure you have subnets in the consuming accounts with the same AZ-IDs (check the AWS Console / Subnets and column Availability Zone ID). If, when creating the Endpoint in the consuming account, your expected subnets are not selectable, it is due to not being in the same AZ-IDs. + +### Enhancements + +Below are ideas for taking this further, especially if "production-izing": + +1. Instead of using "allowed_principals" in the privatelink_stack.py and specifying an array principals, leverage AWS Resource Access Manager (RAM) and share with an AWS Organization or OU. +2. Move the props dictionary to AWS Systems Manager (SSM) Parameter Store or at least source from a separately managed configuration file so adding/removing shared accounts doesn't requiring editing the core stack. +3. Create a one-time cron to kick off the initial Lambda run. +4. Create a multi-account CDK and a stack to create the endpoint in the consumer account. +5. Convert your working solution to AWS Service Catalog so others can deploy the stack in a self-service model. + + +### Restarting CDK session +Always be sure to enable the virtualenv before working: run `source .venv/bin/activate` from the project root + +### AWS SSO +If your account uses AWS SSO for access be aware that, as of this demo, AWS SSO isn't supported by the CDK. There's a utility, yawsso[[3]](#fn3), that can be used to sync the cached SSO credentials for CDK to use: + +1. Install yawsso: `pip3 install yawsso` +2. Log into AWS SSO: `aws sso login` +3. Sync the cached credentials: `yawsso` +4. To avoid appending `--profile` to every CDK call, set the default profile via shell: `export AWS_DEFAULT_PROFILE={what you named the profile when running aws sso login}` + +Alternatively, there's cdk-sso-sync[[4]](#fn4) but I haven't used it. + + +## Footnotes +1. AWS Cloud Development Kit: https://docs.aws.amazon.com/cdk/latest/guide/work-with-cdk-python.html +2. Getting Started With AWS CDK: https://docs.aws.amazon.com/cdk/latest/guide/getting_started.html +3. Yawsso for bridging AWS SSO with CDK (and anything that doesn't support AWS SSO) for auth: https://github.com/victorskl/yawsso +4. cdk-sso-sync: https://www.npmjs.com/package/cdk-sso-sync +5. Working with the AWS CDK in Python: https://docs.aws.amazon.com/cdk/latest/guide/work-with-cdk-python.html diff --git a/python/privatelink-rds/app.py b/python/privatelink-rds/app.py new file mode 100644 index 0000000000..d09356dbc5 --- /dev/null +++ b/python/privatelink-rds/app.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 + +from aws_cdk import ( + core +) + +from stacks.vpc_stack import VpcStack +from stacks.sns_stack import SnsStack +from stacks.rds_stack import RdsStack +from stacks.nlb_stack import NlbStack +from stacks.privatelink_stack import PrivatelinkStack +from stacks.lambda_stack import LambdaStack + +app = core.App() + +props = { + 'vpc_cidr': '192.168.10.0/24', + 'db_port': 3306, + 'principals_to_share_with': [ + 'arn:aws:iam::XXXX:root' + ] # list accounts, OUs, or Org principals here +} + +vpc_stack = VpcStack(app, "PrivatelinkRdsDemoVpc", + vpc_cidr = props['vpc_cidr'] +) + +sns_stack = SnsStack(app, "PrivatelinkRdsDemoSns") + +rds_stack = RdsStack(app, "PrivatelinkRdsDemoDb", + vpc = vpc_stack.vpc, + vpc_cidr = props['vpc_cidr'], + db_port = props['db_port'], + subnet_group = 'DB', + sns_topic_arn = sns_stack.topic.topic_arn +) + +nlb_stack = NlbStack(app, "PrivatelinkRdsDemoNlb", + vpc = vpc_stack.vpc, + subnet_group = 'PrivateIngress', + db_port = props['db_port'] +) + +privatelink_stack = PrivatelinkStack(app, "PrivatelinkRdsDemoVpcServiceEndpoint", + nlb = nlb_stack.nlb, + principals_to_share_with = props['principals_to_share_with'] +) + +lambda_stack = LambdaStack(app, "PrivatelinkRdsDemoLambda", + rds_endpoint = rds_stack.db.db_instance_endpoint_address, + sns_topic = sns_stack.topic, + target_group_arn = nlb_stack.target_group.target_group_arn +) + +print("Next steps:") +print("1. Populate the target group by executing the Lambda function, either via:") +print(" - Use the AWS console or") +print(" - by getting the function ARN from the above output and running: run_lambda.py ", lambda_stack.function.function_arn) +print("2. In an account listed as allowed in this stack's props section: go to the AWS Console / VPC / Endpoints. Create an endpoint and search for this private service: ", privatelink_stack.endpoint.vpc_endpoint_service_id) + +app.synth() diff --git a/python/privatelink-rds/architecture.png b/python/privatelink-rds/architecture.png new file mode 100644 index 0000000000..573574b23e Binary files /dev/null and b/python/privatelink-rds/architecture.png differ diff --git a/python/privatelink-rds/cdk.json b/python/privatelink-rds/cdk.json new file mode 100644 index 0000000000..552b09fc19 --- /dev/null +++ b/python/privatelink-rds/cdk.json @@ -0,0 +1,12 @@ +{ + "app": "python3 app.py", + "context": { + "@aws-cdk/core:enableStackNameDuplicates": "true", + "aws-cdk:enableDiffNoFail": "true", + "@aws-cdk/core:stackRelativeExports": "true", + "@aws-cdk/aws-ecr-assets:dockerIgnoreSupport": true, + "@aws-cdk/aws-secretsmanager:parseOwnedSecretName": true, + "@aws-cdk/aws-kms:defaultKeyPolicies": true, + "@aws-cdk/aws-s3:grantWriteWithoutAcl": true + } +} diff --git a/python/privatelink-rds/lambda_function/DO_NOT_AUTOTEST b/python/privatelink-rds/lambda_function/DO_NOT_AUTOTEST new file mode 100644 index 0000000000..e69de29bb2 diff --git a/python/privatelink-rds/lambda_function/README.md b/python/privatelink-rds/lambda_function/README.md new file mode 100644 index 0000000000..5a6cfbadcc --- /dev/null +++ b/python/privatelink-rds/lambda_function/README.md @@ -0,0 +1,5 @@ +# ELB Hostname as Target + +This is a trimmed down version of the function published at https://github.com/aws-samples/hostname-as-target-for-elastic-load-balancer/. It removes the S3 and multi-IP portions as this use-case will only ever be one IP (the RDS endpoint) and uses the Lambda's local DNS resolver (which can be overriden as with the original function). + +This function requires the PIP module "dns". If you are modifying the code and need to re-package it, ensure requirements.txt modules are installed locally with the function and packaged up as part of the zipfile for deployment. See for more details: https://docs.aws.amazon.com/lambda/latest/dg/python-package-create.html \ No newline at end of file diff --git a/python/privatelink-rds/lambda_function/elb_hostname_as_target.py b/python/privatelink-rds/lambda_function/elb_hostname_as_target.py new file mode 100755 index 0000000000..f7048288ff --- /dev/null +++ b/python/privatelink-rds/lambda_function/elb_hostname_as_target.py @@ -0,0 +1,83 @@ +import json +import logging +import os +import sys + +import lambda_utils as utils + +""" +Configure these environment variables in your Lambda environment or +CloudFormation Inputs settings): + +1. TARGET_FQDN (mandatory): The Fully Qualified DNS Name used for application +cluster +2. ELB_TG_ARN (mandatory): The ARN of the Elastic Load Balancer's target group +3. DNS_SERVER (optional): The DNS Servers to query TARGET_FQDN if you do not want to use AWS default (i.e., if you want to run this function attached to a VPC and use its resolver) +""" + +if 'TARGET_FQDN' in os.environ: + TARGET_FQDN = os.environ['TARGET_FQDN'] +else: + print("ERROR: Missing Target Hostname.") + sys.exit(1) + +if 'ELB_TG_ARN' in os.environ: + ELB_TG_ARN = os.environ['ELB_TG_ARN'] +else: + print("ERROR: Missing Destination Target Group ARN.") + sys.exit(1) + +if 'DNS_SERVER' in os.environ: + DNS_SERVER = os.environ['DNS_SERVER'] +else: + print("Info: DNS resolver not specified, using default.") + DNS_SERVER = None + + +# MAIN Function - This function will be invoked when Lambda is called +def lambda_handler(event, context): + logger = logging.getLogger() + logger.setLevel(logging.INFO) + logger.info("INFO: Received event: {}".format(json.dumps(event))) + + # Get Currently Resgistered IPs list + logger.info("INFO: Checking existing target group members") + registered_ip_list = utils.describe_target_health(ELB_TG_ARN) + + # Query DNS for hostname IPs + logger.info("INFO: Performing DNS lookup") + try: + hostname_ip_list = [] + dns_lookup_result = utils.dns_lookup(DNS_SERVER, TARGET_FQDN, "A") + hostname_ip_list = dns_lookup_result + hostname_ip_list + logger.info(f"INFO: Hostname IPs resolved by DNS lookup: {format(hostname_ip_list)}") + + # IP list to register with target group, minus existing IPs + new_ips_to_register_list = list(set(hostname_ip_list) - set(registered_ip_list)) + + # Register new targets + if new_ips_to_register_list: + logger.info(f"INFO: Registering {format(new_ips_to_register_list)}") + utils.register_target(ELB_TG_ARN, new_ips_to_register_list) + else: + logger.info("INFO: No IPs to register.") + + + # IP list to remove from the target group, minus the currently resolved ones + old_ips_to_remove_list = list(set(registered_ip_list) - set(hostname_ip_list)) + + # Remove old IPs from the target group + if old_ips_to_remove_list: + logger.info(f"INFO: Removing old IPs: {format(old_ips_to_remove_list)}") + utils.deregister_target(ELB_TG_ARN, old_ips_to_remove_list) + else: + logger.info("INFO: Target group members up to date, nothing to remove") + + logger.info("INFO: Update completed successfuly.") + + # Exception handler + except Exception as e: + logger.error("ERROR:", e) + logger.error("ERROR: Invocation failed.") + return(1) + return (0) diff --git a/python/privatelink-rds/lambda_function/lambda_utils.py b/python/privatelink-rds/lambda_function/lambda_utils.py new file mode 100755 index 0000000000..e649dacba7 --- /dev/null +++ b/python/privatelink-rds/lambda_function/lambda_utils.py @@ -0,0 +1,121 @@ +import json +import logging +import random +import re +import sys + +import boto3 +from botocore.exceptions import ClientError + +import dns.resolver + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +try: + to_unicode = unicode +except NameError: + to_unicode = str + +try: + elbv2_client = boto3.client('elbv2') +except ClientError as e: + logger.error("ERROR: failed to connect to elbv2 client.") + logger.error(e.response['Error']['Message']) + sys.exit(1) + +def render_list(ip_list): + """ + Format list of IPs to what target group API call expects + """ + target_list = [] + for ip in ip_list: + target = { + 'Id': ip + } + target_list.append(target) + return target_list + +def register_target(tg_arn, new_target_list): + """ + Register resolved IPs to the NLB target group + """ + logger.info("INFO: Register new_target_list:{}".format(new_target_list)) + id_list = render_list(new_target_list) + try: + elbv2_client.register_targets( + TargetGroupArn=tg_arn, + Targets=id_list + ) + except ClientError: + logger.error("ERROR: IP Targets registration failed.") + raise + + +def deregister_target(tg_arn, dereg_target_list): + """ + Deregister missing IPs from the target group + """ + + id_list = render_list(dereg_target_list) + try: + logger.info("INFO: Deregistering {}".format(dereg_target_list)) + elbv2_client.deregister_targets( + TargetGroupArn=tg_arn, + Targets=id_list + ) + except ClientError: + logger.error("ERROR: IP Targets deregistration failed.") + raise + + +def describe_target_health(tg_arn): + """ + Get a IP address list of registered targets in the NLB's target group + """ + registered_ip_list = [] + try: + response = elbv2_client.describe_target_health(TargetGroupArn=tg_arn) + for target in response['TargetHealthDescriptions']: + registered_ip = target['Target']['Id'] + registered_ip_list.append(registered_ip) + except ClientError: + logger.error("ERROR: Can't retrieve Target Group information.") + raise + return registered_ip_list + + +def dns_lookup(dns_server, domainname, record_type): + """ + Get dns lookup results + :param domain: + :return: list of dns lookup results + """ + lookup_result_list = [] + + # Select DNS server to use + myResolver = dns.resolver.Resolver() + myResolver.domain = '' + + # Apply default DNS Server override + if dns_server: + name_server_ip_list = re.split(r'[,; ]+', dns_server) + myResolver.nameservers = [random.choice(name_server_ip_list)] + else: + logger.info("INFO: Using default DNS resolver") + # logger.info("INFO: Using default DNS " + # "resolvers: {}".format(dns.resolver.Resolver().nameservers)) + # myResolver.nameservers = random.choice(dns.resolver.Resolver().nameservers) + + logger.info("INFO: Selected DNS Server: {}".format(myResolver.nameservers)) + # Resolve FQDN + try: + logger.info(f"Trying lookup for {domainname}") + lookupAnswer = myResolver.query(domainname, record_type) + logger.info(f"Resolved list of {lookupAnswer}") + for answer in lookupAnswer: + lookup_result_list.append(str(answer)) + logger.info(f"Resolved {domainname} to {answer}") + except ClientError: + raise + return lookup_result_list diff --git a/python/privatelink-rds/lambda_function/requirements.txt b/python/privatelink-rds/lambda_function/requirements.txt new file mode 100644 index 0000000000..a5d652679d --- /dev/null +++ b/python/privatelink-rds/lambda_function/requirements.txt @@ -0,0 +1 @@ +dnspython==1.15.0 diff --git a/python/privatelink-rds/requirements.txt b/python/privatelink-rds/requirements.txt new file mode 100644 index 0000000000..916d2452e2 --- /dev/null +++ b/python/privatelink-rds/requirements.txt @@ -0,0 +1,8 @@ +aws-cdk.core +aws-cdk.aws-lambda +aws-cdk.aws-lambda-event-sources +aws-cdk.aws-rds +aws-cdk.aws-elasticloadbalancingv2 +aws-cdk.aws-elasticloadbalancingv2-actions +aws-cdk.aws-elasticloadbalancingv2-targets +boto3 \ No newline at end of file diff --git a/python/privatelink-rds/run_lambda.py b/python/privatelink-rds/run_lambda.py new file mode 100755 index 0000000000..b0821b7c2a --- /dev/null +++ b/python/privatelink-rds/run_lambda.py @@ -0,0 +1,8 @@ +import boto3 +import sys + +client = boto3.client('lambda') + +response = client.invoke( + FunctionName = sys.argv[1] +) \ No newline at end of file diff --git a/python/privatelink-rds/setup.py b/python/privatelink-rds/setup.py new file mode 100644 index 0000000000..85792d6256 --- /dev/null +++ b/python/privatelink-rds/setup.py @@ -0,0 +1,45 @@ +import setuptools + + +with open("README.md") as fp: + long_description = fp.read() + + +setuptools.setup( + name="rds_privatelink", + version="0.0.1", + + description="An empty CDK Python app", + long_description=long_description, + long_description_content_type="text/markdown", + + author="author", + + package_dir={"": "rds_privatelink"}, + packages=setuptools.find_packages(where="rds_privatelink"), + + install_requires=[ + "aws-cdk.core==1.88.0", + ], + + python_requires=">=3.6", + + classifiers=[ + "Development Status :: 4 - Beta", + + "Intended Audience :: Developers", + + "License :: OSI Approved :: Apache Software License", + + "Programming Language :: JavaScript", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + + "Topic :: Software Development :: Code Generators", + "Topic :: Utilities", + + "Typing :: Typed", + ], +) diff --git a/python/privatelink-rds/stacks/lambda_stack.py b/python/privatelink-rds/stacks/lambda_stack.py new file mode 100644 index 0000000000..c80ae7d857 --- /dev/null +++ b/python/privatelink-rds/stacks/lambda_stack.py @@ -0,0 +1,55 @@ +from aws_cdk import ( + aws_lambda as _lambda, + aws_lambda_event_sources as events, + aws_iam as iam, + core +) + +class LambdaStack(core.Stack): + + def __init__(self, scope: core.Construct, id: str, + rds_endpoint, + sns_topic, + target_group_arn, + **kwargs + ) -> None: + super().__init__(scope, id, **kwargs) + + # The rights we'll need to add to the Lambda function since we're gluing + # Privatelink and NLB ourselves and CDK won't know to generate these. + # Note: Describe doesn't accept resource constraint, see https://docs.aws.amazon.com/elasticloadbalancing/latest/userguide/load-balancer-authentication-access-control.html + policy_describe_targetgroup = iam.PolicyStatement( + actions = [ + "elasticloadbalancing:DescribeTargetHealth" + ], + resources = ["*"] + ) + policy_update_targetgroup = iam.PolicyStatement( + actions = [ + "elasticloadbalancing:RegisterTargets", + "elasticloadbalancing:DeregisterTargets" + ], + resources = [ + target_group_arn + ] + ) + + # create lambda function + self.function = _lambda.Function(self, "PrivatelinkRdsDemoLambda", + runtime = _lambda.Runtime.PYTHON_3_7, + handler = "elb_hostname_as_target.lambda_handler", + code = _lambda.AssetCode("./PrivatelinkRdsDemoNlbUpdater.zip"), + initial_policy = [policy_describe_targetgroup, policy_update_targetgroup], + timeout = core.Duration.seconds(45), + environment = { + 'ELB_TG_ARN': target_group_arn, + 'TARGET_FQDN': rds_endpoint + } + ) + + # add the event source/trigger from SNS + self.function.add_event_source(events.SnsEventSource(sns_topic)) + + core.CfnOutput(self, "Output", + value = "Function ARN: " + self.function.function_arn + ) diff --git a/python/privatelink-rds/stacks/nlb_stack.py b/python/privatelink-rds/stacks/nlb_stack.py new file mode 100644 index 0000000000..7370ab7c9f --- /dev/null +++ b/python/privatelink-rds/stacks/nlb_stack.py @@ -0,0 +1,48 @@ +from aws_cdk import ( + core, + aws_ec2 as ec2, + aws_elasticloadbalancingv2 as elb +) + +class NlbStack(core.Stack): + def __init__(self, scope: core.Construct, id: str, + vpc, + subnet_group, + db_port, + **kwargs + ) -> None: + super().__init__(scope, id, **kwargs) + + self.nlb = elb.NetworkLoadBalancer(self, "PrivbatelinkRdsDemoNlb", + vpc = vpc, + internet_facing = False, + load_balancer_name = "PrivatelinkRdsDemoNlb", + vpc_subnets = ec2.SubnetSelection(subnet_group_name = subnet_group) + ) + + health_check = elb.HealthCheck( + enabled = True, + healthy_threshold_count = 2, + unhealthy_threshold_count = 2, + interval = core.Duration.seconds(10), + port = str(db_port) + ) + + self.target_group = elb.NetworkTargetGroup(self, "PrivatelinkRdsDemoTargetGroup", + port = db_port, + health_check = health_check, + deregistration_delay = core.Duration.seconds(0), + vpc = vpc, + target_type = elb.TargetType('IP') + ) + + listener = self.nlb.add_listener( + "MySql", + port = db_port, + default_target_groups = [self.target_group] + ) + + core.CfnOutput(self, "Output", + value = "NLB ARN: " + self.nlb.load_balancer_arn + "\nTG ARN: " + self.target_group.target_group_arn + ) + diff --git a/python/privatelink-rds/stacks/privatelink_stack.py b/python/privatelink-rds/stacks/privatelink_stack.py new file mode 100644 index 0000000000..d05c714edb --- /dev/null +++ b/python/privatelink-rds/stacks/privatelink_stack.py @@ -0,0 +1,28 @@ +from aws_cdk import ( + core, + aws_ec2 as ec2, + aws_iam as iam +) + +class PrivatelinkStack(core.Stack): + + def __init__(self, scope: core.Construct, id: str, + nlb, + principals_to_share_with, + **kwargs + ) -> None: + super().__init__(scope, id, **kwargs) + + principals = [] + for principal in principals_to_share_with: + principals.append(iam.ArnPrincipal(principal)) + + self.endpoint = ec2.VpcEndpointService(self, "PrivatelinkRdsDemoVpcEndpoint", + vpc_endpoint_service_load_balancers = [nlb], + acceptance_required = False, + allowed_principals = principals + ) + + core.CfnOutput(self, "Output", + value = "Endpoint service ID: " + self.endpoint.vpc_endpoint_service_id + ) \ No newline at end of file diff --git a/python/privatelink-rds/stacks/rds_stack.py b/python/privatelink-rds/stacks/rds_stack.py new file mode 100644 index 0000000000..6a92eacc55 --- /dev/null +++ b/python/privatelink-rds/stacks/rds_stack.py @@ -0,0 +1,70 @@ +from aws_cdk import ( + core, + aws_ec2 as ec2, + aws_rds as rds +) + +class RdsStack(core.Stack): + + def __init__(self, scope: core.Construct, id: str, + vpc, + vpc_cidr, + db_port, + subnet_group, + sns_topic_arn, + **kwargs + ) -> None: + super().__init__(scope, id, **kwargs) + + # Create the security group for NLB to connect + self.db_sg = ec2.SecurityGroup(self, "PrivatelinkRdsDemoDbSg", + vpc = vpc, + security_group_name = "PrivatelinkRdsDemoDbSg" + ) + + # Allow VPC CIDR to connect on DB port + # Security note: This VPC is small and only to host this database, NLB, and VPC service endpoint. + # More secure method is to look up the subnets of the NLB and allow those CIDRs + # or just the IPs of the created NLB ENIs. + self.db_sg.add_ingress_rule( + peer = ec2.Peer.ipv4(vpc_cidr), + connection = ec2.Port.tcp(db_port) + ) + + # Allow this security group to connect back to itself on DB port + # NOT NEEDED, used for debugging to attach an EC2 instance to for testing traffic +# self.db_sg.add_ingress_rule( +# peer = self.db_sg, +# connection = ec2.Port.tcp(db_port) +# ) + + # Create the RDS instance + self.db = rds.DatabaseInstance(self, "PrivatelinkRdsDemoDb", + engine = rds.DatabaseInstanceEngine.mysql( + version = rds.MysqlEngineVersion.VER_5_7_30 + ), + instance_type = ec2.InstanceType.of( + ec2.InstanceClass.BURSTABLE2, ec2.InstanceSize.SMALL), + vpc = vpc, + security_groups = [self.db_sg], + vpc_subnets = ec2.SubnetSelection(subnet_group_name = subnet_group), + multi_az = True, + allocated_storage = 100, + storage_type = rds.StorageType.GP2, + cloudwatch_logs_exports = ["audit", "error", "general", "slowquery"], + deletion_protection = False, + delete_automated_backups = False, + backup_retention = core.Duration.days(7), + parameter_group = rds.ParameterGroup.from_parameter_group_name( + self, "para-group-mysql", + parameter_group_name = "default.mysql5.7" + ) + ) + + # Create the event notification for cluster events (to trigger Lambda) + event_topic = rds.CfnEventSubscription(self, "PrivatelinkRdsDemoEvent", + sns_topic_arn = sns_topic_arn, + event_categories = ['failover', 'failure', 'recovery', 'maintenance'], + source_type = 'db-instance', + source_ids = [self.db.instance_identifier] + ) \ No newline at end of file diff --git a/python/privatelink-rds/stacks/sns_stack.py b/python/privatelink-rds/stacks/sns_stack.py new file mode 100644 index 0000000000..5036051baf --- /dev/null +++ b/python/privatelink-rds/stacks/sns_stack.py @@ -0,0 +1,13 @@ +from aws_cdk import ( + aws_sns as sns, + core +) + +class SnsStack(core.Stack): + + def __init__(self, scope: core.Construct, id: str, **kwargs) -> None: + super().__init__(scope, id, **kwargs) + + self.topic = sns.Topic(self, "PrivatelinkRdsDemoSns", + display_name = "RdsEventsTopicForSendingNotificationsAndTriggeringLambda" + ) \ No newline at end of file diff --git a/python/privatelink-rds/stacks/vpc_stack.py b/python/privatelink-rds/stacks/vpc_stack.py new file mode 100644 index 0000000000..647d10de27 --- /dev/null +++ b/python/privatelink-rds/stacks/vpc_stack.py @@ -0,0 +1,31 @@ +from aws_cdk import ( + core, + aws_ec2 as ec2 +) + +class VpcStack(core.Stack): + + def __init__(self, scope: core.Construct, id: str, + vpc_cidr, + **kwargs + ) -> None: + super().__init__(scope, id, **kwargs) + + # SubnetType.ISOLATED used as we don't want Internet traffic possible for this demo + self.vpc = ec2.Vpc(self, "VPC", + max_azs = 2, + cidr = vpc_cidr, + subnet_configuration = [ + ec2.SubnetConfiguration( + subnet_type = ec2.SubnetType.ISOLATED, + name = "PrivateIngress", + cidr_mask = 28 + ), ec2.SubnetConfiguration( + subnet_type = ec2.SubnetType.ISOLATED, + name = "DB", + cidr_mask = 28 + ) + ], + ) + core.CfnOutput(self, "Output", + value = self.vpc.vpc_id) \ No newline at end of file