@ossy/deployment-tools 3.4.0 → 3.7.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 CHANGED
@@ -3,6 +3,20 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # [3.7.0](https://github.com/ossy-se/ossy/compare/v3.6.2...v3.7.0) (2026-08-22)
7
+
8
+ ### Features
9
+
10
+ * **deployment-tools:** give website-ossy 1 vCPU, 4 GB, two tasks, and a larger Node heap ([#667](https://github.com/ossy-se/ossy/issues/667)) ([3e81436](https://github.com/ossy-se/ossy/commit/3e814363a7f80f37a1b90f64677371cfa2f2fe44))
11
+
12
+
13
+ # [3.6.0](https://github.com/ossy-se/ossy/compare/v3.5.1...v3.6.0) (2026-08-16)
14
+
15
+ ### Bug Fixes
16
+
17
+ * **deployment-tools:** stabilize ECS cutover edge, OIDC, and ALB rules ([#657](https://github.com/ossy-se/ossy/issues/657)) ([257bd79](https://github.com/ossy-se/ossy/commit/257bd7965d86a09aae2b8f285c5e22e2d29160bb))
18
+
19
+
6
20
  # [3.4.0](https://github.com/ossy-se/ossy/compare/v3.3.0...v3.4.0) (2026-08-14)
7
21
 
8
22
  ### Features
package/cdk.context.json CHANGED
@@ -84,5 +84,18 @@
84
84
  "hosted-zone:account=858451553223:domainName=tepit.se:region=eu-north-1": {
85
85
  "Id": "/hostedzone/Z07859112SU1GH5SKMUXD",
86
86
  "Name": "tepit.se."
87
+ },
88
+ "availability-zones:account=858451553223:region=eu-north-1": [
89
+ "eu-north-1a",
90
+ "eu-north-1b",
91
+ "eu-north-1c"
92
+ ],
93
+ "hosted-zone:account=858451553223:domainName=ossy.se:region=us-east-1": {
94
+ "Id": "/hostedzone/Z09424571CVOTIJV8Z50C",
95
+ "Name": "ossy.se."
96
+ },
97
+ "hosted-zone:account=858451553223:domainName=plexus-sanitas.com:region=us-east-1": {
98
+ "Id": "/hostedzone/Z0222639MQIJ8EOL9VAG",
99
+ "Name": "plexus-sanitas.com."
87
100
  }
88
101
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ossy/deployment-tools",
3
- "version": "3.4.0",
3
+ "version": "3.7.0",
4
4
  "description": "Collection of scripts and tools to aid deployment of containers and static files to Amazon Web Services through GitHub Actions",
5
5
  "source": "./src/index.js",
6
6
  "main": "./src/index.js",
@@ -24,5 +24,5 @@
24
24
  "devDependencies": {
25
25
  "jest": "^30.4.2"
26
26
  },
27
- "gitHead": "d36b69444268d172bc3e8e1dc77e67afc63f8650"
27
+ "gitHead": "622d97535abad08415963405ace2f5aaaecd7ca4"
28
28
  }
@@ -21,6 +21,9 @@
21
21
  * @property {string} domain - primary hostname (default ALB host when `hosts` omitted)
22
22
  * @property {string[]=} hosts - ALB host-header values (#557); defaults to `[domain]`
23
23
  * @property {string} image - Docker image (e.g. ghcr.io/ossy-se/website-ossy:latest)
24
+ * @property {number=} cpu - Fargate CPU units; default 512
25
+ * @property {number=} memoryLimitMiB - Fargate memory MiB; default 1024
26
+ * @property {number=} desiredCount - ECS desired tasks; default 1
24
27
  *
25
28
  * @typedef {Object} TcpServiceConfig
26
29
  * @property {'tcp'} type - raw socket service; not deployed on the ECS path (#560)
@@ -42,6 +42,10 @@ const CONTAINER_ENVIRONMENT_KEYS = Object.freeze([
42
42
  * @property {string[]} hosts - ALB host-header values for this service (empty = default/catch-all)
43
43
  * @property {boolean} isDefault - when true, ALB default action forwards here (runtime)
44
44
  * @property {number} containerPort - container listen port
45
+ * @property {number} cpu - Fargate task CPU units (256, 512, 1024, …)
46
+ * @property {number} memoryLimitMiB - Fargate task memory (must be valid for `cpu`)
47
+ * @property {number} desiredCount - ECS service desired task count
48
+ * @property {string} [nodeOptions] - optional `NODE_OPTIONS` for the container
45
49
  */
46
50
 
47
51
  /**
@@ -55,7 +59,7 @@ function secretEnvKeysForEcsContainer(envKeys = []) {
55
59
  if (!Array.isArray(envKeys)) {
56
60
  throw new Error('[platform-ecs-services] envKeys must be an array')
57
61
  }
58
- const reserved = new Set(CONTAINER_ENVIRONMENT_KEYS)
62
+ const reserved = new Set([...CONTAINER_ENVIRONMENT_KEYS, 'NODE_OPTIONS'])
59
63
  return envKeys.filter((key) => typeof key === 'string' && key && !reserved.has(key))
60
64
  }
61
65
 
@@ -78,6 +82,7 @@ function containerEnvironment({
78
82
  containerPort = CONTAINER_PORT,
79
83
  mediaRepository,
80
84
  mediaCdnDomainName,
85
+ nodeOptions,
81
86
  }) {
82
87
  if (!serviceKey) {
83
88
  throw new Error('[platform-ecs-services] serviceKey is required')
@@ -91,6 +96,7 @@ function containerEnvironment({
91
96
  OSSY_SERVICE_NAME: serviceKey,
92
97
  MEDIA_REPOSITORY: mediaRepository,
93
98
  MEDIA_CDN_DOMAIN_NAME: mediaCdnDomainName,
99
+ ...(nodeOptions ? { NODE_OPTIONS: nodeOptions } : {}),
94
100
  }
95
101
  }
96
102
 
@@ -151,7 +157,7 @@ function runtimeHosts(config, httpEntries) {
151
157
  * @param {{
152
158
  * platformName: string,
153
159
  * domains?: string[],
154
- * services?: Array<{ name: string, type?: string, domain?: string, hosts?: string[], image?: string }>
160
+ * services?: Array<{ name: string, type?: string, domain?: string, hosts?: string[], image?: string, cpu?: number, memoryLimitMiB?: number, desiredCount?: number }>
155
161
  * }} config
156
162
  * @returns {PlatformEcsService[]}
157
163
  */
@@ -173,6 +179,9 @@ function listPlatformEcsServices(config) {
173
179
  hosts: runtimeHostList,
174
180
  isDefault: true,
175
181
  containerPort: CONTAINER_PORT,
182
+ cpu: 512,
183
+ memoryLimitMiB: 1024,
184
+ desiredCount: 1,
176
185
  }
177
186
  }
178
187
 
@@ -190,6 +199,10 @@ function listPlatformEcsServices(config) {
190
199
  hosts: hostsFromServiceEntry(service.entry),
191
200
  isDefault: false,
192
201
  containerPort: CONTAINER_PORT,
202
+ cpu: Number.isFinite(service.entry.cpu) ? service.entry.cpu : 512,
203
+ memoryLimitMiB: Number.isFinite(service.entry.memoryLimitMiB) ? service.entry.memoryLimitMiB : 1024,
204
+ desiredCount: Number.isFinite(service.entry.desiredCount) ? service.entry.desiredCount : 1,
205
+ nodeOptions: typeof service.entry.nodeOptions === 'string' ? service.entry.nodeOptions : undefined,
193
206
  }
194
207
  })
195
208
  }
@@ -63,6 +63,16 @@ describe('containerEnvironment', () => {
63
63
  })
64
64
  })
65
65
 
66
+ it('adds NODE_OPTIONS when provided', () => {
67
+ const env = containerEnvironment({
68
+ serviceKey: 'website-ossy',
69
+ mediaRepository: 'media-bucket',
70
+ mediaCdnDomainName: 'https://cdn.example',
71
+ nodeOptions: '--max-old-space-size=3072',
72
+ })
73
+ expect(env.NODE_OPTIONS).toBe('--max-old-space-size=3072')
74
+ })
75
+
66
76
  it('requires serviceKey and media fields', () => {
67
77
  expect(() => containerEnvironment({
68
78
  mediaRepository: 'b',
@@ -82,6 +92,7 @@ describe('secretEnvKeysForEcsContainer', () => {
82
92
  'TOKEN_SECRET',
83
93
  'NODE_ENV',
84
94
  'MEDIA_REPOSITORY',
95
+ 'NODE_OPTIONS',
85
96
  'DB_URL',
86
97
  ])).toEqual(['TOKEN_SECRET', 'DB_URL'])
87
98
  })
@@ -121,6 +132,10 @@ describe('listPlatformEcsServices', () => {
121
132
  domain: 'ossy.se',
122
133
  hosts: ['ossy.se', 'api.ossy.se'],
123
134
  image: 'ghcr.io/ossy-se/website-ossy:latest',
135
+ cpu: 1024,
136
+ memoryLimitMiB: 4096,
137
+ desiredCount: 2,
138
+ nodeOptions: '--max-old-space-size=3072',
124
139
  },
125
140
  {
126
141
  name: 'ossy-website-plexus-sanitas',
@@ -153,6 +168,19 @@ describe('listPlatformEcsServices', () => {
153
168
  expect(website.hosts).toEqual(['ossy.se', 'api.ossy.se'])
154
169
  expect(website.image).toBe('ghcr.io/ossy-se/website-ossy:latest')
155
170
  expect(website.secretName).toBe('ossybot/website-ossy')
171
+ expect(website.cpu).toBe(1024)
172
+ expect(website.memoryLimitMiB).toBe(4096)
173
+ expect(website.desiredCount).toBe(2)
174
+ expect(website.nodeOptions).toBe('--max-old-space-size=3072')
175
+
176
+ const plexus = services.find(s => s.key === 'website-plexus-sanitas')
177
+ expect(plexus.cpu).toBe(512)
178
+ expect(plexus.memoryLimitMiB).toBe(1024)
179
+ expect(plexus.desiredCount).toBe(1)
180
+ expect(plexus.nodeOptions).toBeUndefined()
181
+
182
+ expect(runtime.cpu).toBe(512)
183
+ expect(runtime.desiredCount).toBe(1)
156
184
 
157
185
  expect(services.some(s => s.key.includes('api'))).toBe(false)
158
186
  })
@@ -28,8 +28,29 @@ function rootDomainOf(domain) {
28
28
  return parts.slice(-2).join('.')
29
29
  }
30
30
 
31
+ /**
32
+ * Whether `hostname` is already covered by a `*.root` wildcard in `aliases`.
33
+ * CloudFront rejects overlapping CNAMEs (e.g. both `*.ossy.se` and `api.ossy.se`).
34
+ *
35
+ * @param {string} hostname
36
+ * @param {string[]} aliases
37
+ * @returns {boolean}
38
+ */
39
+ function coveredByWildcardAlias(hostname, aliases) {
40
+ if (!hostname || hostname.includes('*')) return false
41
+ return aliases.some(alias => {
42
+ if (!alias.startsWith('*.')) return false
43
+ const suffix = alias.slice(1) // e.g. ".ossy.se"
44
+ if (!hostname.endsWith(suffix)) return false
45
+ const prefix = hostname.slice(0, -suffix.length)
46
+ // single-label subdomain only (*.ossy.se covers api.ossy.se, not a.b.ossy.se)
47
+ return prefix.length > 0 && !prefix.includes('.')
48
+ })
49
+ }
50
+
31
51
  /**
32
52
  * Unique, stable sorted list of CloudFront aliases from `domains` + HTTP service hosts.
53
+ * Drops hostnames covered by a sibling wildcard so ACM/CloudFront accept the set.
33
54
  *
34
55
  * @param {{
35
56
  * domains?: string[],
@@ -46,9 +67,10 @@ function listEdgeAliases(config) {
46
67
  return entry.domain ? [entry.domain] : []
47
68
  })
48
69
 
49
- return [...new Set([...fromDomains, ...fromServices])].sort((a, b) =>
50
- a.localeCompare(b)
51
- )
70
+ const all = [...new Set([...fromDomains, ...fromServices])]
71
+ return all
72
+ .filter(alias => !coveredByWildcardAlias(alias, all))
73
+ .sort((a, b) => a.localeCompare(b))
52
74
  }
53
75
 
54
76
  /**
@@ -14,7 +14,7 @@ describe('rootDomainOf', () => {
14
14
  })
15
15
 
16
16
  describe('listEdgeAliases', () => {
17
- it('unions domains and HTTP service hosts; skips tcp', () => {
17
+ it('unions domains and HTTP service hosts; skips tcp; drops names under wildcards', () => {
18
18
  expect(listEdgeAliases({
19
19
  domains: ['ossy.se', '*.ossy.se', 'worker.ossy.se'],
20
20
  services: [
@@ -30,7 +30,7 @@ describe('listEdgeAliases', () => {
30
30
  ports: [25565],
31
31
  },
32
32
  ],
33
- })).toEqual(['*.ossy.se', 'api.ossy.se', 'ossy.se', 'worker.ossy.se'])
33
+ })).toEqual(['*.ossy.se', 'ossy.se'])
34
34
  })
35
35
  })
36
36
 
@@ -59,17 +59,14 @@ describe('planEdgeDomains', () => {
59
59
  })
60
60
 
61
61
  expect(plan.primaryDomain).toBe('ossy.se')
62
+ // api/worker covered by *.ossy.se — CloudFront forbids overlapping CNAMEs
62
63
  expect(plan.aliases).toEqual([
63
64
  '*.ossy.se',
64
- 'api.ossy.se',
65
65
  'ossy.se',
66
- 'worker.ossy.se',
67
66
  'www.plexus-sanitas.com',
68
67
  ])
69
68
  expect(plan.subjectAlternativeNames).toEqual([
70
69
  '*.ossy.se',
71
- 'api.ossy.se',
72
- 'worker.ossy.se',
73
70
  'www.plexus-sanitas.com',
74
71
  ])
75
72
  expect(plan.validationDomainToRoot.get('*.ossy.se')).toBe('ossy.se')
@@ -23,8 +23,9 @@ const {
23
23
  * Trust is limited to `ref:refs/heads/main` for `config.githubDeployRepos`
24
24
  * (set in platforms.json — add a repo there to allow it to assume this role).
25
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.
26
+ * Note: AWS allows only one OIDC provider per URL per account. This stack
27
+ * references the account provider (often created earlier by `trust-ci`) instead
28
+ * of creating a second one.
28
29
  */
29
30
  class PlatformCiStack extends Stack {
30
31
  /**
@@ -61,10 +62,12 @@ class PlatformCiStack extends Stack {
61
62
  return `repo:${repo}:ref:refs/heads/main`
62
63
  })
63
64
 
64
- const provider = new OpenIdConnectProvider(this, 'GithubOidcProvider', {
65
- url: 'https://token.actions.githubusercontent.com',
66
- clientIds: ['sts.amazonaws.com'],
67
- })
65
+ // One provider per issuer URL per account — reuse the existing GitHub OIDC IdP.
66
+ const provider = OpenIdConnectProvider.fromOpenIdConnectProviderArn(
67
+ this,
68
+ 'GithubOidcProvider',
69
+ `arn:aws:iam::${account}:oidc-provider/token.actions.githubusercontent.com`
70
+ )
68
71
 
69
72
  const deployRole = new Role(this, 'GithubDeployRole', {
70
73
  roleName,
@@ -166,8 +166,8 @@ class PlatformEcsStack extends Stack {
166
166
 
167
167
  const taskDefinition = new FargateTaskDefinition(this, `TaskDef${idSuffix}`, {
168
168
  family: `${platformName}-${service.key}`,
169
- cpu: 512,
170
- memoryLimitMiB: 1024,
169
+ cpu: service.cpu,
170
+ memoryLimitMiB: service.memoryLimitMiB,
171
171
  runtimePlatform: {
172
172
  cpuArchitecture: CpuArchitecture.X86_64,
173
173
  operatingSystemFamily: OperatingSystemFamily.LINUX,
@@ -199,6 +199,7 @@ class PlatformEcsStack extends Stack {
199
199
  containerPort: service.containerPort,
200
200
  mediaRepository: props.bucket.bucketName,
201
201
  mediaCdnDomainName: `https://${props.cdnDomainName}`,
202
+ nodeOptions: service.nodeOptions,
202
203
  }),
203
204
  secrets: containerSecrets,
204
205
  // Node fetch → GET /health (no curl in node:*-slim; no image-local script required).
@@ -222,7 +223,7 @@ class PlatformEcsStack extends Stack {
222
223
  serviceName: truncateAwsName(`${platformName}-${service.key}`, 255),
223
224
  cluster,
224
225
  taskDefinition,
225
- desiredCount: 1,
226
+ desiredCount: service.desiredCount,
226
227
  assignPublicIp: false,
227
228
  securityGroups: [serviceSecurityGroup],
228
229
  vpcSubnets: { subnetType: SubnetType.PRIVATE_WITH_EGRESS },
@@ -281,23 +282,18 @@ class PlatformEcsStack extends Stack {
281
282
 
282
283
  // Lower priority number = evaluated first. Specific hosts before wildcards
283
284
  // 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) {
285
+ // Priorities are hash-stable per host — sequential 10,11,12… renumbers
286
+ // every insert and CloudFormation cannot swap ALB priorities in one update.
287
+ const usedPriorities = new Set()
288
+ for (const rule of hostRules) {
292
289
  listener.addTargetGroups(
293
290
  `Rule${rule.idSuffix}${toConstructId(rule.host)}`,
294
291
  {
295
- priority: rulePriority,
292
+ priority: albHostRulePriority(rule.host, usedPriorities),
296
293
  conditions: [ListenerCondition.hostHeaders([rule.host])],
297
294
  targetGroups: [rule.targetGroup],
298
295
  }
299
296
  )
300
- rulePriority += 1
301
297
  }
302
298
 
303
299
  this.vpc = vpc
@@ -325,6 +321,29 @@ class PlatformEcsStack extends Stack {
325
321
  }
326
322
  }
327
323
 
324
+ /**
325
+ * Stable ALB listener-rule priority for a host header (1–50000).
326
+ * Specific hosts: 10000–29999; wildcards: 40000–49999 (evaluated later).
327
+ * @param {string} host
328
+ * @param {Set<number>} used
329
+ * @returns {number}
330
+ */
331
+ function albHostRulePriority(host, used) {
332
+ const band = host.includes('*') ? 40000 : 10000
333
+ let hash = 2166136261
334
+ for (let i = 0; i < host.length; i += 1) {
335
+ hash ^= host.charCodeAt(i)
336
+ hash = Math.imul(hash, 16777619) >>> 0
337
+ }
338
+ let priority = band + (hash % 20000)
339
+ while (used.has(priority)) {
340
+ priority += 1
341
+ if (priority >= band + 20000) priority = band
342
+ }
343
+ used.add(priority)
344
+ return priority
345
+ }
346
+
328
347
  /**
329
348
  * CloudFormation-safe construct id fragment.
330
349
  * @param {string} key
@@ -346,4 +365,5 @@ function truncateAwsName(name, max) {
346
365
 
347
366
  module.exports = {
348
367
  PlatformEcsStack,
368
+ albHostRulePriority,
349
369
  }