@ossy/deployment-tools 3.0.9 → 3.1.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.
Files changed (33) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +154 -118
  3. package/jest.config.js +5 -0
  4. package/package.json +6 -4
  5. package/src/config/platform-config.js +16 -16
  6. package/src/ecs/platform-ecs-services.js +111 -0
  7. package/src/ecs/platform-ecs-services.spec.js +77 -0
  8. package/src/edge/platform-edge-domains.js +101 -0
  9. package/src/edge/platform-edge-domains.spec.js +87 -0
  10. package/src/index.js +33 -1
  11. package/src/infrastructure/dns-stack.js +47 -3
  12. package/src/infrastructure/dns-stack.spec.js +15 -0
  13. package/src/infrastructure/platform-ci-stack.js +151 -0
  14. package/src/infrastructure/platform-ecs-stack.js +326 -0
  15. package/src/infrastructure/platform-edge-stack.js +213 -0
  16. package/src/infrastructure/platform-secrets-stack.js +103 -0
  17. package/src/infrastructure/platform-stage.js +32 -5
  18. package/src/infrastructure/storage-static-stack.js +4 -5
  19. package/src/secrets/platform-secret-services.js +32 -0
  20. package/src/secrets/platform-secret-services.spec.js +40 -0
  21. package/src/secrets/sync-platform-secrets.js +136 -0
  22. package/src/services/platform-services.js +94 -0
  23. package/src/services/platform-services.spec.js +80 -0
  24. package/src/template/platform-template.js +5 -5
  25. package/src/infrastructure/container-deployment-target/aws-profile.js +0 -25
  26. package/src/infrastructure/container-deployment-target/caddy.service.js +0 -117
  27. package/src/infrastructure/container-deployment-target/container-deployment-target.js +0 -222
  28. package/src/infrastructure/container-deployment-target/container-service.js +0 -162
  29. package/src/infrastructure/container-deployment-target/index.js +0 -3
  30. package/src/infrastructure/container-deployment-target/ossy-api.service.js +0 -54
  31. package/src/infrastructure/container-deployment-target/ossy-runtime.service.js +0 -49
  32. package/src/infrastructure/container-deployment-target/user-data-commands.js +0 -31
  33. package/src/infrastructure/deployment-target-stack.js +0 -53
