diff --git a/Makefile b/Makefile index a042f69..45430a8 100644 --- a/Makefile +++ b/Makefile @@ -72,6 +72,26 @@ static-site-destroy: static-site-diff: cdk diff --app='./lib/integ/integ.static-site.js'; + +## -- Django-Vue App Targets -- + +## synthesize Django-Vue App +django-vue-synth: + cdk synth --app='./lib/integ/integ.django-vue.js'; + +## deploy Django-Vue App +django-vue-deploy: + cdk deploy --app='./lib/integ/integ.django-vue.js'; + +## destroy Django-Vue App +django-vue-destroy: + cdk destroy --app='./lib/integ/integ.django-vue.js'; + +## diff Django-Vue App +django-vue-diff: + cdk diff --app='./lib/integ/integ.django-vue.js'; + + ## describe CloudFormation stacks in the AWS account describe-stacks: aws cloudformation describe-stacks | jq diff --git a/src/django-ecs.ts b/src/django-ecs.ts index 88e06f5..662aa53 100644 --- a/src/django-ecs.ts +++ b/src/django-ecs.ts @@ -105,10 +105,15 @@ export class DjangoEcs extends cdk.Construct { public vpc: ec2.IVpc; public cluster: ecs.Cluster; public image: ecs.ContainerImage; + public loadBalancer: elbv2.ApplicationLoadBalancer; + public apiDomainName: string; constructor(scope: cdk.Construct, id: string, props: DjangoEcsProps) { super(scope, id); + // expose api domain name + this.apiDomainName = props.apiDomainName!; + /** * VPC must have public, private and isolated subnets * @@ -320,6 +325,9 @@ export class DjangoEcs extends cdk.Construct { albfs.loadBalancer.logAccessLogs(albLogsBucket); + // expose the load balancer on the DjangoEcs construct + this.loadBalancer = albfs.loadBalancer; + // optionally disable the admin interface // albfs.listener.addAction diff --git a/src/django-vue.ts b/src/django-vue.ts new file mode 100644 index 0000000..7902b79 --- /dev/null +++ b/src/django-vue.ts @@ -0,0 +1,88 @@ +/** + * + * This is a high-level construct that demonstrates + * how to use the static site CloudFrontWebDistribution + * together with the ECS Stack. + * + * CloudFront allows for both the static site and + * the `/api` calls to be served from the same + * domain (app.example.com, for example). + * + * This would be a good setup if you are using a Vue or React + * static website (such as an SPA) and your Django app's + * REST API uses either JWT (with HttpOnly refresh token) + * or Session-based API authentication. + * + * If you are using a Vue app and a Django app and don't mind + * storing JWT on the browser's localStorage, you can also use this + * approach, but you can also setup the frontend and backend on + * different domains. + * + **/ + +import * as cdk from '@aws-cdk/core'; +import { DjangoEcs } from './index'; +import { StaticSite } from './index'; +import * as acm from '@aws-cdk/aws-certificatemanager'; +import * as route53 from '@aws-cdk/aws-route53'; + +/** + * Django and Vue application stack props + */ +export interface DjangoVueProps { + /** + * Certificate ARN for looking up the Certificate to use for CloudFront and ALB + */ + readonly certificateArn?: string; + + readonly domainName: string; + + readonly zoneName: string; + + +} + +/** + * + * Construct for projects using Django backend and static site for frontend + */ +export class DjangoVue extends cdk.Construct { + constructor(scope: cdk.Construct, id: string, props: DjangoVueProps) { + super(scope, id); + + let certificate; + if (props.certificateArn) { + certificate = acm.Certificate.fromCertificateArn(scope, 'Cert', process.env.CERTIFICATE_ARN!); + } else { + certificate = new acm.DnsValidatedCertificate(scope, 'Cert', { + domainName: props.domainName, + hostedZone: route53.HostedZone.fromLookup(scope, 'HostedZone', { + domainName: props.zoneName, + }) + }) + } + + // const apiBackend = + const apiBackend = new DjangoEcs(scope, 'DjangoEcsSample', { + imageDirectory: './test/django-step-by-step/backend', + webCommand: ['./scripts/start_prod.sh'], + useCeleryBeat: true, + apiDomainName: process.env.API_DOMAIN_NAME, + zoneName: process.env.ZONE_NAME, + useEcsExec: true, + frontendUrl: process.env.FRONTEND_URL, + certificateArn: certificate.certificateArn, + }); + + new StaticSite(scope, 'StaticSiteSample', { + frontendDomainName: props.domainName, + pathToDist: 'test/django-step-by-step/quasar-app/dist/pwa', + zoneName: props.zoneName, + loadBalancer: apiBackend.loadBalancer, + // assetsBucket: apiBackend.staticFileBucket, + }); + + new cdk.CfnOutput(this, 'loadBalancerName', { value: apiBackend.loadBalancer.loadBalancerDnsName }); + + }; +}; diff --git a/src/index.ts b/src/index.ts index 5bf00eb..897958a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ export * from './django-ecs'; export * from './django-eks'; export * from './django-s3-storage'; -export * from './static-site'; \ No newline at end of file +export * from './static-site'; +export * from './django-vue'; \ No newline at end of file diff --git a/src/integ/integ.django-vue.ts b/src/integ/integ.django-vue.ts new file mode 100644 index 0000000..bd08f4b --- /dev/null +++ b/src/integ/integ.django-vue.ts @@ -0,0 +1,22 @@ +import * as cdk from '@aws-cdk/core'; +import { DjangoVue } from '../index'; + +const env = { + region: process.env.AWS_DEFAULT_REGION || 'us-east-1', + account: process.env.AWS_ACCOUNT_ID, +}; + + +const app = new cdk.App(); +const stack = new cdk.Stack(app, 'DjangoVueStack', { env }); + +// no props needed on this construct +const construct = new DjangoVue(stack, 'DjangoVueSample', { + domainName: process.env.FRONTEND_DOMAIN_NAME!, + zoneName: process.env.ZONE_NAME!, +}); + +/** + * Add tagging for this construct and all child constructs + */ +cdk.Tags.of(construct).add('stack', 'DjangoVueStack'); diff --git a/src/static-site.ts b/src/static-site.ts index f88ac8d..19bd3a4 100644 --- a/src/static-site.ts +++ b/src/static-site.ts @@ -6,8 +6,10 @@ import * as cloudfront from '@aws-cdk/aws-cloudfront'; import * as iam from '@aws-cdk/aws-iam'; import * as route53 from '@aws-cdk/aws-route53'; import * as targets from '@aws-cdk/aws-route53-targets'; +import * as acm from '@aws-cdk/aws-certificatemanager'; import * as s3 from '@aws-cdk/aws-s3'; import * as s3Deployment from '@aws-cdk/aws-s3-deployment'; +import * as elbv2 from '@aws-cdk/aws-elasticloadbalancingv2'; import * as cdk from '@aws-cdk/core'; @@ -32,6 +34,26 @@ export interface StaticSiteProps { */ readonly certificateArn?: string; + /** + * + * Load Balancer + * + * If the backend and the frontend are served on the same site, + * this is required. CloudFront will act as a proxy, doing + * path-based routing to the backend load balancer (for example, + * all requests starting with `/api/*`) + * + * + **/ + readonly loadBalancer?: elbv2.ApplicationLoadBalancer; + + /** + * Assets bucket + * + * Proxy requests to /static and /media to the assets bucket + */ + readonly assetsBucket?: s3.Bucket; + /** * Public Hosted Zone * @@ -48,6 +70,9 @@ export interface StaticSiteProps { * https://github.com/aws-samples/aws-cdk-examples/blob/master/typescript/static-site/static-site.ts */ export class StaticSite extends cdk.Construct { + + public distribution: cloudfront.CloudFrontWebDistribution; + constructor(scope: cdk.Construct, id: string, props: StaticSiteProps) { super(scope, id); @@ -75,32 +100,106 @@ export class StaticSite extends cdk.Construct { domainName: props.zoneName, }); - // create the certificate if it doesn't exist - // TODO: optionally look up the certificate from props.certificateArn - const certificate = new DnsValidatedCertificate(scope, 'Certificate', { - domainName: props.frontendDomainName, - hostedZone, - }); + // create the certificate if it doesn't exist or look up the certificate from props.certificateArn + let certificate; + if (props.certificateArn) { + certificate = acm.Certificate.fromCertificateArn(scope, 'Certificate', props.certificateArn); + } else { + certificate = new DnsValidatedCertificate(scope, 'Certificate', { + domainName: props.frontendDomainName, + hostedZone, + }); + } + + const originConfigs = []; + + /** + * CloudFront Origins + * + * 1. Static site origin + * 2. Static assets origin + * 3. Load balancer origin + * + */ + + // static site origin + const staticSiteOrigin = { + s3OriginSource: { + s3BucketSource: staticSiteBucket, + originAccessIdentity: cloudfrontOAI, + }, + behaviors: [{ + isDefaultBehavior: true, + compress: true, + allowedMethods: cloudfront.CloudFrontAllowedMethods.GET_HEAD_OPTIONS, + }], + }; + originConfigs.push(staticSiteOrigin); + + // if there is a S3 Bucket prop passed to the static site construct, + // create a fileStorageOrigin + if (props.assetsBucket ?? false) { + const fileStorageOrigin = { + s3OriginSource: { + s3BucketSource: props.assetsBucket!, + originAccessIdentity: cloudfrontOAI, + }, + behaviors: ["/static/*", "/media/*"].map(path => { + return { + isDefaultBehavior: false, + compress: false, + allowedMethods: cloudfront.CloudFrontAllowedMethods.GET_HEAD_OPTIONS, + forwardedValues: { "query_string": true }, + pathPattern: path, + minTtl: cdk.Duration.seconds(0), + defaultTtl: cdk.Duration.seconds(0), + maxTtl: cdk.Duration.seconds(0), + } + }), + } + originConfigs.push(fileStorageOrigin); + } + + // load balancer origin + if (props.loadBalancer ?? false) { + + // TODO: set this as a config option with a default + const pathPatterns = ['/api/*', '/graphql/*', '/admin/*']; + + const behaviors = pathPatterns.map(path => { + return { + allowedMethods: cloudfront.CloudFrontAllowedMethods.ALL, + forwardedValues: { + "queryString": true, + "cookies": { forward: "all" }, + "headers": ["*"], + }, + pathPattern: path, + // minTtl: cdk.Duration.seconds(0), + // defaultTtl: cdk.Duration.seconds(0), + // maxTtl: cdk.Duration.seconds(0), + } + }); + + const loadBalancerOrigin = { + customOriginSource: { + domainName: props.loadBalancer!.loadBalancerDnsName, + originProtocolPolicy: cloudfront.OriginProtocolPolicy.HTTP_ONLY, + }, + behaviors, + }; + originConfigs.push(loadBalancerOrigin); + } + + console.log(originConfigs); // create the cloudfront web distribution - const distribution = new cloudfront.CloudFrontWebDistribution(this, 'StaticSiteDistribution', { + this.distribution = new cloudfront.CloudFrontWebDistribution(this, 'StaticSiteDistribution', { aliasConfiguration: { acmCertRef: certificate.certificateArn, names: [props.frontendDomainName], }, - originConfigs: [ - { - s3OriginSource: { - s3BucketSource: staticSiteBucket, - originAccessIdentity: cloudfrontOAI, - }, - behaviors: [{ - isDefaultBehavior: true, - compress: true, - allowedMethods: cloudfront.CloudFrontAllowedMethods.GET_HEAD_OPTIONS, - }], - }, - ], + originConfigs, // viewerCertificate: cloudfront.ViewerCertificate.fromAcmCertificate(certificate), }); @@ -111,19 +210,17 @@ export class StaticSite extends cdk.Construct { new s3Deployment.BucketDeployment(scope, 'BucketDeployment', { sources: [s3Deployment.Source.asset(path.join(process.cwd(), props.pathToDist))], destinationBucket: staticSiteBucket, - distribution, + distribution: this.distribution, distributionPaths: ['/*'], }); } - // create the A Record that will point to the CloudFront distribution new route53.ARecord(scope, 'RecordTarget', { - target: route53.RecordTarget.fromAlias(new targets.CloudFrontTarget(distribution)), + target: route53.RecordTarget.fromAlias(new targets.CloudFrontTarget(this.distribution)), zone: hostedZone, // note that the recordName must end with a period (.) recordName: `${props.frontendDomainName}.`, }); - } } \ No newline at end of file diff --git a/test/django-step-by-step b/test/django-step-by-step index 15d1d2a..ab628fc 160000 --- a/test/django-step-by-step +++ b/test/django-step-by-step @@ -1 +1 @@ -Subproject commit 15d1d2a75bb12faa2026045ae9ff9e14894b22fd +Subproject commit ab628fceb08334da963785ce65f0eace8a2608f6