diff --git a/lib/restate-constructs/single-node-restate-deployment.ts b/lib/restate-constructs/single-node-restate-deployment.ts index 225ebb8..d43ecb3 100644 --- a/lib/restate-constructs/single-node-restate-deployment.ts +++ b/lib/restate-constructs/single-node-restate-deployment.ts @@ -23,12 +23,28 @@ export interface SingleNodeRestateProps { /** EC2 instance type to use. */ instanceType?: ec2.InstanceType; - /** Machine image. */ + /** Machine image. Note: startup script expects yum-based package management. */ machineImage?: ec2.IMachineImage; /** The VPC in which to launch the Restate host. */ vpc?: ec2.IVpc; + networkConfiguration?: { + /** + * Subnet type for the Restate host. + * + * Available options: + * - [Default] {@link ec2.SubnetType.PRIVATE_WITH_EGRESS} will create the Restate instance with outbound internet + * access only, so that it can invoke HTTP endpoints. The security groups {@link ingressSecurityGroup} and + * {@link adminSecurityGroup} control inbound traffic to the service ingress and admin ports respectively. + * Configure {@link ServiceDeployer} to use the latter, and set up ingress traffic routing outside of this + * construct using the former. + * - Insecure, internet-facing {@link ec2.SubnetType.PUBLIC} will also provision an nginx reverse proxy + * and an HTTP listener with a self-signed certificate. + */ + subnetType?: ec2.SubnetType.PRIVATE_WITH_EGRESS | ec2.SubnetType.PUBLIC; + }; + /** Log group for Restate service logs. */ logGroup?: logs.LogGroup; @@ -90,7 +106,7 @@ export interface SingleNodeRestateProps { } const PUBLIC_INGRESS_PORT = 443; -const PUBLIC_ADMIN_PORT = 9073; +const PUBLIC_ADMIN_PORT = 9070; const RESTATE_INGRESS_PORT = 8080; const RESTATE_ADMIN_PORT = 9070; const RESTATE_IMAGE_DEFAULT = "docker.io/restatedev/restate"; @@ -99,16 +115,18 @@ const ADOT_DOCKER_DEFAULT_TAG = "latest"; const DATA_DEVICE_NAME = "/dev/sdd"; /** - * Creates a Restate service deployment backed by a single EC2 instance, and is suitable for - * development and testing purposes. + * Creates a Restate service deployment backed by a single EC2 instance, suitable for development and testing purposes. + * * The EC2 instance will be created in the default VPC unless otherwise specified. - * The instance will be assigned a public IP address. + * See {@link SingleNodeRestateProps} for available configuration options. */ export class SingleNodeRestateDeployment extends Construct implements IRestateEnvironment { readonly instance: ec2.Instance; readonly instanceRole: iam.IRole; readonly invokerRole: iam.IRole; readonly vpc: ec2.IVpc; + readonly ingressSecurityGroup: ec2.ISecurityGroup; + readonly adminSecurityGroup: ec2.ISecurityGroup; readonly ingressUrl: string; readonly adminUrl: string; @@ -118,6 +136,8 @@ export class SingleNodeRestateDeployment extends Construct implements IRestateEn this.vpc = props.vpc ?? ec2.Vpc.fromLookup(this, "Vpc", { isDefault: true }); + const subnetType = props.networkConfiguration?.subnetType ?? ec2.SubnetType.PRIVATE_WITH_EGRESS; + this.instanceRole = new iam.Role(this, "InstanceRole", { assumedBy: new iam.ServicePrincipal("ec2.amazonaws.com"), managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName("AmazonSSMManagedInstanceCore")], @@ -155,40 +175,49 @@ export class SingleNodeRestateDeployment extends Construct implements IRestateEn const initScript = ec2.UserData.forLinux(); initScript.addCommands( "set -euf -o pipefail", - "yum install -y docker nginx", + `yum install -y npm && npm install -gq @restatedev/restate@${restateTag}`, + "yum install -y docker", this.mountDataVolumeScript(), - "mkdir /etc/restate", + + "mkdir -p /etc/restate", ["cat << EOF > /etc/restate/config.toml", this.restateConfig(id, props), "EOF"].join("\n"), "systemctl start docker.service", - [ - "docker run --name adot --restart on-failure --detach", - " -p 4317:4317 -p 55680:55680 -p 8889:8888", - ` public.ecr.aws/aws-observability/aws-otel-collector:${adotTag}`, - ].join(""), - [ - "docker run --name restate --restart on-failure --detach", - " --volume /etc/restate:/etc/restate", - " --volume /var/restate:/restate-data", - " --network=host", - " -e RESTATE_OBSERVABILITY__LOG__FORMAT=Json -e RUST_LOG=info,restate_worker::partition=warn", - " -e RESTATE_OBSERVABILITY__TRACING__ENDPOINT=http://localhost:4317", - ` --log-driver=awslogs --log-opt awslogs-group=${logGroup.logGroupName}`, - ` ${restateImage}:${restateTag}`, - " --config-file /etc/restate/config.toml", - ].join(""), - "mkdir -p /etc/pki/private", - [ - "openssl req -new -x509 -nodes -sha256 -days 365 -extensions v3_ca", - " -subj '/C=DE/ST=Berlin/L=Berlin/O=restate.dev/OU=demo/CN=restate.example.com'", - " -newkey rsa:2048 -keyout /etc/pki/private/restate-selfsigned.key -out /etc/pki/private/restate-selfsigned.crt", - ].join(""), + // Start the ADOT collector - needed for X-ray trace forwarding + `if [ "$(docker ps -qa -f name=adot)" ]; then docker stop adot || true; docker rm adot; fi`, + "docker run --name adot --restart on-failure --detach" + + " -p 4317:4317 -p 55680:55680 -p 8889:8888" + + ` public.ecr.aws/aws-observability/aws-otel-collector:${adotTag}`, - ["cat << EOF > /etc/nginx/conf.d/restate-ingress.conf", ingressNginxConfig, "EOF"].join("\n"), - "systemctl start nginx", + // Start the Restate server container + `if [ "$(docker ps -qa -f name=restate)" ]; then docker stop restate || true; docker rm restate; fi`, + "docker run --name restate --restart on-failure --detach" + + " --volume /etc/restate:/etc/restate" + + " --volume /var/restate:/restate-data" + + " --network=host" + + " -e RESTATE_OBSERVABILITY__LOG__FORMAT=Json -e RUST_LOG=info,restate_worker::partition=warn" + + " -e RESTATE_OBSERVABILITY__TRACING__ENDPOINT=http://localhost:4317" + + ` --log-driver=awslogs --log-opt awslogs-group=${logGroup.logGroupName}` + + ` ${restateImage}:${restateTag}` + + " --config-file /etc/restate/config.toml", ); + if (subnetType == ec2.SubnetType.PUBLIC) { + initScript.addCommands( + "yum install -y nginx", + "mkdir -p /etc/pki/private", + [ + "openssl req -new -x509 -nodes -sha256 -days 365 -extensions v3_ca", + " -subj '/C=DE/ST=Berlin/L=Berlin/O=restate.dev/OU=demo/CN=restate.example.com'", + " -newkey rsa:2048 -keyout /etc/pki/private/restate-selfsigned.key -out /etc/pki/private/restate-selfsigned.crt", + ].join(""), + + ["cat << EOF > /etc/nginx/conf.d/restate-ingress.conf", ingressNginxConfig, "EOF"].join("\n"), + "systemctl start nginx", + ); + } + const cloudConfig = ec2.UserData.custom([`cloud_final_modules:`, `- [scripts-user, always]`].join("\n")); const userData = new ec2.MultipartUserData(); @@ -197,7 +226,7 @@ export class SingleNodeRestateDeployment extends Construct implements IRestateEn const restateInstance = new ec2.Instance(this, "Host", { vpc: this.vpc, - vpcSubnets: { subnetType: ec2.SubnetType.PUBLIC }, + vpcSubnets: { subnetType }, instanceType: props.instanceType ?? new ec2.InstanceType("t4g.micro"), machineImage: props.machineImage ?? @@ -229,28 +258,52 @@ export class SingleNodeRestateDeployment extends Construct implements IRestateEn restateInstance.role.addManagedPolicy(iam.ManagedPolicy.fromAwsManagedPolicyName("AWSXrayWriteOnlyAccess")); } - const restateInstanceSecurityGroup = new ec2.SecurityGroup(this, "RestateSecurityGroup", { + const ingressSecurityGroup = new ec2.SecurityGroup(this, "IngressSecurityGroup", { vpc: this.vpc, - securityGroupName: "RestateSecurityGroup", - description: "Restate service ACLs", + description: "Restate Ingress ACLs", }); - restateInstance.addSecurityGroup(restateInstanceSecurityGroup); - - restateInstanceSecurityGroup.addIngressRule( - ec2.Peer.anyIpv4(), - ec2.Port.tcp(443), - "Allow traffic from anywhere to Restate ingress port", - ); - restateInstanceSecurityGroup.addIngressRule( - ec2.Peer.anyIpv4(), - ec2.Port.tcp(9073), - "Allow traffic from anywhere to Restate admin port", - ); + restateInstance.addSecurityGroup(ingressSecurityGroup); + const adminSecurityGroup = new ec2.SecurityGroup(this, "AdminSecurityGroup", { + vpc: this.vpc, + description: "Restate Admin ACLs", + }); + restateInstance.addSecurityGroup(adminSecurityGroup); + + if (subnetType == ec2.SubnetType.PUBLIC) { + // Insecure, public-facing deployment + ingressSecurityGroup.addIngressRule( + ec2.Peer.anyIpv4(), + ec2.Port.tcp(PUBLIC_INGRESS_PORT), + "Allow traffic from anywhere to Restate ingress port", + ); + ingressSecurityGroup.addIngressRule( + ec2.Peer.anyIpv4(), + ec2.Port.tcp(PUBLIC_ADMIN_PORT), + "Allow traffic from anywhere to Restate admin port", + ); + + this.ingressUrl = + `https://${restateInstance.instancePublicDnsName}` + + (PUBLIC_INGRESS_PORT == 443 ? "" : `:${PUBLIC_INGRESS_PORT}`); + this.adminUrl = `https://${restateInstance.instancePublicDnsName}:${PUBLIC_ADMIN_PORT}`; + } else { + ingressSecurityGroup.addIngressRule( + ingressSecurityGroup, + ec2.Port.tcp(RESTATE_INGRESS_PORT), + "Allow traffic to Restate ingress port", + ); + adminSecurityGroup.addIngressRule( + adminSecurityGroup, + ec2.Port.tcp(RESTATE_ADMIN_PORT), + "Allow traffic to Restate admin port", + ); + + this.ingressUrl = `http://${restateInstance.instancePrivateDnsName}:${RESTATE_INGRESS_PORT}`; + this.adminUrl = `http://${restateInstance.instancePrivateDnsName}:${RESTATE_ADMIN_PORT}`; + } - this.ingressUrl = `https://${restateInstance.instancePublicDnsName}${ - PUBLIC_INGRESS_PORT == 443 ? "" : `:${PUBLIC_INGRESS_PORT}` - }`; - this.adminUrl = `https://${restateInstance.instancePublicDnsName}:${PUBLIC_ADMIN_PORT}`; + this.ingressSecurityGroup = ingressSecurityGroup; + this.adminSecurityGroup = adminSecurityGroup; } protected restateConfig(id: string, props: SingleNodeRestateProps) { @@ -334,8 +387,8 @@ fi props.ingressNginxConfigOverride ?? [ "server {", - " listen 443 ssl http2;", - " listen [::]:443 ssl http2;", + ` listen ${PUBLIC_INGRESS_PORT} ssl http2;`, + ` listen [::]:${PUBLIC_INGRESS_PORT} ssl http2;`, " server_name _;", " root /usr/share/nginx/html;", "", @@ -353,8 +406,8 @@ fi "}", "", "server {", - " listen 9073 ssl http2;", - " listen [::]:9073 ssl http2;", + ` listen ${PUBLIC_ADMIN_PORT} ssl http2;`, + ` listen [::]:${PUBLIC_ADMIN_PORT} ssl http2;`, " server_name _;", " root /usr/share/nginx/html;", "", diff --git a/test/__snapshots__/restate-constructs.test.ts.snap b/test/__snapshots__/restate-constructs.test.ts.snap index 0fb8cb0..02186f8 100644 --- a/test/__snapshots__/restate-constructs.test.ts.snap +++ b/test/__snapshots__/restate-constructs.test.ts.snap @@ -611,9 +611,12 @@ exports[`Restate constructs Create a self-hosted Restate environment deployed on - RestateHostInstanceSecurityGroup471D630B - GroupId - 'Fn::GetAtt': - - RestateRestateSecurityGroup73273B13 + - RestateIngressSecurityGroupAF84F229 - GroupId - SubnetId: s-12345 + - 'Fn::GetAtt': + - RestateAdminSecurityGroupA3FF2853 + - GroupId + SubnetId: p-12345 Tags: - Key: Name Value: RestateSelfHostedServerEc2Stack/Restate/Host @@ -651,7 +654,10 @@ exports[`Restate constructs Create a self-hosted Restate environment deployed on set -euf -o pipefail - yum install -y docker nginx + yum install -y npm && npm install -gq + @restatedev/restate@latest + + yum install -y docker if mount | grep -qs '/var/restate'; then @@ -678,7 +684,7 @@ exports[`Restate constructs Create a self-hosted Restate environment deployed on fi - mkdir /etc/restate + mkdir -p /etc/restate cat << EOF > /etc/restate/config.toml @@ -751,10 +757,16 @@ exports[`Restate constructs Create a self-hosted Restate environment deployed on systemctl start docker.service + if [ "$(docker ps -qa -f name=adot)" ]; then docker stop + adot || true; docker rm adot; fi + docker run --name adot --restart on-failure --detach -p 4317:4317 -p 55680:55680 -p 8889:8888 public.ecr.aws/aws-observability/aws-otel-collector:latest + if [ "$(docker ps -qa -f name=restate)" ]; then docker + stop restate || true; docker rm restate; fi + docker run --name restate --restart on-failure --detach --volume /etc/restate:/etc/restate --volume /var/restate:/restate-data --network=host -e @@ -763,87 +775,61 @@ exports[`Restate constructs Create a self-hosted Restate environment deployed on RESTATE_OBSERVABILITY__TRACING__ENDPOINT=http://localhost:4317 --log-driver=awslogs --log-opt awslogs-group= - Ref: RestateLogsFD86ECAE - - >2- - docker.io/restatedev/restate:latest --config-file /etc/restate/config.toml - mkdir -p /etc/pki/private - - openssl req -new -x509 -nodes -sha256 -days 365 - -extensions v3_ca -subj - '/C=DE/ST=Berlin/L=Berlin/O=restate.dev/OU=demo/CN=restate.example.com' - -newkey rsa:2048 -keyout - /etc/pki/private/restate-selfsigned.key -out - /etc/pki/private/restate-selfsigned.crt - - cat << EOF > /etc/nginx/conf.d/restate-ingress.conf - - server { - listen 443 ssl http2; - listen [::]:443 ssl http2; - server_name _; - root /usr/share/nginx/html; - - ssl_certificate "/etc/pki/private/restate-selfsigned.crt"; - ssl_certificate_key "/etc/pki/private/restate-selfsigned.key"; - ssl_session_cache shared:SSL:1m; - ssl_session_timeout 10m; - ssl_ciphers PROFILE=SYSTEM; - ssl_prefer_server_ciphers on; - - location / { - proxy_pass http://localhost:8080; - proxy_read_timeout 3600; - } - } - - - server { - listen 9073 ssl http2; - listen [::]:9073 ssl http2; - server_name _; - root /usr/share/nginx/html; - - ssl_certificate "/etc/pki/private/restate-selfsigned.crt"; - ssl_certificate_key "/etc/pki/private/restate-selfsigned.key"; - ssl_session_cache shared:SSL:1m; - ssl_session_timeout 10m; - ssl_ciphers PROFILE=SYSTEM; - ssl_prefer_server_ciphers on; - - location / { - proxy_pass http://localhost:9070; - } - } - - EOF - - systemctl start nginx + - ' docker.io/restatedev/restate:latest --config-file /etc/restate/config.toml' - | --+AWS+CDK+User+Data+Separator==-- DependsOn: - RestateInstanceRoleDefaultPolicyD1D39538 - RestateInstanceRoleACC59A6F - RestateRestateSecurityGroup73273B13: + RestateIngressSecurityGroupAF84F229: Type: 'AWS::EC2::SecurityGroup' Properties: - GroupDescription: Restate service ACLs - GroupName: RestateSecurityGroup + GroupDescription: Restate Ingress ACLs SecurityGroupEgress: - CidrIp: 0.0.0.0/0 Description: Allow all outbound traffic by default IpProtocol: '-1' - SecurityGroupIngress: - - CidrIp: 0.0.0.0/0 - Description: Allow traffic from anywhere to Restate ingress port - FromPort: 443 - IpProtocol: tcp - ToPort: 443 + VpcId: vpc-12345 + RestateIngressSecurityGroupfromRestateSelfHostedServerEc2StackRestateIngressSecurityGroup2A345A7B8080B8082F1D: + Type: 'AWS::EC2::SecurityGroupIngress' + Properties: + Description: Allow traffic to Restate ingress port + FromPort: 8080 + GroupId: + 'Fn::GetAtt': + - RestateIngressSecurityGroupAF84F229 + - GroupId + IpProtocol: tcp + SourceSecurityGroupId: + 'Fn::GetAtt': + - RestateIngressSecurityGroupAF84F229 + - GroupId + ToPort: 8080 + RestateAdminSecurityGroupA3FF2853: + Type: 'AWS::EC2::SecurityGroup' + Properties: + GroupDescription: Restate Admin ACLs + SecurityGroupEgress: - CidrIp: 0.0.0.0/0 - Description: Allow traffic from anywhere to Restate admin port - FromPort: 9073 - IpProtocol: tcp - ToPort: 9073 + Description: Allow all outbound traffic by default + IpProtocol: '-1' VpcId: vpc-12345 + RestateAdminSecurityGroupfromRestateSelfHostedServerEc2StackRestateAdminSecurityGroupFC96B1BB9070F9F6DD3D: + Type: 'AWS::EC2::SecurityGroupIngress' + Properties: + Description: Allow traffic to Restate admin port + FromPort: 9070 + GroupId: + 'Fn::GetAtt': + - RestateAdminSecurityGroupA3FF2853 + - GroupId + IpProtocol: tcp + SourceSecurityGroupId: + 'Fn::GetAtt': + - RestateAdminSecurityGroupA3FF2853 + - GroupId + ToPort: 9070 Parameters: Any " `; diff --git a/test/e2e/single-node-ec2.e2e.ts b/test/e2e/single-node-ec2.e2e.ts index 0634dba..5d42675 100644 --- a/test/e2e/single-node-ec2.e2e.ts +++ b/test/e2e/single-node-ec2.e2e.ts @@ -4,6 +4,7 @@ import * as logs from "aws-cdk-lib/aws-logs"; import "source-map-support/register"; import { ServiceDeployer, SingleNodeRestateDeployment } from "../../lib/restate-constructs"; +import * as ec2 from "aws-cdk-lib/aws-ec2"; // Deploy with: npx cdk --app 'npx tsx single-node-ec2.e2e.ts' deploy const app = new cdk.App(); @@ -20,22 +21,32 @@ const handler: lambda.Function = new lambda.Function(stack, "Service", { handler: "bundle.handler", }); +const vpc = ec2.Vpc.fromLookup(stack, "Vpc", { vpcId: "vpc-0d2e373fed47934f3" }); + const environment = new SingleNodeRestateDeployment(stack, "Restate", { + vpc, + networkConfiguration: { + subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS, + }, logGroup: new logs.LogGroup(stack, "ServerLogs", { retention: logs.RetentionDays.ONE_MONTH, removalPolicy: cdk.RemovalPolicy.DESTROY, }), + removalPolicy: cdk.RemovalPolicy.DESTROY, }); const deployer = new ServiceDeployer(stack, "ServiceDeployer", { + vpc: environment.vpc, + securityGroups: [environment.adminSecurityGroup], removalPolicy: cdk.RemovalPolicy.DESTROY, entry: "../../dist/register-service-handler/index.js", // only for tests }); deployer.deployService("Greeter", handler.currentVersion, environment, { - insecure: true, // accept self-signed certificate from server + configurationVersion: new Date().toISOString(), }); new cdk.CfnOutput(stack, "RestateIngressUrl", { value: environment.ingressUrl }); +new cdk.CfnOutput(stack, "RestateInstanceId", { value: environment.instance.instanceId }); app.synth();