@@ -0,0 +1,326 @@
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 { listPlatformEcsServices } = require('../ecs/platform-ecs-services')
35
+
36
+ /**
37
+ * @typedef {Object} PlatformEcsStackProps
38
+ * @property {import('../config/platform-config').PlatformConfig} config
39
+ * @property {import('aws-cdk-lib/aws-s3').IBucket} bucket
40
+ * @property {string} cdnDomainName
41
+ * @property {Record<string, import('aws-cdk-lib/aws-secretsmanager').ISecret>} secrets
42
+ * @property {Record<string, import('aws-cdk-lib/aws-iam').IRole>} taskRoles
43
+ * @property {import('aws-cdk-lib/aws-secretsmanager').ISecret} ghcrPullSecret
44
+ */
45
+
46
+ /**
47
+ * VPC + ECS Fargate + ALB for platform HTTP services (#557 / epic #553).
48
+ *
49
+ * - ALB in public subnets (HTTP :80; CloudFront origin in #558)
50
+ * - Fargate tasks in private subnets; env from Secrets Manager (#555)
51
+ * - Host-based listener rules; runtime is the default / catch-all target
52
+ * - No `ossy-api` service — website images include the API
53
+ *
54
+ * Production traffic reaches these services via CloudFront → ALB (#558 / #560).
55
+ */
56
+ class PlatformEcsStack extends Stack {
57
+ /**
58
+ * @param {object} scope
59
+ * @param {string} id
60
+ * @param {PlatformEcsStackProps} props
61
+ */
62
+ constructor(scope, id, props) {
63
+ super(scope, id, props)
64
+
65
+ if (!props?.config) {
66
+ throw new Error('[PlatformEcsStack] No config provided')
67
+ }
68
+ if (!props.bucket || !props.cdnDomainName) {
69
+ throw new Error('[PlatformEcsStack] bucket and cdnDomainName are required')
70
+ }
71
+ if (!props.secrets || !props.taskRoles || !props.ghcrPullSecret) {
72
+ throw new Error('[PlatformEcsStack] secrets, taskRoles, and ghcrPullSecret are required')
73
+ }
74
+
75
+ const platformName = props.config.platformName
76
+ const services = listPlatformEcsServices(props.config)
77
+ const envKeys = Object.keys(props.config.env || {})
78
+
79
+ const vpc = new Vpc(this, 'Vpc', {
80
+ maxAzs: 2,
81
+ natGateways: 1,
82
+ subnetConfiguration: [
83
+ { name: 'public', subnetType: SubnetType.PUBLIC, cidrMask: 24 },
84
+ { name: 'private', subnetType: SubnetType.PRIVATE_WITH_EGRESS, cidrMask: 24 },
85
+ ],
86
+ })
87
+
88
+ const albSecurityGroup = new SecurityGroup(this, 'AlbSecurityGroup', {
89
+ vpc,
90
+ description: `${platformName} ALB ingress`,
91
+ allowAllOutbound: true,
92
+ })
93
+ albSecurityGroup.addIngressRule(
94
+ Peer.anyIpv4(),
95
+ Port.tcp(80),
96
+ 'HTTP from Internet / CloudFront origin'
97
+ )
98
+
99
+ const serviceSecurityGroup = new SecurityGroup(this, 'ServiceSecurityGroup', {
100
+ vpc,
101
+ description: `${platformName} ECS tasks`,
102
+ allowAllOutbound: true,
103
+ })
104
+ serviceSecurityGroup.addIngressRule(
105
+ albSecurityGroup,
106
+ Port.tcp(3000),
107
+ 'ALB to Fargate containers'
108
+ )
109
+
110
+ const cluster = new Cluster(this, 'Cluster', {
111
+ clusterName: `${platformName}-platform`,
112
+ vpc,
113
+ containerInsights: true,
114
+ })
115
+
116
+ const loadBalancer = new ApplicationLoadBalancer(this, 'Alb', {
117
+ vpc,
118
+ internetFacing: true,
119
+ securityGroup: albSecurityGroup,
120
+ vpcSubnets: { subnetType: SubnetType.PUBLIC },
121
+ loadBalancerName: truncateAwsName(`${platformName}-alb`, 32),
122
+ })
123
+
124
+ /** @type {ApplicationTargetGroup|undefined} */
125
+ let defaultTargetGroup
126
+ /** @type {Array<{ idSuffix: string, host: string, targetGroup: ApplicationTargetGroup }>} */
127
+ const hostRules = []
128
+
129
+ this.services = {}
130
+
131
+ for (const service of services) {
132
+ const idSuffix = toConstructId(service.key)
133
+ const secret = props.secrets[service.key]
134
+ const taskRole = props.taskRoles[service.key]
135
+
136
+ if (!secret || !taskRole) {
137
+ throw new Error(
138
+ `[PlatformEcsStack] missing secret or task role for "${service.key}" — deploy platform-secrets first`
139
+ )
140
+ }
141
+
142
+ const executionRole = new Role(this, `ExecutionRole${idSuffix}`, {
143
+ roleName: truncateAwsName(`${platformName}-${service.key}-exec`, 64),
144
+ description: `ECS execution role for ${service.secretName}`,
145
+ assumedBy: new ServicePrincipal('ecs-tasks.amazonaws.com'),
146
+ managedPolicies: [
147
+ ManagedPolicy.fromAwsManagedPolicyName(
148
+ 'service-role/AmazonECSTaskExecutionRolePolicy'
149
+ ),
150
+ ],
151
+ })
152
+ // Task-def secret injection + GHCR pull use the execution role
153
+ secret.grantRead(executionRole)
154
+ props.ghcrPullSecret.grantRead(executionRole)
155
+
156
+ const logGroup = new LogGroup(this, `Logs${idSuffix}`, {
157
+ retention: RetentionDays.ONE_MONTH,
158
+ })
159
+
160
+ const taskDefinition = new FargateTaskDefinition(this, `TaskDef${idSuffix}`, {
161
+ family: `${platformName}-${service.key}`,
162
+ cpu: 512,
163
+ memoryLimitMiB: 1024,
164
+ runtimePlatform: {
165
+ cpuArchitecture: CpuArchitecture.X86_64,
166
+ operatingSystemFamily: OperatingSystemFamily.LINUX,
167
+ },
168
+ taskRole,
169
+ executionRole,
170
+ })
171
+
172
+ const containerSecrets = Object.fromEntries(
173
+ envKeys.map(key => [key, EcsSecret.fromSecretsManager(secret, key)])
174
+ )
175
+
176
+ const container = taskDefinition.addContainer(`Container${idSuffix}`, {
177
+ containerName: service.key,
178
+ image: ContainerImage.fromRegistry(service.image, {
179
+ credentials: props.ghcrPullSecret,
180
+ }),
181
+ logging: LogDrivers.awsLogs({
182
+ streamPrefix: service.key,
183
+ logGroup,
184
+ }),
185
+ environment: {
186
+ NODE_ENV: 'production',
187
+ PORT: String(service.containerPort),
188
+ MEDIA_REPOSITORY: props.bucket.bucketName,
189
+ MEDIA_CDN_DOMAIN_NAME: `https://${props.cdnDomainName}`,
190
+ },
191
+ secrets: containerSecrets,
192
+ })
193
+
194
+ container.addPortMappings({
195
+ containerPort: service.containerPort,
196
+ protocol: Protocol.TCP,
197
+ })
198
+
199
+ const fargateService = new FargateService(this, `Service${idSuffix}`, {
200
+ serviceName: truncateAwsName(`${platformName}-${service.key}`, 255),
201
+ cluster,
202
+ taskDefinition,
203
+ desiredCount: 1,
204
+ assignPublicIp: false,
205
+ securityGroups: [serviceSecurityGroup],
206
+ vpcSubnets: { subnetType: SubnetType.PRIVATE_WITH_EGRESS },
207
+ circuitBreaker: { rollback: true },
208
+ })
209
+
210
+ const targetGroup = new ApplicationTargetGroup(this, `TargetGroup${idSuffix}`, {
211
+ vpc,
212
+ port: service.containerPort,
213
+ protocol: ApplicationProtocol.HTTP,
214
+ targetType: TargetType.IP,
215
+ targetGroupName: truncateAwsName(`${platformName}-${service.key}`, 32),
216
+ healthCheck: {
217
+ path: '/health',
218
+ healthyHttpCodes: '200',
219
+ interval: Duration.seconds(30),
220
+ timeout: Duration.seconds(5),
221
+ healthyThresholdCount: 2,
222
+ unhealthyThresholdCount: 3,
223
+ },
224
+ deregistrationDelay: Duration.seconds(30),
225
+ })
226
+
227
+ fargateService.attachToApplicationTargetGroup(targetGroup)
228
+
229
+ if (service.isDefault) {
230
+ defaultTargetGroup = targetGroup
231
+ }
232
+
233
+ for (const host of service.hosts) {
234
+ hostRules.push({ idSuffix, host, targetGroup })
235
+ }
236
+
237
+ this.services[service.key] = {
238
+ service: fargateService,
239
+ targetGroup,
240
+ }
241
+
242
+ new CfnOutput(this, `ServiceName${idSuffix}`, {
243
+ value: fargateService.serviceName,
244
+ description: `ECS service name for ${service.secretName}`,
245
+ exportName: `${platformName}-EcsService-${service.key}`,
246
+ })
247
+ }
248
+
249
+ if (!defaultTargetGroup) {
250
+ throw new Error('[PlatformEcsStack] runtime default target group was not created')
251
+ }
252
+
253
+ const listener = loadBalancer.addListener('HttpListener', {
254
+ port: 80,
255
+ protocol: ApplicationProtocol.HTTP,
256
+ defaultTargetGroups: [defaultTargetGroup],
257
+ })
258
+
259
+ // Lower priority number = evaluated first. Specific hosts before wildcards
260
+ // so `api.ossy.se` is not swallowed by `*.ossy.se`.
261
+ const orderedHostRules = [...hostRules].sort((a, b) => {
262
+ const aWild = a.host.includes('*') ? 1 : 0
263
+ const bWild = b.host.includes('*') ? 1 : 0
264
+ return aWild - bWild
265
+ })
266
+
267
+ let rulePriority = 10
268
+ for (const rule of orderedHostRules) {
269
+ listener.addTargetGroups(
270
+ `Rule${rule.idSuffix}${toConstructId(rule.host)}`,
271
+ {
272
+ priority: rulePriority,
273
+ conditions: [ListenerCondition.hostHeaders([rule.host])],
274
+ targetGroups: [rule.targetGroup],
275
+ }
276
+ )
277
+ rulePriority += 1
278
+ }
279
+
280
+ this.vpc = vpc
281
+ this.cluster = cluster
282
+ this.loadBalancer = loadBalancer
283
+ this.listener = listener
284
+
285
+ new CfnOutput(this, 'LoadBalancerDnsName', {
286
+ value: loadBalancer.loadBalancerDnsName,
287
+ description: 'ALB DNS name (CloudFront origin for platform-edge)',
288
+ exportName: `${platformName}-AlbDnsName`,
289
+ })
290
+
291
+ new CfnOutput(this, 'LoadBalancerArn', {
292
+ value: loadBalancer.loadBalancerArn,
293
+ description: 'ALB ARN',
294
+ exportName: `${platformName}-AlbArn`,
295
+ })
296
+
297
+ new CfnOutput(this, 'ClusterName', {
298
+ value: cluster.clusterName,
299
+ description: 'ECS cluster name',
300
+ exportName: `${platformName}-EcsClusterName`,
301
+ })
302
+ }
303
+ }
304
+
305
+ /**
306
+ * CloudFormation-safe construct id fragment.
307
+ * @param {string} key
308
+ * @returns {string}
309
+ */
310
+ function toConstructId(key) {
311
+ return key.replace(/[^A-Za-z0-9]/g, '')
312
+ }
313
+
314
+ /**
315
+ * AWS name length limits (ALB, IAM, ECS, target groups).
316
+ * @param {string} name
317
+ * @param {number} max
318
+ * @returns {string}
319
+ */
320
+ function truncateAwsName(name, max) {
321
+ return name.length <= max ? name : name.slice(0, max)
322
+ }
323
+
324
+ module.exports = {
325
+ PlatformEcsStack,
326
+ }
@@ -0,0 +1,213 @@
1
+ /* eslint-disable no-new */
2
+ const { CfnOutput, Stack, Duration } = require('aws-cdk-lib')
3
+ const {
4
+ Certificate,
5
+ CertificateValidation,
6
+ } = require('aws-cdk-lib/aws-certificatemanager')
7
+ const {
8
+ AllowedMethods,
9
+ CachePolicy,
10
+ CachedMethods,
11
+ Distribution,
12
+ OriginProtocolPolicy,
13
+ OriginRequestPolicy,
14
+ PriceClass,
15
+ ViewerProtocolPolicy,
16
+ } = require('aws-cdk-lib/aws-cloudfront')
17
+ const { HttpOrigin } = require('aws-cdk-lib/aws-cloudfront-origins')
18
+ const { HostedZone } = require('aws-cdk-lib/aws-route53')
19
+ const { CfnWebACL } = require('aws-cdk-lib/aws-wafv2')
20
+ const { planEdgeDomains } = require('../edge/platform-edge-domains')
21
+
22
+ /** CloudFront ACM + WAFv2 CloudFront-scope resources must live in us-east-1. */
23
+ const CLOUDFRONT_REGION = 'us-east-1'
24
+
25
+ /**
26
+ * @typedef {Object} PlatformEdgeStackProps
27
+ * @property {import('../config/platform-config').PlatformConfig} config
28
+ * @property {string} albDnsName - DNS name of the platform ALB (CloudFront origin)
29
+ */
30
+
31
+ /**
32
+ * CloudFront + WAF + ACM in front of the platform ALB (#558 / epic #553).
33
+ *
34
+ * - Separate distribution from the `/media` CDN in `storage-static`
35
+ * - Forwards viewer `Host` (and cookies/query) so ALB host rules keep working
36
+ * - ACM certificate in us-east-1 covering known platform domains
37
+ * - WAFv2 web ACL (CLOUDFRONT scope) associated with the distribution
38
+ *
39
+ * Deploy this stack with `env.region = us-east-1`. DNS alias cutover is wired
40
+ * from `DnsStack` using the distribution domain name output.
41
+ */
42
+ class PlatformEdgeStack extends Stack {
43
+ /**
44
+ * @param {object} scope
45
+ * @param {string} id
46
+ * @param {PlatformEdgeStackProps} props
47
+ */
48
+ constructor(scope, id, props) {
49
+ super(scope, id, props)
50
+
51
+ if (!props?.config) {
52
+ throw new Error('[PlatformEdgeStack] No config provided')
53
+ }
54
+ if (!props.albDnsName) {
55
+ throw new Error('[PlatformEdgeStack] albDnsName is required')
56
+ }
57
+ if (Stack.of(this).region !== CLOUDFRONT_REGION) {
58
+ throw new Error(
59
+ `[PlatformEdgeStack] must be deployed in ${CLOUDFRONT_REGION} (got ${Stack.of(this).region})`
60
+ )
61
+ }
62
+
63
+ const platformName = props.config.platformName
64
+ const plan = planEdgeDomains(props.config)
65
+
66
+ /** @type {Map<string, import('aws-cdk-lib/aws-route53').IHostedZone>} */
67
+ const zonesByRoot = new Map(
68
+ [...new Set(plan.validationDomainToRoot.values())].map(rootDomain => [
69
+ rootDomain,
70
+ HostedZone.fromLookup(this, `${toConstructId(rootDomain)}Zone`, {
71
+ domainName: rootDomain,
72
+ }),
73
+ ])
74
+ )
75
+
76
+ /** @type {Record<string, import('aws-cdk-lib/aws-route53').IHostedZone>} */
77
+ const validationZones = Object.fromEntries(
78
+ [...plan.validationDomainToRoot.entries()].map(([domain, root]) => [
79
+ domain,
80
+ zonesByRoot.get(root),
81
+ ])
82
+ )
83
+
84
+ const certificate = new Certificate(this, 'ViewerCertificate', {
85
+ domainName: plan.primaryDomain,
86
+ subjectAlternativeNames: plan.subjectAlternativeNames,
87
+ validation: CertificateValidation.fromDnsMultiZone(validationZones),
88
+ })
89
+
90
+ const webAcl = new CfnWebACL(this, 'WebAcl', {
91
+ name: truncateName(`${platformName}-app-edge`, 128),
92
+ description: `WAF for ${platformName} CloudFront app distribution`,
93
+ scope: 'CLOUDFRONT',
94
+ defaultAction: { allow: {} },
95
+ visibilityConfig: {
96
+ cloudWatchMetricsEnabled: true,
97
+ metricName: truncateName(`${platformName}AppEdge`, 128),
98
+ sampledRequestsEnabled: true,
99
+ },
100
+ rules: [
101
+ {
102
+ name: 'AWSManagedRulesCommonRuleSet',
103
+ priority: 1,
104
+ overrideAction: { none: {} },
105
+ statement: {
106
+ managedRuleGroupStatement: {
107
+ vendorName: 'AWS',
108
+ name: 'AWSManagedRulesCommonRuleSet',
109
+ },
110
+ },
111
+ visibilityConfig: {
112
+ cloudWatchMetricsEnabled: true,
113
+ metricName: 'AWSManagedRulesCommonRuleSet',
114
+ sampledRequestsEnabled: true,
115
+ },
116
+ },
117
+ {
118
+ name: 'AWSManagedRulesKnownBadInputsRuleSet',
119
+ priority: 2,
120
+ overrideAction: { none: {} },
121
+ statement: {
122
+ managedRuleGroupStatement: {
123
+ vendorName: 'AWS',
124
+ name: 'AWSManagedRulesKnownBadInputsRuleSet',
125
+ },
126
+ },
127
+ visibilityConfig: {
128
+ cloudWatchMetricsEnabled: true,
129
+ metricName: 'AWSManagedRulesKnownBadInputsRuleSet',
130
+ sampledRequestsEnabled: true,
131
+ },
132
+ },
133
+ ],
134
+ })
135
+
136
+ const origin = new HttpOrigin(props.albDnsName, {
137
+ protocolPolicy: OriginProtocolPolicy.HTTP_ONLY,
138
+ readTimeout: Duration.seconds(60),
139
+ keepaliveTimeout: Duration.seconds(60),
140
+ })
141
+
142
+ // CloudFront associates WAF via webAclId (not CfnWebACLAssociation).
143
+ const distribution = new Distribution(this, 'AppDistribution', {
144
+ comment: `${platformName} app edge (ALB origin) — not the /media CDN`,
145
+ domainNames: plan.aliases,
146
+ certificate,
147
+ priceClass: PriceClass.PRICE_CLASS_100,
148
+ webAclId: webAcl.attrArn,
149
+ defaultBehavior: {
150
+ origin,
151
+ viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
152
+ allowedMethods: AllowedMethods.ALLOW_ALL,
153
+ cachedMethods: CachedMethods.CACHE_GET_HEAD_OPTIONS,
154
+ // Dynamic app traffic — do not cache at the edge by default.
155
+ cachePolicy: CachePolicy.CACHING_DISABLED,
156
+ // Forward Host so ALB listener host rules match viewer hostnames.
157
+ originRequestPolicy: OriginRequestPolicy.ALL_VIEWER,
158
+ },
159
+ })
160
+
161
+ this.distribution = distribution
162
+ this.certificate = certificate
163
+ this.domainName = distribution.distributionDomainName
164
+ this.distributionId = distribution.distributionId
165
+ this.aliases = plan.aliases
166
+
167
+ new CfnOutput(this, 'DistributionDomainName', {
168
+ value: distribution.distributionDomainName,
169
+ description: 'CloudFront domain for app traffic (Route53 alias target)',
170
+ exportName: `${platformName}-AppCloudFrontDomainName`,
171
+ })
172
+
173
+ new CfnOutput(this, 'DistributionId', {
174
+ value: distribution.distributionId,
175
+ description: 'CloudFront distribution id for app traffic',
176
+ exportName: `${platformName}-AppCloudFrontDistributionId`,
177
+ })
178
+
179
+ new CfnOutput(this, 'ViewerCertificateArn', {
180
+ value: certificate.certificateArn,
181
+ description: 'ACM certificate ARN (us-east-1) for CloudFront aliases',
182
+ exportName: `${platformName}-AppCloudFrontCertificateArn`,
183
+ })
184
+
185
+ new CfnOutput(this, 'WebAclArn', {
186
+ value: webAcl.attrArn,
187
+ description: 'WAFv2 web ACL ARN associated with the app distribution',
188
+ exportName: `${platformName}-AppCloudFrontWebAclArn`,
189
+ })
190
+ }
191
+ }
192
+
193
+ /**
194
+ * @param {string} key
195
+ * @returns {string}
196
+ */
197
+ function toConstructId(key) {
198
+ return key.replace(/[^A-Za-z0-9]/g, '')
199
+ }
200
+
201
+ /**
202
+ * @param {string} name
203
+ * @param {number} max
204
+ * @returns {string}
205
+ */
206
+ function truncateName(name, max) {
207
+ return name.length <= max ? name : name.slice(0, max)
208
+ }
209
+
210
+ module.exports = {
211
+ CLOUDFRONT_REGION,
212
+ PlatformEdgeStack,
213
+ }
@@ -0,0 +1,103 @@
1
+ /* eslint-disable no-new */
2
+ const { CfnOutput, RemovalPolicy, Stack } = require('aws-cdk-lib')
3
+ const { Role, ServicePrincipal } = require('aws-cdk-lib/aws-iam')
4
+ const { Secret } = require('aws-cdk-lib/aws-secretsmanager')
5
+ const { listPlatformSecretServices } = require('../secrets/platform-secret-services')
6
+
7
+ /**
8
+ * PlatformSecretsStackProps
9
+ * @typedef {Object} PlatformSecretsStackProps
10
+ * @property {import('../config/platform-config').PlatformConfig} config - platform config
11
+ */
12
+
13
+ /**
14
+ * Creates one Secrets Manager secret per platform service (runtime + HTTP services[])
15
+ * and an ECS task role that can read that secret. Values are not set here — sync them
16
+ * from `platforms.json` with `npm run sync-secrets` after deploy (see #555).
17
+ *
18
+ * Also creates `{platform}/ghcr-pull` for private GHCR image pulls from ECS (#557).
19
+ * Put `{"username":"<github>","password":"<pat with read:packages>"}` into that secret
20
+ * before tasks can start (not synced from platforms.json).
21
+ *
22
+ * Long-lived secrets + task roles for ECS; independent of edge/DNS stack lifecycle.
23
+ */
24
+ class PlatformSecretsStack extends Stack {
25
+ /**
26
+ * @param {object} scope
27
+ * @param {string} id
28
+ * @param {PlatformSecretsStackProps} props
29
+ */
30
+ constructor(scope, id, props) {
31
+ super(scope, id, props)
32
+
33
+ if (!props?.config) {
34
+ throw new Error('[PlatformSecretsStack] No config provided')
35
+ }
36
+
37
+ const platformName = props.config.platformName
38
+ /** @type {Record<string, import('aws-cdk-lib/aws-secretsmanager').ISecret>} */
39
+ this.secrets = {}
40
+ /** @type {Record<string, import('aws-cdk-lib/aws-iam').IRole>} */
41
+ this.taskRoles = {}
42
+
43
+ this.ghcrPullSecret = new Secret(this, 'GhcrPull', {
44
+ secretName: `${platformName}/ghcr-pull`,
45
+ description: `GHCR docker pull credentials for ${platformName} ECS (username + password)`,
46
+ removalPolicy: RemovalPolicy.RETAIN,
47
+ })
48
+
49
+ new CfnOutput(this, 'GhcrPullSecretArn', {
50
+ value: this.ghcrPullSecret.secretArn,
51
+ description: `Secrets Manager ARN for ${platformName}/ghcr-pull`,
52
+ exportName: `${platformName}-SecretArn-ghcr-pull`,
53
+ })
54
+
55
+ const services = listPlatformSecretServices(props.config)
56
+
57
+ for (const service of services) {
58
+ const idSuffix = toConstructId(service.key)
59
+
60
+ const secret = new Secret(this, `Secret${idSuffix}`, {
61
+ secretName: service.secretName,
62
+ description: `Platform secrets for ${service.source} (${platformName})`,
63
+ removalPolicy: RemovalPolicy.RETAIN,
64
+ })
65
+
66
+ // ECS Fargate task role (#557). Grant read only for this service secret.
67
+ const taskRole = new Role(this, `TaskRole${idSuffix}`, {
68
+ roleName: `${platformName}-${service.key}-task`,
69
+ description: `ECS task role for ${service.secretName} (read Secrets Manager)`,
70
+ assumedBy: new ServicePrincipal('ecs-tasks.amazonaws.com'),
71
+ })
72
+ secret.grantRead(taskRole)
73
+
74
+ this.secrets[service.key] = secret
75
+ this.taskRoles[service.key] = taskRole
76
+
77
+ new CfnOutput(this, `SecretArn${idSuffix}`, {
78
+ value: secret.secretArn,
79
+ description: `Secrets Manager ARN for ${service.secretName}`,
80
+ exportName: `${platformName}-SecretArn-${service.key}`,
81
+ })
82
+
83
+ new CfnOutput(this, `TaskRoleArn${idSuffix}`, {
84
+ value: taskRole.roleArn,
85
+ description: `ECS task role ARN for ${service.secretName}`,
86
+ exportName: `${platformName}-TaskRoleArn-${service.key}`,
87
+ })
88
+ }
89
+ }
90
+ }
91
+
92
+ /**
93
+ * CloudFormation-safe construct id fragment from a service key.
94
+ * @param {string} key
95
+ * @returns {string}
96
+ */
97
+ function toConstructId(key) {
98
+ return key.replace(/[^A-Za-z0-9]/g, '')
99
+ }
100
+
101
+ module.exports = {
102
+ PlatformSecretsStack,
103
+ }
@@ -1,9 +1,15 @@
1
1
  /* eslint-disable no-new */
