Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,470 changes: 1,247 additions & 223 deletions package-lock.json

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,21 +39,23 @@
"@upstash/pulumi": "^0.3.14"
},
"devDependencies": {
"@aws-sdk/client-acm": "^3.782.0",
"@aws-sdk/client-application-auto-scaling": "^3.758.0",
"@aws-sdk/client-cloudwatch-logs": "^3.767.0",
"@aws-sdk/client-ec2": "^3.767.0",
"@aws-sdk/client-ecs": "^3.766.0",
"@aws-sdk/client-efs": "^3.758.0",
"@aws-sdk/client-elastic-load-balancing-v2": "^3.764.0",
"@aws-sdk/client-route-53": "^3.782.0",
"@aws-sdk/client-servicediscovery": "^3.758.0",
"@studion/prettier-config": "^0.1.0",
"@types/node": "^22",
"exponential-backoff": "^3.1.2",
"http-status": "^2.1.0",
"nanospinner": "^1.2.2",
"pathe": "^2.0.3",
"prettier": "^3.4.2",
"release-it": "^18.1.1",
"http-status": "^2.1.0",
"nanospinner": "^1.2.2",
"ts-node": "^10.9.2",
"typescript": "^5.7.3",
"undici": "^6.21.2"
Expand Down
6 changes: 5 additions & 1 deletion src/components/web-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,11 @@ export class WebServer extends pulumi.ComponentResource {
args: WebServerArgs,
opts: pulumi.ComponentResourceOptions = {},
) {
super('studion:WebServer', name, args, opts);
const aliases = opts.aliases || [];
super('studion:LegacyWebServer', name, args, {
...opts,
aliases: [...aliases, { type: 'studion:WebServer' }]
});

const { vpcId, domain, hostedZoneId } = args;

Expand Down
8 changes: 5 additions & 3 deletions src/v2/components/ecs-service/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ type PersistentStorage = {
accessPoint: aws.efs.AccessPoint
};

namespace EcsService {
export namespace EcsService {
/**
* Create a named volume that can be mounted into one or more containers.
* Used with Amazon EFS to enable persistent storage across:
Expand Down Expand Up @@ -44,7 +44,7 @@ namespace EcsService {
export type Container = {
name: pulumi.Input<string>;
image: pulumi.Input<string>;
portMappings: pulumi.Input<aws.ecs.PortMapping>[];
portMappings: pulumi.Input<pulumi.Input<aws.ecs.PortMapping>[]>;
command?: pulumi.Input<string[]>;
mountPoints?: PersistentStorageMountPoint[];
environment?: pulumi.Input<aws.ecs.KeyValuePair[]>;
Expand Down Expand Up @@ -223,7 +223,9 @@ export class EcsService extends pulumi.ComponentResource {
this.registerOutputs();
}

public static createTcpPortMapping(port: number): aws.ecs.PortMapping {
public static createTcpPortMapping(
port: pulumi.Input<number>
): aws.ecs.PortMapping {
return {
containerPort: port,
hostPort: port,
Expand Down
165 changes: 165 additions & 0 deletions src/v2/components/web-server/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import * as pulumi from '@pulumi/pulumi';
import * as aws from '@pulumi/aws';
import * as awsx from '@pulumi/awsx';
import { commonTags } from '../../../constants';
import { AcmCertificate } from '../../../components/acm-certificate';
import { EcsService } from '../ecs-service';
import { WebServerLoadBalancer } from './load-balancer';

export namespace WebServer {
export type Args = Pick<
EcsService.Args,
| 'cluster'
| 'vpc'
| 'desiredCount'
| 'autoscaling'
| 'size'
| 'volumes'
| 'taskExecutionRoleInlinePolicies'
| 'taskRoleInlinePolicies'
| 'tags'
>
& Pick<EcsService.Container, 'image' | 'environment' | 'secrets' | 'mountPoints'>
& {
port: pulumi.Input<number>,
publicSubnetIds: pulumi.Input<pulumi.Input<string>[]>;
/**
* The domain which will be used to access the service.
* The domain or subdomain must belong to the provided hostedZone.
*/
domain?: pulumi.Input<string>;
hostedZoneId?: pulumi.Input<string>;
/**
* Path for the load balancer target group health check request.
*
* @default
* "/healthcheck"
*/
healthCheckPath?: pulumi.Input<string>;
};
}

export class WebServer extends pulumi.ComponentResource {
name: string;
service: EcsService;
serviceSecurityGroup: aws.ec2.SecurityGroup;
lb: WebServerLoadBalancer;
certificate?: AcmCertificate;
dnsRecord?: aws.route53.Record;

constructor(
name: string,
args: WebServer.Args,
opts: pulumi.ComponentResourceOptions = {},
) {
super('studion:WebServer', name, args, opts);

const { vpc, domain, hostedZoneId } = args;

if (domain && !hostedZoneId) {
throw new Error(
'WebServer:hostedZoneId must be provided when the domain is specified',
);
}
const hasCustomDomain = !!domain && !!hostedZoneId;
if (hasCustomDomain) {
this.certificate = this.createTlsCertificate({ domain, hostedZoneId });
}

this.name = name;
this.lb = new WebServerLoadBalancer(`${this.name}-lb`, {
vpc,
port: args.port,
certificate: this.certificate?.certificate,
healthCheckPath: args.healthCheckPath
});
this.serviceSecurityGroup = this.createSecurityGroup(vpc);
this.service = this.createEcsService(args);

if (hasCustomDomain) {
this.dnsRecord = this.createDnsRecord({ domain, hostedZoneId });
}

this.registerOutputs();
}

private createTlsCertificate({
domain,
hostedZoneId,
}: Pick<Required<WebServer.Args>, 'domain' | 'hostedZoneId'>): AcmCertificate {
return new AcmCertificate(`${domain}-acm-certificate`, {
domain,
hostedZoneId,
}, { parent: this });
}

private createSecurityGroup(
vpc: pulumi.Input<awsx.ec2.Vpc>
): aws.ec2.SecurityGroup {
const vpcId = pulumi.output(vpc).vpcId;
return new aws.ec2.SecurityGroup(
`${this.name}-security-group`, {
vpcId,
ingress: [{
fromPort: 0,
toPort: 0,
protocol: '-1',
securityGroups: [this.lb.securityGroup.id],
}],
egress: [{
fromPort: 0,
toPort: 0,
protocol: '-1',
cidrBlocks: ['0.0.0.0/0'],
}],
tags: commonTags,
}, { parent: this });
}

private createEcsService(
args: WebServer.Args
): EcsService {
return new EcsService(this.name, {
...args,
containers: [{
name: this.name,
image: args.image,
portMappings: [EcsService.createTcpPortMapping(args.port)],
mountPoints: args.mountPoints,
environment: args.environment,
secrets: args.secrets,
essential: true
}],
enableServiceAutoDiscovery: false,
loadBalancers: [{
containerName: this.name,
containerPort: args.port,
targetGroupArn: this.lb.targetGroup.arn,
}],
assignPublicIp: true,
securityGroup: this.serviceSecurityGroup,
}, {
parent: this,
dependsOn: [this.lb, this.lb.targetGroup],
});
}

private createDnsRecord({
domain,
hostedZoneId,
}: Pick<
Required<WebServer.Args>,
'domain' | 'hostedZoneId'
>): aws.route53.Record {
return new aws.route53.Record(`${this.name}-route53-record`, {
type: 'A',
name: domain,
zoneId: hostedZoneId,
aliases: [{
name: this.lb.lb.dnsName,
zoneId: this.lb.lb.zoneId,
evaluateTargetHealth: true,
}],
}, { parent: this });
}
}
162 changes: 162 additions & 0 deletions src/v2/components/web-server/load-balancer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import * as pulumi from '@pulumi/pulumi';
import * as aws from '@pulumi/aws';
import * as awsx from '@pulumi/awsx';
import { commonTags } from '../../../constants';

export namespace WebServerLoadBalancer {
export type Args = {
vpc: pulumi.Input<awsx.ec2.Vpc>;
port: pulumi.Input<number>;
certificate?: aws.acm.Certificate;
healthCheckPath?: pulumi.Input<string>;
};
}

const webServerLoadBalancerNetworkConfig = {
ingress: [{
protocol: 'tcp',
fromPort: 80,
toPort: 80,
cidrBlocks: ['0.0.0.0/0'],
}, {
protocol: 'tcp',
fromPort: 443,
toPort: 443,
cidrBlocks: ['0.0.0.0/0'],
}],
egress: [{
fromPort: 0,
toPort: 0,
protocol: '-1',
cidrBlocks: ['0.0.0.0/0'],
}]
};

const defaults = {
healthCheckPath: '/healthcheck',
};

export class WebServerLoadBalancer extends pulumi.ComponentResource {
name: string;
lb: aws.lb.LoadBalancer;
targetGroup: aws.lb.TargetGroup;
httpListener: aws.lb.Listener;
tlsListener: aws.lb.Listener | undefined;
securityGroup: aws.ec2.SecurityGroup;

constructor(
name: string,
args: WebServerLoadBalancer.Args,
opts: pulumi.ComponentResourceOptions = {}
) {
super('studion:WebServerLoadBalancer', name, args, opts);

this.name = name;
const vpc = pulumi.output(args.vpc);
const { port, certificate, healthCheckPath } = args;

this.securityGroup = this.createLbSecurityGroup(vpc.vpcId);

this.lb = new aws.lb.LoadBalancer(this.name, {
namePrefix: 'lb-',
loadBalancerType: 'application',
subnets: vpc.publicSubnetIds,
securityGroups: [this.securityGroup.id],
internal: false,
ipAddressType: 'ipv4',
tags: { ...commonTags, Name: name },
}, { parent: this });

this.targetGroup = this.createLbTargetGroup(
port,
vpc.vpcId,
healthCheckPath
);
this.httpListener = this.createLbHttpListener(
this.lb,
this.targetGroup,
!!certificate
);
this.tlsListener = certificate &&
this.createLbTlsListener(this.lb, this.targetGroup, certificate);

this.registerOutputs();
}

private createLbTlsListener(
lb: aws.lb.LoadBalancer,
lbTargetGroup: aws.lb.TargetGroup,
certificate: aws.acm.Certificate
): aws.lb.Listener {
return new aws.lb.Listener(`${this.name}-listener-443`, {
loadBalancerArn: lb.arn,
port: 443,
protocol: 'HTTPS',
sslPolicy: 'ELBSecurityPolicy-2016-08',
certificateArn: certificate.arn,
defaultActions: [{
type: 'forward',
targetGroupArn: lbTargetGroup.arn,
}],
tags: commonTags,
}, { parent: this, dependsOn: [certificate] });
}

private createLbHttpListener(
lb: aws.lb.LoadBalancer,
lbTargetGroup: aws.lb.TargetGroup,
redirectToHttps: boolean
): aws.lb.Listener {
const httpsRedirectAction = {
type: 'redirect',
redirect: {
port: '443',
protocol: 'HTTPS',
statusCode: 'HTTP_301',
},
};
const defaultAction = redirectToHttps ? httpsRedirectAction : {
type: 'forward',
targetGroupArn: lbTargetGroup.arn,
};

return new aws.lb.Listener(`${this.name}-listener-80`, {
loadBalancerArn: lb.arn,
port: 80,
defaultActions: [defaultAction],
tags: commonTags,
}, { parent: this });
}

private createLbTargetGroup(
port: pulumi.Input<number>,
vpcId: awsx.ec2.Vpc['vpcId'],
healthCheckPath: pulumi.Input<string> | undefined
): aws.lb.TargetGroup {
return new aws.lb.TargetGroup(`${this.name}-tg`, {
namePrefix: 'lb-tg-',
port,
protocol: 'HTTP',
targetType: 'ip',
vpcId,
healthCheck: {
healthyThreshold: 3,
unhealthyThreshold: 2,
interval: 60,
timeout: 5,
path: healthCheckPath || defaults.healthCheckPath,
},
tags: { ...commonTags, Name: `${this.name}-target-group` },
}, { parent: this, dependsOn: [this.lb] });
}

private createLbSecurityGroup(
vpcId: awsx.ec2.Vpc['vpcId']
): aws.ec2.SecurityGroup {
return new aws.ec2.SecurityGroup(`${this.name}-security-group`, {
...webServerLoadBalancerNetworkConfig,
vpcId,
tags: commonTags,
}, { parent: this });
}
}
Loading