@ossy/deployment-tools 3.1.0 → 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 CHANGED
@@ -3,6 +3,13 @@
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.4.0](https://github.com/ossy-se/ossy/compare/v3.3.0...v3.4.0) (2026-08-14)
7
+
8
+ ### Features
9
+
10
+ * **deployment-tools:** add ECS container /health probes ([#589](https://github.com/ossy-se/ossy/issues/589)) ([0761f19](https://github.com/ossy-se/ossy/commit/0761f19a695f98470fccdcd9fc90ae0af288d7f3)), closes [#553](https://github.com/ossy-se/ossy/issues/553) [#553](https://github.com/ossy-se/ossy/issues/553)
11
+
12
+
6
13
  # [3.1.0](https://github.com/ossy-se/ossy/compare/v3.0.9...v3.1.0) (2026-08-12)
7
14
 
8
15
  ### Features
package/README.md CHANGED
@@ -16,7 +16,7 @@ Internet → CloudFront (ACM TLS, us-east-1) → WAF → ALB (:80)
16
16
 
17
17
  - Website images include the API — **no** separate `ossy-api` service.
18
18
  - Task definitions inject env from Secrets Manager (`{platform}/{serviceKey}`) and pull images from GHCR using `{platform}/ghcr-pull`.
19
- - Health checks use `GET /health` on each target group.
19
+ - Health checks use `GET /health` on each ALB target group **and** each ECS container (Node `fetch` probe; no curl in `node:*-slim`).
20
20
  - App CloudFront is **separate** from the `/media` CloudFront in `storage-static`.
21
21
  - Viewer `Host` is forwarded to the ALB (`OriginRequestPolicy.ALL_VIEWER`) so host rules match.
22
22
  - ACM terminates TLS at CloudFront; Caddy is not part of this path.
@@ -26,14 +26,16 @@ Customer nameserver / custom-domain handoff at a registrar is **out of scope** (
26
26
 
27
27
  ## Health checks (ALB / ECS)
28
28
 
29
- Every deployable Ossy HTTP image exposes an unauthenticated liveness probe at **`GET /health`** (also accepts `HEAD`). It returns `200` when the Node process can accept traffic.
29
+ Every deployable Ossy HTTP image exposes an unauthenticated liveness probe at **`GET /health`** (also accepts `HEAD`). It returns `200` when the Node process can accept traffic. The JSON `service` field prefers **`OSSY_SERVICE_NAME`** (set on the task definition to the service key).
30
30
 
31
31
  | Image | How `/health` is provided |
32
32
  |---|---|
33
33
  | `ghcr.io/ossy-se/platform` (runtime) | Built into `@ossy/platform` `startRuntime` — does not load a CMS site |
34
34
  | Website / app images (`website-ossy`, other `services[]`) | Built into `@ossy/platform` `startServer` (and/or a local `health.api.js` until the platform package is bumped) |
35
35
 
36
- Use path `/health` for ALB target-group and ECS container health checks. Do not point probes at `/`, `/status`, or authenticated routes.
36
+ **ALB target groups** probe `GET /health`. **ECS container health checks** run an exec-form Node one-liner (`ecsContainerHealthCheckCommand`) that `fetch`es the same path on `PORT` and aborts after `HEALTHCHECK_FETCH_TIMEOUT_MS` (4s). Do not point probes at `/`, `/status`, or authenticated routes.
37
+
38
+ Task definitions also set plain env `PORT`, `OSSY_SERVICE_NAME`, `NODE_ENV`, and media bucket vars via `containerEnvironment`. Those keys are **filtered out** of Secrets Manager injection (`secretEnvKeysForEcsContainer`) so ECS cannot reject duplicate environment/secret keys.
37
39
 
38
40
  ## Configuration — `platforms.json`
39
41
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ossy/deployment-tools",
3
- "version": "3.1.0",
3
+ "version": "3.4.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": "9b146fb6fd9769f800863806c5bd16016508b218"
27
+ "gitHead": "d36b69444268d172bc3e8e1dc77e67afc63f8650"
28
28
  }
@@ -13,6 +13,25 @@ const {
13
13
 
14
14
  const RUNTIME_IMAGE = 'ghcr.io/ossy-se/platform:latest'
15
15
  const CONTAINER_PORT = 3000
16
+ /** Shared with `@ossy/platform` `HEALTH_PATH` and ALB target-group checks. */
17
+ const HEALTH_PATH = '/health'
18
+ /**
19
+ * Abort hung Node `fetch` probes under the usual 5s ECS/Docker check timeout.
20
+ * Keep in sync with website-ossy `docker-healthcheck.js` when that lands.
21
+ */
22
+ const HEALTHCHECK_FETCH_TIMEOUT_MS = 4000
23
+
24
+ /**
25
+ * Plain task-definition `environment` keys set by PlatformEcsStack.
26
+ * Must not also be injected from Secrets Manager (ECS rejects duplicate keys).
27
+ */
28
+ const CONTAINER_ENVIRONMENT_KEYS = Object.freeze([
29
+ 'NODE_ENV',
30
+ 'PORT',
31
+ 'OSSY_SERVICE_NAME',
32
+ 'MEDIA_REPOSITORY',
33
+ 'MEDIA_CDN_DOMAIN_NAME',
34
+ ])
16
35
 
17
36
  /**
18
37
  * @typedef {Object} PlatformEcsService
@@ -25,6 +44,79 @@ const CONTAINER_PORT = 3000
25
44
  * @property {number} containerPort - container listen port
26
45
  */
27
46
 
47
+ /**
48
+ * Env keys from `platforms.json` that may be injected as ECS secrets.
49
+ * Drops keys already set as plain container environment.
50
+ *
51
+ * @param {string[]} envKeys
52
+ * @returns {string[]}
53
+ */
54
+ function secretEnvKeysForEcsContainer(envKeys = []) {
55
+ if (!Array.isArray(envKeys)) {
56
+ throw new Error('[platform-ecs-services] envKeys must be an array')
57
+ }
58
+ const reserved = new Set(CONTAINER_ENVIRONMENT_KEYS)
59
+ return envKeys.filter((key) => typeof key === 'string' && key && !reserved.has(key))
60
+ }
61
+
62
+ /**
63
+ * Plain task-definition `environment` for an ECS HTTP container.
64
+ *
65
+ * Keys must stay aligned with `CONTAINER_ENVIRONMENT_KEYS` so Secrets Manager
66
+ * injection cannot collide with these values.
67
+ *
68
+ * @param {{
69
+ * serviceKey: string,
70
+ * containerPort?: number,
71
+ * mediaRepository: string,
72
+ * mediaCdnDomainName: string,
73
+ * }} options
74
+ * @returns {Record<string, string>}
75
+ */
76
+ function containerEnvironment({
77
+ serviceKey,
78
+ containerPort = CONTAINER_PORT,
79
+ mediaRepository,
80
+ mediaCdnDomainName,
81
+ }) {
82
+ if (!serviceKey) {
83
+ throw new Error('[platform-ecs-services] serviceKey is required')
84
+ }
85
+ if (!mediaRepository || !mediaCdnDomainName) {
86
+ throw new Error('[platform-ecs-services] mediaRepository and mediaCdnDomainName are required')
87
+ }
88
+ return {
89
+ NODE_ENV: 'production',
90
+ PORT: String(containerPort),
91
+ OSSY_SERVICE_NAME: serviceKey,
92
+ MEDIA_REPOSITORY: mediaRepository,
93
+ MEDIA_CDN_DOMAIN_NAME: mediaCdnDomainName,
94
+ }
95
+ }
96
+
97
+ /**
98
+ * ECS container health-check command for Ossy HTTP images.
99
+ *
100
+ * Uses Node `fetch` against `HEALTH_PATH` (honors runtime `PORT`) so probes
101
+ * work on `node:*-slim` without curl/wget and without requiring a WORKDIR
102
+ * `docker-healthcheck.js` in every image. Abort after
103
+ * `HEALTHCHECK_FETCH_TIMEOUT_MS` so hung sockets fail under the 5s timeout.
104
+ *
105
+ * @param {{ containerPort?: number }} [options]
106
+ * @returns {string[]} Docker/ECS exec-form health check command
107
+ */
108
+ function ecsContainerHealthCheckCommand({ containerPort = CONTAINER_PORT } = {}) {
109
+ const portFallback = Number(containerPort) > 0 ? Number(containerPort) : CONTAINER_PORT
110
+ // Single expression — ECS passes each CMD arg without a shell.
111
+ const probe = [
112
+ `const port=Number.parseInt(String(process.env.PORT||''),10);`,
113
+ `const p=Number.isFinite(port)&&port>0?port:${portFallback};`,
114
+ `fetch('http://127.0.0.1:'+p+'${HEALTH_PATH}',{signal:AbortSignal.timeout(${HEALTHCHECK_FETCH_TIMEOUT_MS})})`,
115
+ `.then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))`,
116
+ ].join('')
117
+ return ['CMD', 'node', '-e', probe]
118
+ }
119
+
28
120
  /**
29
121
  * Host headers for an HTTP `services[]` entry.
30
122
  * Uses optional `hosts` when set; otherwise `[domain]`.
@@ -105,6 +197,12 @@ function listPlatformEcsServices(config) {
105
197
  module.exports = {
106
198
  RUNTIME_IMAGE,
107
199
  CONTAINER_PORT,
200
+ HEALTH_PATH,
201
+ HEALTHCHECK_FETCH_TIMEOUT_MS,
202
+ CONTAINER_ENVIRONMENT_KEYS,
203
+ secretEnvKeysForEcsContainer,
204
+ containerEnvironment,
205
+ ecsContainerHealthCheckCommand,
108
206
  hostsFromServiceEntry,
109
207
  listPlatformEcsServices,
110
208
  runtimeHosts,
@@ -1,10 +1,96 @@
1
1
  const { describe, expect, it } = require('@jest/globals')
2
+ const fs = require('node:fs')
3
+ const path = require('node:path')
2
4
  const {
5
+ CONTAINER_ENVIRONMENT_KEYS,
6
+ CONTAINER_PORT,
7
+ HEALTH_PATH,
8
+ HEALTHCHECK_FETCH_TIMEOUT_MS,
3
9
  RUNTIME_IMAGE,
10
+ containerEnvironment,
11
+ ecsContainerHealthCheckCommand,
4
12
  hostsFromServiceEntry,
5
13
  listPlatformEcsServices,
14
+ secretEnvKeysForEcsContainer,
6
15
  } = require('./platform-ecs-services')
7
16
 
17
+ describe('shared health constants', () => {
18
+ it('stay aligned with @ossy/platform health.js (ALB + ECS probes)', () => {
19
+ const healthSrc = fs.readFileSync(
20
+ path.join(__dirname, '../../../platform/src/health.js'),
21
+ 'utf8'
22
+ )
23
+ expect(healthSrc).toContain(`export const HEALTH_PATH = '${HEALTH_PATH}'`)
24
+ expect(HEALTH_PATH).toBe('/health')
25
+ expect(HEALTHCHECK_FETCH_TIMEOUT_MS).toBe(4000)
26
+ expect(CONTAINER_PORT).toBe(3000)
27
+ })
28
+ })
29
+
30
+ describe('ecsContainerHealthCheckCommand', () => {
31
+ it('probes HEALTH_PATH with Node fetch and abort timeout', () => {
32
+ const command = ecsContainerHealthCheckCommand()
33
+ expect(command[0]).toBe('CMD')
34
+ expect(command[1]).toBe('node')
35
+ expect(command[2]).toBe('-e')
36
+ expect(command[3]).toContain(`'${HEALTH_PATH}'`)
37
+ expect(command[3]).toContain(`AbortSignal.timeout(${HEALTHCHECK_FETCH_TIMEOUT_MS})`)
38
+ expect(command[3]).toContain(String(CONTAINER_PORT))
39
+ expect(command[3]).toContain('process.env.PORT')
40
+ })
41
+
42
+ it('honors an explicit containerPort fallback', () => {
43
+ const command = ecsContainerHealthCheckCommand({ containerPort: 3002 })
44
+ expect(command[3]).toContain('3002')
45
+ })
46
+ })
47
+
48
+ describe('containerEnvironment', () => {
49
+ it('uses exactly CONTAINER_ENVIRONMENT_KEYS (reserved for secret filtering)', () => {
50
+ const env = containerEnvironment({
51
+ serviceKey: 'website-ossy',
52
+ containerPort: 3000,
53
+ mediaRepository: 'media-bucket',
54
+ mediaCdnDomainName: 'https://cdn.example',
55
+ })
56
+ expect(Object.keys(env).sort()).toEqual([...CONTAINER_ENVIRONMENT_KEYS].sort())
57
+ expect(env).toEqual({
58
+ NODE_ENV: 'production',
59
+ PORT: '3000',
60
+ OSSY_SERVICE_NAME: 'website-ossy',
61
+ MEDIA_REPOSITORY: 'media-bucket',
62
+ MEDIA_CDN_DOMAIN_NAME: 'https://cdn.example',
63
+ })
64
+ })
65
+
66
+ it('requires serviceKey and media fields', () => {
67
+ expect(() => containerEnvironment({
68
+ mediaRepository: 'b',
69
+ mediaCdnDomainName: 'https://cdn.example',
70
+ })).toThrow(/serviceKey is required/)
71
+ expect(() => containerEnvironment({ serviceKey: 'runtime' })).toThrow(
72
+ /mediaRepository and mediaCdnDomainName are required/
73
+ )
74
+ })
75
+ })
76
+
77
+ describe('secretEnvKeysForEcsContainer', () => {
78
+ it('drops reserved container environment keys', () => {
79
+ expect(secretEnvKeysForEcsContainer([
80
+ 'PORT',
81
+ 'OSSY_SERVICE_NAME',
82
+ 'TOKEN_SECRET',
83
+ 'NODE_ENV',
84
+ 'MEDIA_REPOSITORY',
85
+ 'DB_URL',
86
+ ])).toEqual(['TOKEN_SECRET', 'DB_URL'])
87
+ })
88
+
89
+ it('requires an array', () => {
90
+ expect(() => secretEnvKeysForEcsContainer(null)).toThrow(/envKeys must be an array/)
91
+ })
92
+ })
93
+
8
94
  describe('hostsFromServiceEntry', () => {
9
95
  it('uses hosts when provided', () => {
10
96
  expect(hostsFromServiceEntry({
package/src/index.js CHANGED
@@ -12,6 +12,12 @@ const {
12
12
  hostsFromServiceEntry,
13
13
  RUNTIME_IMAGE,
14
14
  CONTAINER_PORT,
15
+ HEALTH_PATH,
16
+ HEALTHCHECK_FETCH_TIMEOUT_MS,
17
+ CONTAINER_ENVIRONMENT_KEYS,
18
+ secretEnvKeysForEcsContainer,
19
+ containerEnvironment,
20
+ ecsContainerHealthCheckCommand,
15
21
  } = require('./ecs/platform-ecs-services')
16
22
  const {
17
23
  rootDomainOf,
@@ -31,6 +37,12 @@ module.exports = {
31
37
  hostsFromServiceEntry,
32
38
  RUNTIME_IMAGE,
33
39
  CONTAINER_PORT,
40
+ HEALTH_PATH,
41
+ HEALTHCHECK_FETCH_TIMEOUT_MS,
42
+ CONTAINER_ENVIRONMENT_KEYS,
43
+ secretEnvKeysForEcsContainer,
44
+ containerEnvironment,
45
+ ecsContainerHealthCheckCommand,
34
46
  rootDomainOf,
35
47
  listEdgeAliases,
36
48
  planEdgeDomains,
@@ -31,7 +31,14 @@ const {
31
31
  ServicePrincipal,
32
32
  } = require('aws-cdk-lib/aws-iam')
33
33
  const { LogGroup, RetentionDays } = require('aws-cdk-lib/aws-logs')
34
- const { listPlatformEcsServices } = require('../ecs/platform-ecs-services')
34
+ const {
35
+ CONTAINER_PORT,
36
+ HEALTH_PATH,
37
+ containerEnvironment,
38
+ ecsContainerHealthCheckCommand,
39
+ listPlatformEcsServices,
40
+ secretEnvKeysForEcsContainer,
41
+ } = require('../ecs/platform-ecs-services')
35
42
 
36
43
  /**
37
44
  * @typedef {Object} PlatformEcsStackProps
@@ -103,7 +110,7 @@ class PlatformEcsStack extends Stack {
103
110
  })
104
111
  serviceSecurityGroup.addIngressRule(
105
112
  albSecurityGroup,
106
- Port.tcp(3000),
113
+ Port.tcp(CONTAINER_PORT),
107
114
  'ALB to Fargate containers'
108
115
  )
109
116
 
@@ -169,8 +176,12 @@ class PlatformEcsStack extends Stack {
169
176
  executionRole,
170
177
  })
171
178
 
179
+ // Secrets must not redefine plain `environment` keys (PORT, OSSY_SERVICE_NAME, …).
172
180
  const containerSecrets = Object.fromEntries(
173
- envKeys.map(key => [key, EcsSecret.fromSecretsManager(secret, key)])
181
+ secretEnvKeysForEcsContainer(envKeys).map(key => [
182
+ key,
183
+ EcsSecret.fromSecretsManager(secret, key),
184
+ ])
174
185
  )
175
186
 
176
187
  const container = taskDefinition.addContainer(`Container${idSuffix}`, {
@@ -182,13 +193,24 @@ class PlatformEcsStack extends Stack {
182
193
  streamPrefix: service.key,
183
194
  logGroup,
184
195
  }),
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
- },
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
+ }),
191
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
+ },
192
214
  })
193
215
 
194
216
  container.addPortMappings({
@@ -205,6 +227,7 @@ class PlatformEcsStack extends Stack {
205
227
  securityGroups: [serviceSecurityGroup],
206
228
  vpcSubnets: { subnetType: SubnetType.PRIVATE_WITH_EGRESS },
207
229
  circuitBreaker: { rollback: true },
230
+ healthCheckGracePeriod: Duration.seconds(60),
208
231
  })
209
232
 
210
233
  const targetGroup = new ApplicationTargetGroup(this, `TargetGroup${idSuffix}`, {
@@ -214,7 +237,7 @@ class PlatformEcsStack extends Stack {
214
237
  targetType: TargetType.IP,
215
238
  targetGroupName: truncateAwsName(`${platformName}-${service.key}`, 32),
216
239
  healthCheck: {
217
- path: '/health',
240
+ path: HEALTH_PATH,
218
241
  healthyHttpCodes: '200',
219
242
  interval: Duration.seconds(30),
220
243
  timeout: Duration.seconds(5),