2
2
  const { Stage } = require('aws-cdk-lib')
3
3
  const { StorageStaticStack } = require('./storage-static-stack')
4
- const { DeploymentTargetStack } = require('./deployment-target-stack')
5
4
  const { DnsStack } = require('./dns-stack')
6
5
  const { SesStack } = require('./ses-stack')
6
+ const { PlatformSecretsStack } = require('./platform-secrets-stack')
7
+ const { PlatformEcsStack } = require('./platform-ecs-stack')
8
+ const { PlatformCiStack } = require('./platform-ci-stack')
9
+ const {
10
+ PlatformEdgeStack,
11
+ CLOUDFRONT_REGION,
12
+ } = require('./platform-edge-stack')
7
13
 
8
14
  /**
9
15
  * PlatformStage groups all stacks that make up a single platform deployment.
@@ -12,7 +18,10 @@ const { SesStack } = require('./ses-stack')
12
18
  * cdk deploy 'ossybot/**' --profile ossybot
13
19
  *
14
20
  * Stack names within the stage are automatically prefixed with the platform name,
15
- * e.g. ossybot/trust-ci, ossybot/storage-static, ossybot/deployment-target, etc.
21
+ * e.g. ossybot/storage-static, ossybot/platform-ecs, ossybot/platform-edge, etc.
22
+ *
23
+ * EC2 + Caddy (`deployment-target`) is removed from the stage (#560). Destroy any
24
+ * leftover CloudFormation stack of that name after edge + DNS are live.
16
25
  */
