203 lines
8.1 KiB
TypeScript
203 lines
8.1 KiB
TypeScript
import * as cdk from 'aws-cdk-lib';
|
|
import * as s3 from 'aws-cdk-lib/aws-s3';
|
|
import * as cloudfront from 'aws-cdk-lib/aws-cloudfront';
|
|
import * as origins from 'aws-cdk-lib/aws-cloudfront-origins';
|
|
import * as iam from 'aws-cdk-lib/aws-iam';
|
|
import * as route53 from 'aws-cdk-lib/aws-route53';
|
|
import * as targets from 'aws-cdk-lib/aws-route53-targets';
|
|
import * as certificatemanager from 'aws-cdk-lib/aws-certificatemanager';
|
|
import { Construct } from 'constructs';
|
|
|
|
export interface ForgePortfolioStackProps extends cdk.StackProps {
|
|
domainName?: string; // Optional custom domain
|
|
hostedZoneId?: string; // Required if using custom domain
|
|
}
|
|
|
|
export class ForgePortfolioStack extends cdk.Stack {
|
|
public readonly bucket: s3.Bucket;
|
|
public readonly distribution: cloudfront.Distribution;
|
|
public readonly deploymentUser: iam.User;
|
|
|
|
constructor(scope: Construct, id: string, props: ForgePortfolioStackProps) {
|
|
super(scope, id, props);
|
|
|
|
// S3 Bucket for hosting static files
|
|
this.bucket = new s3.Bucket(this, 'PortfolioBucket', {
|
|
bucketName: `${id.toLowerCase()}-portfolio-${this.account}`,
|
|
removalPolicy: cdk.RemovalPolicy.DESTROY, // Be careful with this in production
|
|
autoDeleteObjects: true,
|
|
publicReadAccess: false, // CloudFront will handle access
|
|
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
|
|
versioned: false, // Keep costs low
|
|
websiteIndexDocument: 'index.html',
|
|
websiteErrorDocument: 'error.html',
|
|
});
|
|
|
|
// Origin Access Control for CloudFront to access S3
|
|
const originAccessControl = new cloudfront.S3OriginAccessControl(this, 'OAC', {
|
|
description: 'OAC for portfolio website',
|
|
});
|
|
|
|
// SSL Certificate (only if custom domain is provided)
|
|
let certificate: certificatemanager.Certificate | undefined;
|
|
if (props.domainName) {
|
|
certificate = new certificatemanager.Certificate(this, 'Certificate', {
|
|
domainName: props.domainName,
|
|
validation: certificatemanager.CertificateValidation.fromDns(),
|
|
});
|
|
}
|
|
|
|
// CloudFront Distribution
|
|
this.distribution = new cloudfront.Distribution(this, 'Distribution', {
|
|
defaultBehavior: {
|
|
origin: origins.S3BucketOrigin.withOriginAccessControl(this.bucket, {
|
|
originAccessControl,
|
|
}),
|
|
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
|
|
cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
|
|
compress: true,
|
|
allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD,
|
|
},
|
|
defaultRootObject: 'index.html',
|
|
errorResponses: [
|
|
{
|
|
httpStatus: 404,
|
|
responseHttpStatus: 200,
|
|
responsePagePath: '/index.html', // For SPA routing
|
|
ttl: cdk.Duration.minutes(5),
|
|
},
|
|
{
|
|
httpStatus: 403,
|
|
responseHttpStatus: 200,
|
|
responsePagePath: '/index.html', // For SPA routing
|
|
ttl: cdk.Duration.minutes(5),
|
|
},
|
|
],
|
|
domainNames: props.domainName ? [props.domainName] : undefined,
|
|
certificate: certificate,
|
|
minimumProtocolVersion: cloudfront.SecurityPolicyProtocol.TLS_V1_2_2021,
|
|
priceClass: cloudfront.PriceClass.PRICE_CLASS_100, // Use only NA and Europe for cost optimization
|
|
});
|
|
|
|
// Grant CloudFront access to S3 bucket
|
|
this.bucket.addToResourcePolicy(
|
|
new iam.PolicyStatement({
|
|
actions: ['s3:GetObject'],
|
|
resources: [this.bucket.arnForObjects('*')],
|
|
principals: [new iam.ServicePrincipal('cloudfront.amazonaws.com')],
|
|
conditions: {
|
|
StringEquals: {
|
|
'AWS:SourceArn': `arn:aws:cloudfront::${this.account}:distribution/${this.distribution.distributionId}`,
|
|
},
|
|
},
|
|
})
|
|
);
|
|
|
|
// Ensure bucket allows CloudFront to list objects (needed for error handling)
|
|
this.bucket.addToResourcePolicy(
|
|
new iam.PolicyStatement({
|
|
actions: ['s3:ListBucket'],
|
|
resources: [this.bucket.bucketArn],
|
|
principals: [new iam.ServicePrincipal('cloudfront.amazonaws.com')],
|
|
conditions: {
|
|
StringEquals: {
|
|
'AWS:SourceArn': `arn:aws:cloudfront::${this.account}:distribution/${this.distribution.distributionId}`,
|
|
},
|
|
},
|
|
})
|
|
);
|
|
|
|
// Custom domain DNS record (if provided)
|
|
if (props.domainName && props.hostedZoneId) {
|
|
const hostedZone = route53.HostedZone.fromHostedZoneAttributes(this, 'HostedZone', {
|
|
hostedZoneId: props.hostedZoneId,
|
|
zoneName: props.domainName,
|
|
});
|
|
|
|
new route53.ARecord(this, 'ARecord', {
|
|
zone: hostedZone,
|
|
target: route53.RecordTarget.fromAlias(
|
|
new targets.CloudFrontTarget(this.distribution)
|
|
),
|
|
});
|
|
}
|
|
|
|
// IAM User for GitHub Actions deployment
|
|
this.deploymentUser = new iam.User(this, 'DeploymentUser', {
|
|
userName: `${id}-github-actions-user`,
|
|
});
|
|
|
|
// Policy for deployment user
|
|
const deploymentPolicy = new iam.Policy(this, 'DeploymentPolicy', {
|
|
statements: [
|
|
// S3 permissions for uploading files
|
|
new iam.PolicyStatement({
|
|
actions: [
|
|
's3:PutObject',
|
|
's3:PutObjectAcl',
|
|
's3:GetObject',
|
|
's3:DeleteObject',
|
|
's3:ListBucket',
|
|
],
|
|
resources: [
|
|
this.bucket.bucketArn,
|
|
this.bucket.arnForObjects('*'),
|
|
],
|
|
}),
|
|
// CloudFront invalidation permissions
|
|
new iam.PolicyStatement({
|
|
actions: ['cloudfront:CreateInvalidation'],
|
|
resources: [
|
|
`arn:aws:cloudfront::${this.account}:distribution/${this.distribution.distributionId}`,
|
|
],
|
|
}),
|
|
],
|
|
});
|
|
|
|
this.deploymentUser.attachInlinePolicy(deploymentPolicy);
|
|
|
|
// Create Access Key for the deployment user
|
|
const accessKey = new iam.AccessKey(this, 'DeploymentUserAccessKey', {
|
|
user: this.deploymentUser,
|
|
});
|
|
|
|
// Outputs for GitHub Actions
|
|
new cdk.CfnOutput(this, 'BucketName', {
|
|
value: this.bucket.bucketName,
|
|
description: 'S3 Bucket name for deployment',
|
|
});
|
|
|
|
new cdk.CfnOutput(this, 'DistributionId', {
|
|
value: this.distribution.distributionId,
|
|
description: 'CloudFront Distribution ID for cache invalidation',
|
|
});
|
|
|
|
new cdk.CfnOutput(this, 'DistributionDomainName', {
|
|
value: this.distribution.distributionDomainName,
|
|
description: 'CloudFront Distribution domain name',
|
|
});
|
|
|
|
new cdk.CfnOutput(this, 'AccessKeyId', {
|
|
value: accessKey.accessKeyId,
|
|
description: 'Access Key ID for GitHub Actions (store as secret)',
|
|
});
|
|
|
|
new cdk.CfnOutput(this, 'SecretAccessKey', {
|
|
value: accessKey.secretAccessKey.unsafeUnwrap(),
|
|
description: 'Secret Access Key for GitHub Actions (store as secret)',
|
|
});
|
|
|
|
if (props.domainName) {
|
|
new cdk.CfnOutput(this, 'WebsiteURL', {
|
|
value: `https://${props.domainName}`,
|
|
description: 'Website URL',
|
|
});
|
|
} else {
|
|
new cdk.CfnOutput(this, 'WebsiteURL', {
|
|
value: `https://${this.distribution.distributionDomainName}`,
|
|
description: 'Website URL',
|
|
});
|
|
}
|
|
}
|
|
}
|