@ossy/deployment-tools 3.0.9 → 3.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/README.md +156 -118
- package/jest.config.js +5 -0
- package/package.json +6 -4
- package/src/config/platform-config.js +16 -16
- package/src/ecs/platform-ecs-services.js +209 -0
- package/src/ecs/platform-ecs-services.spec.js +163 -0
- package/src/edge/platform-edge-domains.js +101 -0
- package/src/edge/platform-edge-domains.spec.js +87 -0
- package/src/index.js +45 -1
- package/src/infrastructure/dns-stack.js +47 -3
- package/src/infrastructure/dns-stack.spec.js +15 -0
- package/src/infrastructure/platform-ci-stack.js +151 -0
- package/src/infrastructure/platform-ecs-stack.js +349 -0
- package/src/infrastructure/platform-edge-stack.js +213 -0
- package/src/infrastructure/platform-secrets-stack.js +103 -0
- package/src/infrastructure/platform-stage.js +32 -5
- package/src/infrastructure/storage-static-stack.js +4 -5
- package/src/secrets/platform-secret-services.js +32 -0
- package/src/secrets/platform-secret-services.spec.js +40 -0
- package/src/secrets/sync-platform-secrets.js +136 -0
- package/src/services/platform-services.js +94 -0
- package/src/services/platform-services.spec.js +80 -0
- package/src/template/platform-template.js +5 -5
- package/src/infrastructure/container-deployment-target/aws-profile.js +0 -25
- package/src/infrastructure/container-deployment-target/caddy.service.js +0 -117
- package/src/infrastructure/container-deployment-target/container-deployment-target.js +0 -222
- package/src/infrastructure/container-deployment-target/container-service.js +0 -162
- package/src/infrastructure/container-deployment-target/index.js +0 -3
- package/src/infrastructure/container-deployment-target/ossy-api.service.js +0 -54
- package/src/infrastructure/container-deployment-target/ossy-runtime.service.js +0 -49
- package/src/infrastructure/container-deployment-target/user-data-commands.js +0 -31
- package/src/infrastructure/deployment-target-stack.js +0 -53
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const { describe, expect, it } = require('@jest/globals')
|
|
2
|
+
const {
|
|
3
|
+
CLOUDFRONT_HOSTED_ZONE_ID,
|
|
4
|
+
CloudFrontDomainTarget,
|
|
5
|
+
} = require('./dns-stack')
|
|
6
|
+
|
|
7
|
+
describe('CloudFrontDomainTarget', () => {
|
|
8
|
+
it('binds a flat AliasRecordTargetConfig for CloudFront', () => {
|
|
9
|
+
const target = new CloudFrontDomainTarget('d111111abcdef8.cloudfront.net')
|
|
10
|
+
expect(target.bind()).toEqual({
|
|
11
|
+
hostedZoneId: CLOUDFRONT_HOSTED_ZONE_ID,
|
|
12
|
+
dnsName: 'd111111abcdef8.cloudfront.net',
|
|
13
|
+
})
|
|
14
|
+
})
|
|
15
|
+
})
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/* eslint-disable no-new */
|
|
2
|
+
const { CfnOutput, Stack, Duration } = require('aws-cdk-lib')
|
|
3
|
+
const {
|
|
4
|
+
Effect,
|
|
5
|
+
OpenIdConnectProvider,
|
|
6
|
+
PolicyStatement,
|
|
7
|
+
Role,
|
|
8
|
+
WebIdentityPrincipal,
|
|
9
|
+
} = require('aws-cdk-lib/aws-iam')
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {Object} PlatformCiStackProps
|
|
13
|
+
* @property {import('../config/platform-config').PlatformConfig} config
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* GitHub Actions → AWS OIDC deploy role for ECS rollouts (#559).
|
|
18
|
+
*
|
|
19
|
+
* Creates an OIDC provider for `token.actions.githubusercontent.com` and a
|
|
20
|
+
* platform-scoped role that can register task definitions and update Fargate
|
|
21
|
+
* services. No long-lived access keys in GitHub.
|
|
22
|
+
*
|
|
23
|
+
* Trust is limited to `ref:refs/heads/main` for `config.githubDeployRepos`
|
|
24
|
+
* (set in platforms.json — add a repo there to allow it to assume this role).
|
|
25
|
+
*
|
|
26
|
+
* Note: AWS allows only one OIDC provider per URL per account. This stack is
|
|
27
|
+
* intended for the primary platform stage until a shared account bootstrap exists.
|
|
28
|
+
*/
|
|
29
|
+
class PlatformCiStack extends Stack {
|
|
30
|
+
/**
|
|
31
|
+
* @param {object} scope
|
|
32
|
+
* @param {string} id
|
|
33
|
+
* @param {PlatformCiStackProps} props
|
|
34
|
+
*/
|
|
35
|
+
constructor(scope, id, props) {
|
|
36
|
+
super(scope, id, props)
|
|
37
|
+
|
|
38
|
+
if (!props?.config?.platformName) {
|
|
39
|
+
throw new Error('[PlatformCiStack] config.platformName is required')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const platformName = props.config.platformName
|
|
43
|
+
const account = props.env?.account ?? props.config.awsAccountId
|
|
44
|
+
const region = props.env?.region ?? props.config.awsRegion
|
|
45
|
+
const clusterName = `${platformName}-platform`
|
|
46
|
+
const roleName = `${platformName}-github-deploy`.slice(0, 64)
|
|
47
|
+
const githubDeployRepos = props.config.githubDeployRepos
|
|
48
|
+
|
|
49
|
+
if (!Array.isArray(githubDeployRepos) || githubDeployRepos.length === 0) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
'[PlatformCiStack] config.githubDeployRepos must be a non-empty array in platforms.json'
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const oidcSubjects = githubDeployRepos.map(repo => {
|
|
56
|
+
if (!repo || typeof repo !== 'string' || !repo.includes('/')) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`[PlatformCiStack] invalid githubDeployRepos entry "${repo}" (expected owner/repo)`
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
return `repo:${repo}:ref:refs/heads/main`
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const provider = new OpenIdConnectProvider(this, 'GithubOidcProvider', {
|
|
65
|
+
url: 'https://token.actions.githubusercontent.com',
|
|
66
|
+
clientIds: ['sts.amazonaws.com'],
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
const deployRole = new Role(this, 'GithubDeployRole', {
|
|
70
|
+
roleName,
|
|
71
|
+
description: `GitHub Actions OIDC deploy role for ${platformName} ECS (#559)`,
|
|
72
|
+
maxSessionDuration: Duration.hours(1),
|
|
73
|
+
assumedBy: new WebIdentityPrincipal(
|
|
74
|
+
provider.openIdConnectProviderArn,
|
|
75
|
+
{
|
|
76
|
+
StringEquals: {
|
|
77
|
+
'token.actions.githubusercontent.com:aud': 'sts.amazonaws.com',
|
|
78
|
+
},
|
|
79
|
+
StringLike: {
|
|
80
|
+
'token.actions.githubusercontent.com:sub': oidcSubjects,
|
|
81
|
+
},
|
|
82
|
+
}
|
|
83
|
+
),
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
const clusterArn = `arn:aws:ecs:${region}:${account}:cluster/${clusterName}`
|
|
87
|
+
const serviceArn = `arn:aws:ecs:${region}:${account}:service/${clusterName}/*`
|
|
88
|
+
const taskArn = `arn:aws:ecs:${region}:${account}:task/${clusterName}/*`
|
|
89
|
+
|
|
90
|
+
deployRole.addToPolicy(new PolicyStatement({
|
|
91
|
+
sid: 'EcsDeployRead',
|
|
92
|
+
effect: Effect.ALLOW,
|
|
93
|
+
actions: [
|
|
94
|
+
'ecs:DescribeClusters',
|
|
95
|
+
'ecs:DescribeServices',
|
|
96
|
+
'ecs:DescribeTasks',
|
|
97
|
+
'ecs:ListTasks',
|
|
98
|
+
],
|
|
99
|
+
resources: [clusterArn, serviceArn, taskArn],
|
|
100
|
+
}))
|
|
101
|
+
|
|
102
|
+
// Task-definition APIs are not cluster-scoped in IAM the same way services are.
|
|
103
|
+
deployRole.addToPolicy(new PolicyStatement({
|
|
104
|
+
sid: 'EcsTaskDefinitions',
|
|
105
|
+
effect: Effect.ALLOW,
|
|
106
|
+
actions: [
|
|
107
|
+
'ecs:DescribeTaskDefinition',
|
|
108
|
+
'ecs:RegisterTaskDefinition',
|
|
109
|
+
],
|
|
110
|
+
resources: ['*'],
|
|
111
|
+
}))
|
|
112
|
+
|
|
113
|
+
deployRole.addToPolicy(new PolicyStatement({
|
|
114
|
+
sid: 'EcsUpdateService',
|
|
115
|
+
effect: Effect.ALLOW,
|
|
116
|
+
actions: ['ecs:UpdateService'],
|
|
117
|
+
resources: [serviceArn],
|
|
118
|
+
}))
|
|
119
|
+
|
|
120
|
+
deployRole.addToPolicy(new PolicyStatement({
|
|
121
|
+
sid: 'PassEcsRoles',
|
|
122
|
+
effect: Effect.ALLOW,
|
|
123
|
+
actions: ['iam:PassRole'],
|
|
124
|
+
resources: [`arn:aws:iam::${account}:role/${platformName}-*`],
|
|
125
|
+
conditions: {
|
|
126
|
+
StringEquals: {
|
|
127
|
+
'iam:PassedToService': 'ecs-tasks.amazonaws.com',
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
}))
|
|
131
|
+
|
|
132
|
+
this.deployRole = deployRole
|
|
133
|
+
this.provider = provider
|
|
134
|
+
|
|
135
|
+
new CfnOutput(this, 'GithubDeployRoleArn', {
|
|
136
|
+
value: deployRole.roleArn,
|
|
137
|
+
description: 'IAM role ARN for GitHub Actions OIDC ECS deploys (#559)',
|
|
138
|
+
exportName: `${platformName}-GithubDeployRoleArn`,
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
new CfnOutput(this, 'GithubDeployRoleName', {
|
|
142
|
+
value: roleName,
|
|
143
|
+
description: 'IAM role name for GitHub Actions OIDC ECS deploys',
|
|
144
|
+
exportName: `${platformName}-GithubDeployRoleName`,
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
module.exports = {
|
|
150
|
+
PlatformCiStack,
|
|
151
|
+
}
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
/* eslint-disable no-new */
|
|
2
|
+
const { CfnOutput, Duration, Stack } = require('aws-cdk-lib')
|
|
3
|
+
const {
|
|
4
|
+
Peer,
|
|
5
|
+
Port,
|
|
6
|
+
SecurityGroup,
|
|
7
|
+
SubnetType,
|
|
8
|
+
Vpc,
|
|
9
|
+
} = require('aws-cdk-lib/aws-ec2')
|
|
10
|
+
const {
|
|
11
|
+
Cluster,
|
|
12
|
+
ContainerImage,
|
|
13
|
+
CpuArchitecture,
|
|
14
|
+
FargateService,
|
|
15
|
+
FargateTaskDefinition,
|
|
16
|
+
LogDrivers,
|
|
17
|
+
OperatingSystemFamily,
|
|
18
|
+
Protocol,
|
|
19
|
+
Secret: EcsSecret,
|
|
20
|
+
} = require('aws-cdk-lib/aws-ecs')
|
|
21
|
+
const {
|
|
22
|
+
ApplicationLoadBalancer,
|
|
23
|
+
ApplicationProtocol,
|
|
24
|
+
ApplicationTargetGroup,
|
|
25
|
+
ListenerCondition,
|
|
26
|
+
TargetType,
|
|
27
|
+
} = require('aws-cdk-lib/aws-elasticloadbalancingv2')
|
|
28
|
+
const {
|
|
29
|
+
ManagedPolicy,
|
|
30
|
+
Role,
|
|
31
|
+
ServicePrincipal,
|
|
32
|
+
} = require('aws-cdk-lib/aws-iam')
|
|
33
|
+
const { LogGroup, RetentionDays } = require('aws-cdk-lib/aws-logs')
|
|
34
|
+
const {
|
|
35
|
+
CONTAINER_PORT,
|
|
36
|
+
HEALTH_PATH,
|
|
37
|
+
containerEnvironment,
|
|
38
|
+
ecsContainerHealthCheckCommand,
|
|
39
|
+
listPlatformEcsServices,
|
|
40
|
+
secretEnvKeysForEcsContainer,
|
|
41
|
+
} = require('../ecs/platform-ecs-services')
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @typedef {Object} PlatformEcsStackProps
|
|
45
|
+
* @property {import('../config/platform-config').PlatformConfig} config
|
|
46
|
+
* @property {import('aws-cdk-lib/aws-s3').IBucket} bucket
|
|
47
|
+
* @property {string} cdnDomainName
|
|
48
|
+
* @property {Record<string, import('aws-cdk-lib/aws-secretsmanager').ISecret>} secrets
|
|
49
|
+
* @property {Record<string, import('aws-cdk-lib/aws-iam').IRole>} taskRoles
|
|
50
|
+
* @property {import('aws-cdk-lib/aws-secretsmanager').ISecret} ghcrPullSecret
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* VPC + ECS Fargate + ALB for platform HTTP services (#557 / epic #553).
|
|
55
|
+
*
|
|
56
|
+
* - ALB in public subnets (HTTP :80; CloudFront origin in #558)
|
|
57
|
+
* - Fargate tasks in private subnets; env from Secrets Manager (#555)
|
|
58
|
+
* - Host-based listener rules; runtime is the default / catch-all target
|
|
59
|
+
* - No `ossy-api` service — website images include the API
|
|
60
|
+
*
|
|
61
|
+
* Production traffic reaches these services via CloudFront → ALB (#558 / #560).
|
|
62
|
+
*/
|
|
63
|
+
class PlatformEcsStack extends Stack {
|
|
64
|
+
/**
|
|
65
|
+
* @param {object} scope
|
|
66
|
+
* @param {string} id
|
|
67
|
+
* @param {PlatformEcsStackProps} props
|
|
68
|
+
*/
|
|
69
|
+
constructor(scope, id, props) {
|
|
70
|
+
super(scope, id, props)
|
|
71
|
+
|
|
72
|
+
if (!props?.config) {
|
|
73
|
+
throw new Error('[PlatformEcsStack] No config provided')
|
|
74
|
+
}
|
|
75
|
+
if (!props.bucket || !props.cdnDomainName) {
|
|
76
|
+
throw new Error('[PlatformEcsStack] bucket and cdnDomainName are required')
|
|
77
|
+
}
|
|
78
|
+
if (!props.secrets || !props.taskRoles || !props.ghcrPullSecret) {
|
|
79
|
+
throw new Error('[PlatformEcsStack] secrets, taskRoles, and ghcrPullSecret are required')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const platformName = props.config.platformName
|
|
83
|
+
const services = listPlatformEcsServices(props.config)
|
|
84
|
+
const envKeys = Object.keys(props.config.env || {})
|
|
85
|
+
|
|
86
|
+
const vpc = new Vpc(this, 'Vpc', {
|
|
87
|
+
maxAzs: 2,
|
|
88
|
+
natGateways: 1,
|
|
89
|
+
subnetConfiguration: [
|
|
90
|
+
{ name: 'public', subnetType: SubnetType.PUBLIC, cidrMask: 24 },
|
|
91
|
+
{ name: 'private', subnetType: SubnetType.PRIVATE_WITH_EGRESS, cidrMask: 24 },
|
|
92
|
+
],
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
const albSecurityGroup = new SecurityGroup(this, 'AlbSecurityGroup', {
|
|
96
|
+
vpc,
|
|
97
|
+
description: `${platformName} ALB ingress`,
|
|
98
|
+
allowAllOutbound: true,
|
|
99
|
+
})
|
|
100
|
+
albSecurityGroup.addIngressRule(
|
|
101
|
+
Peer.anyIpv4(),
|
|
102
|
+
Port.tcp(80),
|
|
103
|
+
'HTTP from Internet / CloudFront origin'
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
const serviceSecurityGroup = new SecurityGroup(this, 'ServiceSecurityGroup', {
|
|
107
|
+
vpc,
|
|
108
|
+
description: `${platformName} ECS tasks`,
|
|
109
|
+
allowAllOutbound: true,
|
|
110
|
+
})
|
|
111
|
+
serviceSecurityGroup.addIngressRule(
|
|
112
|
+
albSecurityGroup,
|
|
113
|
+
Port.tcp(CONTAINER_PORT),
|
|
114
|
+
'ALB to Fargate containers'
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
const cluster = new Cluster(this, 'Cluster', {
|
|
118
|
+
clusterName: `${platformName}-platform`,
|
|
119
|
+
vpc,
|
|
120
|
+
containerInsights: true,
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
const loadBalancer = new ApplicationLoadBalancer(this, 'Alb', {
|
|
124
|
+
vpc,
|
|
125
|
+
internetFacing: true,
|
|
126
|
+
securityGroup: albSecurityGroup,
|
|
127
|
+
vpcSubnets: { subnetType: SubnetType.PUBLIC },
|
|
128
|
+
loadBalancerName: truncateAwsName(`${platformName}-alb`, 32),
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
/** @type {ApplicationTargetGroup|undefined} */
|
|
132
|
+
let defaultTargetGroup
|
|
133
|
+
/** @type {Array<{ idSuffix: string, host: string, targetGroup: ApplicationTargetGroup }>} */
|
|
134
|
+
const hostRules = []
|
|
135
|
+
|
|
136
|
+
this.services = {}
|
|
137
|
+
|
|
138
|
+
for (const service of services) {
|
|
139
|
+
const idSuffix = toConstructId(service.key)
|
|
140
|
+
const secret = props.secrets[service.key]
|
|
141
|
+
const taskRole = props.taskRoles[service.key]
|
|
142
|
+
|
|
143
|
+
if (!secret || !taskRole) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
`[PlatformEcsStack] missing secret or task role for "${service.key}" — deploy platform-secrets first`
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const executionRole = new Role(this, `ExecutionRole${idSuffix}`, {
|
|
150
|
+
roleName: truncateAwsName(`${platformName}-${service.key}-exec`, 64),
|
|
151
|
+
description: `ECS execution role for ${service.secretName}`,
|
|
152
|
+
assumedBy: new ServicePrincipal('ecs-tasks.amazonaws.com'),
|
|
153
|
+
managedPolicies: [
|
|
154
|
+
ManagedPolicy.fromAwsManagedPolicyName(
|
|
155
|
+
'service-role/AmazonECSTaskExecutionRolePolicy'
|
|
156
|
+
),
|
|
157
|
+
],
|
|
158
|
+
})
|
|
159
|
+
// Task-def secret injection + GHCR pull use the execution role
|
|
160
|
+
secret.grantRead(executionRole)
|
|
161
|
+
props.ghcrPullSecret.grantRead(executionRole)
|
|
162
|
+
|
|
163
|
+
const logGroup = new LogGroup(this, `Logs${idSuffix}`, {
|
|
164
|
+
retention: RetentionDays.ONE_MONTH,
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
const taskDefinition = new FargateTaskDefinition(this, `TaskDef${idSuffix}`, {
|
|
168
|
+
family: `${platformName}-${service.key}`,
|
|
169
|
+
cpu: 512,
|
|
170
|
+
memoryLimitMiB: 1024,
|
|
171
|
+
runtimePlatform: {
|
|
172
|
+
cpuArchitecture: CpuArchitecture.X86_64,
|
|
173
|
+
operatingSystemFamily: OperatingSystemFamily.LINUX,
|
|
174
|
+
},
|
|
175
|
+
taskRole,
|
|
176
|
+
executionRole,
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
// Secrets must not redefine plain `environment` keys (PORT, OSSY_SERVICE_NAME, …).
|
|
180
|
+
const containerSecrets = Object.fromEntries(
|
|
181
|
+
secretEnvKeysForEcsContainer(envKeys).map(key => [
|
|
182
|
+
key,
|
|
183
|
+
EcsSecret.fromSecretsManager(secret, key),
|
|
184
|
+
])
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
const container = taskDefinition.addContainer(`Container${idSuffix}`, {
|
|
188
|
+
containerName: service.key,
|
|
189
|
+
image: ContainerImage.fromRegistry(service.image, {
|
|
190
|
+
credentials: props.ghcrPullSecret,
|
|
191
|
+
}),
|
|
192
|
+
logging: LogDrivers.awsLogs({
|
|
193
|
+
streamPrefix: service.key,
|
|
194
|
+
logGroup,
|
|
195
|
+
}),
|
|
196
|
+
// Labels `/health` via OSSY_SERVICE_NAME; keys stay in CONTAINER_ENVIRONMENT_KEYS.
|
|
197
|
+
environment: containerEnvironment({
|
|
198
|
+
serviceKey: service.key,
|
|
199
|
+
containerPort: service.containerPort,
|
|
200
|
+
mediaRepository: props.bucket.bucketName,
|
|
201
|
+
mediaCdnDomainName: `https://${props.cdnDomainName}`,
|
|
202
|
+
}),
|
|
203
|
+
secrets: containerSecrets,
|
|
204
|
+
// Node fetch → GET /health (no curl in node:*-slim; no image-local script required).
|
|
205
|
+
healthCheck: {
|
|
206
|
+
command: ecsContainerHealthCheckCommand({
|
|
207
|
+
containerPort: service.containerPort,
|
|
208
|
+
}),
|
|
209
|
+
interval: Duration.seconds(30),
|
|
210
|
+
timeout: Duration.seconds(5),
|
|
211
|
+
retries: 3,
|
|
212
|
+
startPeriod: Duration.seconds(60),
|
|
213
|
+
},
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
container.addPortMappings({
|
|
217
|
+
containerPort: service.containerPort,
|
|
218
|
+
protocol: Protocol.TCP,
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
const fargateService = new FargateService(this, `Service${idSuffix}`, {
|
|
222
|
+
serviceName: truncateAwsName(`${platformName}-${service.key}`, 255),
|
|
223
|
+
cluster,
|
|
224
|
+
taskDefinition,
|
|
225
|
+
desiredCount: 1,
|
|
226
|
+
assignPublicIp: false,
|
|
227
|
+
securityGroups: [serviceSecurityGroup],
|
|
228
|
+
vpcSubnets: { subnetType: SubnetType.PRIVATE_WITH_EGRESS },
|
|
229
|
+
circuitBreaker: { rollback: true },
|
|
230
|
+
healthCheckGracePeriod: Duration.seconds(60),
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
const targetGroup = new ApplicationTargetGroup(this, `TargetGroup${idSuffix}`, {
|
|
234
|
+
vpc,
|
|
235
|
+
port: service.containerPort,
|
|
236
|
+
protocol: ApplicationProtocol.HTTP,
|
|
237
|
+
targetType: TargetType.IP,
|
|
238
|
+
targetGroupName: truncateAwsName(`${platformName}-${service.key}`, 32),
|
|
239
|
+
healthCheck: {
|
|
240
|
+
path: HEALTH_PATH,
|
|
241
|
+
healthyHttpCodes: '200',
|
|
242
|
+
interval: Duration.seconds(30),
|
|
243
|
+
timeout: Duration.seconds(5),
|
|
244
|
+
healthyThresholdCount: 2,
|
|
245
|
+
unhealthyThresholdCount: 3,
|
|
246
|
+
},
|
|
247
|
+
deregistrationDelay: Duration.seconds(30),
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
fargateService.attachToApplicationTargetGroup(targetGroup)
|
|
251
|
+
|
|
252
|
+
if (service.isDefault) {
|
|
253
|
+
defaultTargetGroup = targetGroup
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
for (const host of service.hosts) {
|
|
257
|
+
hostRules.push({ idSuffix, host, targetGroup })
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
this.services[service.key] = {
|
|
261
|
+
service: fargateService,
|
|
262
|
+
targetGroup,
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
new CfnOutput(this, `ServiceName${idSuffix}`, {
|
|
266
|
+
value: fargateService.serviceName,
|
|
267
|
+
description: `ECS service name for ${service.secretName}`,
|
|
268
|
+
exportName: `${platformName}-EcsService-${service.key}`,
|
|
269
|
+
})
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (!defaultTargetGroup) {
|
|
273
|
+
throw new Error('[PlatformEcsStack] runtime default target group was not created')
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const listener = loadBalancer.addListener('HttpListener', {
|
|
277
|
+
port: 80,
|
|
278
|
+
protocol: ApplicationProtocol.HTTP,
|
|
279
|
+
defaultTargetGroups: [defaultTargetGroup],
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
// Lower priority number = evaluated first. Specific hosts before wildcards
|
|
283
|
+
// so `api.ossy.se` is not swallowed by `*.ossy.se`.
|
|
284
|
+
const orderedHostRules = [...hostRules].sort((a, b) => {
|
|
285
|
+
const aWild = a.host.includes('*') ? 1 : 0
|
|
286
|
+
const bWild = b.host.includes('*') ? 1 : 0
|
|
287
|
+
return aWild - bWild
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
let rulePriority = 10
|
|
291
|
+
for (const rule of orderedHostRules) {
|
|
292
|
+
listener.addTargetGroups(
|
|
293
|
+
`Rule${rule.idSuffix}${toConstructId(rule.host)}`,
|
|
294
|
+
{
|
|
295
|
+
priority: rulePriority,
|
|
296
|
+
conditions: [ListenerCondition.hostHeaders([rule.host])],
|
|
297
|
+
targetGroups: [rule.targetGroup],
|
|
298
|
+
}
|
|
299
|
+
)
|
|
300
|
+
rulePriority += 1
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
this.vpc = vpc
|
|
304
|
+
this.cluster = cluster
|
|
305
|
+
this.loadBalancer = loadBalancer
|
|
306
|
+
this.listener = listener
|
|
307
|
+
|
|
308
|
+
new CfnOutput(this, 'LoadBalancerDnsName', {
|
|
309
|
+
value: loadBalancer.loadBalancerDnsName,
|
|
310
|
+
description: 'ALB DNS name (CloudFront origin for platform-edge)',
|
|
311
|
+
exportName: `${platformName}-AlbDnsName`,
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
new CfnOutput(this, 'LoadBalancerArn', {
|
|
315
|
+
value: loadBalancer.loadBalancerArn,
|
|
316
|
+
description: 'ALB ARN',
|
|
317
|
+
exportName: `${platformName}-AlbArn`,
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
new CfnOutput(this, 'ClusterName', {
|
|
321
|
+
value: cluster.clusterName,
|
|
322
|
+
description: 'ECS cluster name',
|
|
323
|
+
exportName: `${platformName}-EcsClusterName`,
|
|
324
|
+
})
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* CloudFormation-safe construct id fragment.
|
|
330
|
+
* @param {string} key
|
|
331
|
+
* @returns {string}
|
|
332
|
+
*/
|
|
333
|
+
function toConstructId(key) {
|
|
334
|
+
return key.replace(/[^A-Za-z0-9]/g, '')
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* AWS name length limits (ALB, IAM, ECS, target groups).
|
|
339
|
+
* @param {string} name
|
|
340
|
+
* @param {number} max
|
|
341
|
+
* @returns {string}
|
|
342
|
+
*/
|
|
343
|
+
function truncateAwsName(name, max) {
|
|
344
|
+
return name.length <= max ? name : name.slice(0, max)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
module.exports = {
|
|
348
|
+
PlatformEcsStack,
|
|
349
|
+
}
|