17
26
  class PlatformStage extends Stage {
18
27
  constructor(scope, id, props) {
@@ -20,15 +29,33 @@ class PlatformStage extends Stage {
20
29
 
21
30
  const storageStatic = new StorageStaticStack(this, 'storage-static', props)
22
31
 
23
- const deploymentTarget = new DeploymentTargetStack(this, 'deployment-target', {
32
+ const platformSecrets = new PlatformSecretsStack(this, 'platform-secrets', props)
33
+
34
+ const platformEcs = new PlatformEcsStack(this, 'platform-ecs', {
24
35
  ...props,
25
36
  bucket: storageStatic.bucket,
26
- cdnDomainName: storageStatic.cdnDomainName
37
+ cdnDomainName: storageStatic.cdnDomainName,
38
+ secrets: platformSecrets.secrets,
39
+ taskRoles: platformSecrets.taskRoles,
40
+ ghcrPullSecret: platformSecrets.ghcrPullSecret,
41
+ })
42
+
43
+ new PlatformCiStack(this, 'platform-ci', props)
44
+
45
+ // CloudFront + WAF + ACM (#558). Must be us-east-1 for viewer certs / CLOUDFRONT WAF.
46
+ const platformEdge = new PlatformEdgeStack(this, 'platform-edge', {
47
+ config: props.config,
48
+ albDnsName: platformEcs.loadBalancer.loadBalancerDnsName,
49
+ env: {
50
+ account: props.env?.account ?? props.config.awsAccountId,
51
+ region: CLOUDFRONT_REGION,
52
+ },
27
53
  })
28
54
 
55
+ // Known Route53 domains → CloudFront (#558 / #560). No EIP path.
29
56
  new DnsStack(this, 'dns', {
30
57
  ...props,
31
- containerDeploymentTargetPublicIp: deploymentTarget.containerDeploymentTargetPublicIp
58
+ cloudFrontDomainName: platformEdge.domainName,
32
59
  })
33
60
 
34
61
  new SesStack(this, 'ses', props)