@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.
Files changed (33) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +156 -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 +209 -0
  7. package/src/ecs/platform-ecs-services.spec.js +163 -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 +45 -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 +349 -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,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
+ }