@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,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ECS/Fargate service targets derived from platforms.json.
|
|
3
|
+
*
|
|
4
|
+
* Service set and naming come from shared `platform-services`. No separate
|
|
5
|
+
* `ossy-api` — API lives in website images. TCP/UDP entries are skipped (not
|
|
6
|
+
* part of the ECS path after #560).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const {
|
|
10
|
+
listHttpServiceEntries,
|
|
11
|
+
listPlatformServices,
|
|
12
|
+
} = require('../services/platform-services')
|
|
13
|
+
|
|
14
|
+
const RUNTIME_IMAGE = 'ghcr.io/ossy-se/platform:latest'
|
|
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
|
+
])
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @typedef {Object} PlatformEcsService
|
|
38
|
+
* @property {string} key - short key (`runtime`, `website-ossy`, …) — matches Secrets Manager
|
|
39
|
+
* @property {string} secretName - `{platform}/{key}`
|
|
40
|
+
* @property {string} source - origin label (`platform-runtime` or `services[].name`)
|
|
41
|
+
* @property {string} image - container image (GHCR)
|
|
42
|
+
* @property {string[]} hosts - ALB host-header values for this service (empty = default/catch-all)
|
|
43
|
+
* @property {boolean} isDefault - when true, ALB default action forwards here (runtime)
|
|
44
|
+
* @property {number} containerPort - container listen port
|
|
45
|
+
*/
|
|
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
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Host headers for an HTTP `services[]` entry.
|
|
122
|
+
* Uses optional `hosts` when set; otherwise `[domain]`.
|
|
123
|
+
*
|
|
124
|
+
* @param {{ domain?: string, hosts?: string[] }} entry
|
|
125
|
+
* @returns {string[]}
|
|
126
|
+
*/
|
|
127
|
+
function hostsFromServiceEntry(entry) {
|
|
128
|
+
if (Array.isArray(entry.hosts) && entry.hosts.length > 0) {
|
|
129
|
+
return entry.hosts.filter(Boolean)
|
|
130
|
+
}
|
|
131
|
+
if (entry.domain) return [entry.domain]
|
|
132
|
+
return []
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Hosts routed explicitly to runtime (platform domains not claimed by an HTTP service).
|
|
137
|
+
* Wildcards like `*.ossy.se` are included so CMS apps keep working behind the ALB.
|
|
138
|
+
*
|
|
139
|
+
* @param {{ domains?: string[] }} config
|
|
140
|
+
* @param {Array<{ domain?: string, hosts?: string[] }>} httpEntries
|
|
141
|
+
* @returns {string[]}
|
|
142
|
+
*/
|
|
143
|
+
function runtimeHosts(config, httpEntries) {
|
|
144
|
+
const claimed = new Set(httpEntries.flatMap(hostsFromServiceEntry))
|
|
145
|
+
return (config.domains || []).filter(domain => !claimed.has(domain))
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* List Fargate services for a platform (runtime + HTTP services[]).
|
|
150
|
+
*
|
|
151
|
+
* @param {{
|
|
152
|
+
* platformName: string,
|
|
153
|
+
* domains?: string[],
|
|
154
|
+
* services?: Array<{ name: string, type?: string, domain?: string, hosts?: string[], image?: string }>
|
|
155
|
+
* }} config
|
|
156
|
+
* @returns {PlatformEcsService[]}
|
|
157
|
+
*/
|
|
158
|
+
function listPlatformEcsServices(config) {
|
|
159
|
+
if (!config?.platformName) {
|
|
160
|
+
throw new Error('[platform-ecs-services] platformName is required')
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const httpEntries = listHttpServiceEntries(config)
|
|
164
|
+
const runtimeHostList = runtimeHosts(config, httpEntries)
|
|
165
|
+
|
|
166
|
+
return listPlatformServices(config).map(service => {
|
|
167
|
+
if (service.key === 'runtime') {
|
|
168
|
+
return {
|
|
169
|
+
key: service.key,
|
|
170
|
+
secretName: service.secretName,
|
|
171
|
+
source: service.source,
|
|
172
|
+
image: RUNTIME_IMAGE,
|
|
173
|
+
hosts: runtimeHostList,
|
|
174
|
+
isDefault: true,
|
|
175
|
+
containerPort: CONTAINER_PORT,
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (!service.entry?.image) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
`[platform-ecs-services] HTTP service "${service.key}" is missing image`
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
key: service.key,
|
|
187
|
+
secretName: service.secretName,
|
|
188
|
+
source: service.source,
|
|
189
|
+
image: service.entry.image,
|
|
190
|
+
hosts: hostsFromServiceEntry(service.entry),
|
|
191
|
+
isDefault: false,
|
|
192
|
+
containerPort: CONTAINER_PORT,
|
|
193
|
+
}
|
|
194
|
+
})
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
module.exports = {
|
|
198
|
+
RUNTIME_IMAGE,
|
|
199
|
+
CONTAINER_PORT,
|
|
200
|
+
HEALTH_PATH,
|
|
201
|
+
HEALTHCHECK_FETCH_TIMEOUT_MS,
|
|
202
|
+
CONTAINER_ENVIRONMENT_KEYS,
|
|
203
|
+
secretEnvKeysForEcsContainer,
|
|
204
|
+
containerEnvironment,
|
|
205
|
+
ecsContainerHealthCheckCommand,
|
|
206
|
+
hostsFromServiceEntry,
|
|
207
|
+
listPlatformEcsServices,
|
|
208
|
+
runtimeHosts,
|
|
209
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
const { describe, expect, it } = require('@jest/globals')
|
|
2
|
+
const fs = require('node:fs')
|
|
3
|
+
const path = require('node:path')
|
|
4
|
+
const {
|
|
5
|
+
CONTAINER_ENVIRONMENT_KEYS,
|
|
6
|
+
CONTAINER_PORT,
|
|
7
|
+
HEALTH_PATH,
|
|
8
|
+
HEALTHCHECK_FETCH_TIMEOUT_MS,
|
|
9
|
+
RUNTIME_IMAGE,
|
|
10
|
+
containerEnvironment,
|
|
11
|
+
ecsContainerHealthCheckCommand,
|
|
12
|
+
hostsFromServiceEntry,
|
|
13
|
+
listPlatformEcsServices,
|
|
14
|
+
secretEnvKeysForEcsContainer,
|
|
15
|
+
} = require('./platform-ecs-services')
|
|
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
|
+
|
|
94
|
+
describe('hostsFromServiceEntry', () => {
|
|
95
|
+
it('uses hosts when provided', () => {
|
|
96
|
+
expect(hostsFromServiceEntry({
|
|
97
|
+
domain: 'ossy.se',
|
|
98
|
+
hosts: ['ossy.se', 'api.ossy.se'],
|
|
99
|
+
})).toEqual(['ossy.se', 'api.ossy.se'])
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('falls back to domain', () => {
|
|
103
|
+
expect(hostsFromServiceEntry({ domain: 'ossy.se' })).toEqual(['ossy.se'])
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
describe('listPlatformEcsServices', () => {
|
|
108
|
+
it('includes runtime (default) and HTTP services; skips tcp and ossy-api', () => {
|
|
109
|
+
const services = listPlatformEcsServices({
|
|
110
|
+
platformName: 'ossybot',
|
|
111
|
+
domains: [
|
|
112
|
+
'api.ossy.se',
|
|
113
|
+
'ossy.se',
|
|
114
|
+
'*.ossy.se',
|
|
115
|
+
'www.plexus-sanitas.com',
|
|
116
|
+
'worker.ossy.se',
|
|
117
|
+
],
|
|
118
|
+
services: [
|
|
119
|
+
{
|
|
120
|
+
name: 'ossy-website-ossy',
|
|
121
|
+
domain: 'ossy.se',
|
|
122
|
+
hosts: ['ossy.se', 'api.ossy.se'],
|
|
123
|
+
image: 'ghcr.io/ossy-se/website-ossy:latest',
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
name: 'ossy-website-plexus-sanitas',
|
|
127
|
+
domain: 'www.plexus-sanitas.com',
|
|
128
|
+
image: 'ghcr.io/ossy-se/website-plexus-sanitas:latest',
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
name: 'minecraft',
|
|
132
|
+
type: 'tcp',
|
|
133
|
+
image: 'itzg/minecraft-server',
|
|
134
|
+
ports: [25565],
|
|
135
|
+
},
|
|
136
|
+
],
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
expect(services.map(s => s.key)).toEqual([
|
|
140
|
+
'runtime',
|
|
141
|
+
'website-ossy',
|
|
142
|
+
'website-plexus-sanitas',
|
|
143
|
+
])
|
|
144
|
+
|
|
145
|
+
const runtime = services.find(s => s.key === 'runtime')
|
|
146
|
+
expect(runtime.isDefault).toBe(true)
|
|
147
|
+
expect(runtime.image).toBe(RUNTIME_IMAGE)
|
|
148
|
+
expect(runtime.hosts).toEqual(['*.ossy.se', 'worker.ossy.se'])
|
|
149
|
+
expect(runtime.containerPort).toBe(3000)
|
|
150
|
+
|
|
151
|
+
const website = services.find(s => s.key === 'website-ossy')
|
|
152
|
+
expect(website.isDefault).toBe(false)
|
|
153
|
+
expect(website.hosts).toEqual(['ossy.se', 'api.ossy.se'])
|
|
154
|
+
expect(website.image).toBe('ghcr.io/ossy-se/website-ossy:latest')
|
|
155
|
+
expect(website.secretName).toBe('ossybot/website-ossy')
|
|
156
|
+
|
|
157
|
+
expect(services.some(s => s.key.includes('api'))).toBe(false)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('requires platformName', () => {
|
|
161
|
+
expect(() => listPlatformEcsServices({})).toThrow(/platformName is required/)
|
|
162
|
+
})
|
|
163
|
+
})
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Derive CloudFront aliases + ACM certificate coverage from platforms.json.
|
|
3
|
+
*
|
|
4
|
+
* Part of #558: ACM certs for CloudFront must live in us-east-1 and cover every
|
|
5
|
+
* known platform hostname (apex + wildcards). Customer domains pointed at the
|
|
6
|
+
* EIP from a registrar (not in Route53) stay out of scope.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {Object} EdgeDomainPlan
|
|
11
|
+
* @property {string[]} aliases - CloudFront alternate domain names (viewer Host values)
|
|
12
|
+
* @property {string} primaryDomain - certificate primary domainName
|
|
13
|
+
* @property {string[]} subjectAlternativeNames - certificate SANs (excludes primary)
|
|
14
|
+
* @property {Map<string, string>} validationDomainToRoot - hostname → root domain for DNS validation zones
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Root domain for Route53 hosted-zone lookup (last two labels).
|
|
19
|
+
* @param {string} domain
|
|
20
|
+
* @returns {string}
|
|
21
|
+
*/
|
|
22
|
+
function rootDomainOf(domain) {
|
|
23
|
+
const bare = domain.replace(/^\*\./, '')
|
|
24
|
+
const parts = bare.split('.')
|
|
25
|
+
if (parts.length < 2) {
|
|
26
|
+
throw new Error(`[platform-edge-domains] cannot derive root domain from "${domain}"`)
|
|
27
|
+
}
|
|
28
|
+
return parts.slice(-2).join('.')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Unique, stable sorted list of CloudFront aliases from `domains` + HTTP service hosts.
|
|
33
|
+
*
|
|
34
|
+
* @param {{
|
|
35
|
+
* domains?: string[],
|
|
36
|
+
* services?: Array<{ type?: string, domain?: string, hosts?: string[] }>
|
|
37
|
+
* }} config
|
|
38
|
+
* @returns {string[]}
|
|
39
|
+
*/
|
|
40
|
+
function listEdgeAliases(config) {
|
|
41
|
+
const fromDomains = (config.domains || []).filter(Boolean)
|
|
42
|
+
const fromServices = (config.services || [])
|
|
43
|
+
.filter(entry => (entry.type || 'http') === 'http')
|
|
44
|
+
.flatMap(entry => {
|
|
45
|
+
if (Array.isArray(entry.hosts)) return entry.hosts.filter(Boolean)
|
|
46
|
+
return entry.domain ? [entry.domain] : []
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
return [...new Set([...fromDomains, ...fromServices])].sort((a, b) =>
|
|
50
|
+
a.localeCompare(b)
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Build ACM + CloudFront domain plan for a platform.
|
|
56
|
+
*
|
|
57
|
+
* Primary cert domain prefers an apex (no wildcard, not a subdomain) when present,
|
|
58
|
+
* otherwise the first alias. SANs cover the rest. Validation maps each name to its
|
|
59
|
+
* root hosted zone (wildcards validate on the parent zone).
|
|
60
|
+
*
|
|
61
|
+
* @param {{
|
|
62
|
+
* platformName?: string,
|
|
63
|
+
* domains?: string[],
|
|
64
|
+
* services?: Array<{ type?: string, domain?: string, hosts?: string[] }>
|
|
65
|
+
* }} config
|
|
66
|
+
* @returns {EdgeDomainPlan}
|
|
67
|
+
*/
|
|
68
|
+
function planEdgeDomains(config) {
|
|
69
|
+
if (!config?.platformName) {
|
|
70
|
+
throw new Error('[platform-edge-domains] platformName is required')
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const aliases = listEdgeAliases(config)
|
|
74
|
+
if (aliases.length === 0) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`[platform-edge-domains] platform "${config.platformName}" has no domains for CloudFront`
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const apex = aliases.find(d => !d.includes('*') && d.split('.').length === 2)
|
|
81
|
+
const primaryDomain = apex || aliases[0]
|
|
82
|
+
const subjectAlternativeNames = aliases.filter(d => d !== primaryDomain)
|
|
83
|
+
|
|
84
|
+
/** @type {Map<string, string>} */
|
|
85
|
+
const validationDomainToRoot = new Map(
|
|
86
|
+
aliases.map(alias => [alias, rootDomainOf(alias)])
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
aliases,
|
|
91
|
+
primaryDomain,
|
|
92
|
+
subjectAlternativeNames,
|
|
93
|
+
validationDomainToRoot,
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = {
|
|
98
|
+
rootDomainOf,
|
|
99
|
+
listEdgeAliases,
|
|
100
|
+
planEdgeDomains,
|
|
101
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
const { describe, expect, it } = require('@jest/globals')
|
|
2
|
+
const {
|
|
3
|
+
rootDomainOf,
|
|
4
|
+
listEdgeAliases,
|
|
5
|
+
planEdgeDomains,
|
|
6
|
+
} = require('./platform-edge-domains')
|
|
7
|
+
|
|
8
|
+
describe('rootDomainOf', () => {
|
|
9
|
+
it('strips wildcards and returns the apex', () => {
|
|
10
|
+
expect(rootDomainOf('*.ossy.se')).toBe('ossy.se')
|
|
11
|
+
expect(rootDomainOf('api.ossy.se')).toBe('ossy.se')
|
|
12
|
+
expect(rootDomainOf('www.plexus-sanitas.com')).toBe('plexus-sanitas.com')
|
|
13
|
+
})
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
describe('listEdgeAliases', () => {
|
|
17
|
+
it('unions domains and HTTP service hosts; skips tcp', () => {
|
|
18
|
+
expect(listEdgeAliases({
|
|
19
|
+
domains: ['ossy.se', '*.ossy.se', 'worker.ossy.se'],
|
|
20
|
+
services: [
|
|
21
|
+
{
|
|
22
|
+
name: 'ossy-website-ossy',
|
|
23
|
+
domain: 'ossy.se',
|
|
24
|
+
hosts: ['ossy.se', 'api.ossy.se'],
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: 'minecraft',
|
|
28
|
+
type: 'tcp',
|
|
29
|
+
image: 'itzg/minecraft-server',
|
|
30
|
+
ports: [25565],
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
})).toEqual(['*.ossy.se', 'api.ossy.se', 'ossy.se', 'worker.ossy.se'])
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('planEdgeDomains', () => {
|
|
38
|
+
it('picks apex as primary and maps validation zones', () => {
|
|
39
|
+
const plan = planEdgeDomains({
|
|
40
|
+
platformName: 'ossybot',
|
|
41
|
+
domains: [
|
|
42
|
+
'api.ossy.se',
|
|
43
|
+
'ossy.se',
|
|
44
|
+
'*.ossy.se',
|
|
45
|
+
'www.plexus-sanitas.com',
|
|
46
|
+
'worker.ossy.se',
|
|
47
|
+
],
|
|
48
|
+
services: [
|
|
49
|
+
{
|
|
50
|
+
name: 'ossy-website-ossy',
|
|
51
|
+
domain: 'ossy.se',
|
|
52
|
+
hosts: ['ossy.se', 'api.ossy.se'],
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: 'ossy-website-plexus-sanitas',
|
|
56
|
+
domain: 'www.plexus-sanitas.com',
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
expect(plan.primaryDomain).toBe('ossy.se')
|
|
62
|
+
expect(plan.aliases).toEqual([
|
|
63
|
+
'*.ossy.se',
|
|
64
|
+
'api.ossy.se',
|
|
65
|
+
'ossy.se',
|
|
66
|
+
'worker.ossy.se',
|
|
67
|
+
'www.plexus-sanitas.com',
|
|
68
|
+
])
|
|
69
|
+
expect(plan.subjectAlternativeNames).toEqual([
|
|
70
|
+
'*.ossy.se',
|
|
71
|
+
'api.ossy.se',
|
|
72
|
+
'worker.ossy.se',
|
|
73
|
+
'www.plexus-sanitas.com',
|
|
74
|
+
])
|
|
75
|
+
expect(plan.validationDomainToRoot.get('*.ossy.se')).toBe('ossy.se')
|
|
76
|
+
expect(plan.validationDomainToRoot.get('www.plexus-sanitas.com')).toBe(
|
|
77
|
+
'plexus-sanitas.com'
|
|
78
|
+
)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('requires platformName and at least one alias', () => {
|
|
82
|
+
expect(() => planEdgeDomains({})).toThrow(/platformName is required/)
|
|
83
|
+
expect(() => planEdgeDomains({ platformName: 'x', domains: [] })).toThrow(
|
|
84
|
+
/no domains for CloudFront/
|
|
85
|
+
)
|
|
86
|
+
})
|
|
87
|
+
})
|
package/src/index.js
CHANGED
|
@@ -1,5 +1,49 @@
|
|
|
1
1
|
const { PlatformTemplateService } = require('./template')
|
|
2
|
+
const {
|
|
3
|
+
listPlatformServices,
|
|
4
|
+
listHttpServiceEntries,
|
|
5
|
+
serviceKeyFromName,
|
|
6
|
+
secretNameFor,
|
|
7
|
+
} = require('./services/platform-services')
|
|
8
|
+
const { listPlatformSecretServices } = require('./secrets/platform-secret-services')
|
|
9
|
+
const { syncPlatformSecrets } = require('./secrets/sync-platform-secrets')
|
|
10
|
+
const {
|
|
11
|
+
listPlatformEcsServices,
|
|
12
|
+
hostsFromServiceEntry,
|
|
13
|
+
RUNTIME_IMAGE,
|
|
14
|
+
CONTAINER_PORT,
|
|
15
|
+
HEALTH_PATH,
|
|
16
|
+
HEALTHCHECK_FETCH_TIMEOUT_MS,
|
|
17
|
+
CONTAINER_ENVIRONMENT_KEYS,
|
|
18
|
+
secretEnvKeysForEcsContainer,
|
|
19
|
+
containerEnvironment,
|
|
20
|
+
ecsContainerHealthCheckCommand,
|
|
21
|
+
} = require('./ecs/platform-ecs-services')
|
|
22
|
+
const {
|
|
23
|
+
rootDomainOf,
|
|
24
|
+
listEdgeAliases,
|
|
25
|
+
planEdgeDomains,
|
|
26
|
+
} = require('./edge/platform-edge-domains')
|
|
2
27
|
|
|
3
28
|
module.exports = {
|
|
4
|
-
PlatformTemplateService
|
|
29
|
+
PlatformTemplateService,
|
|
30
|
+
listPlatformServices,
|
|
31
|
+
listHttpServiceEntries,
|
|
32
|
+
serviceKeyFromName,
|
|
33
|
+
secretNameFor,
|
|
34
|
+
listPlatformSecretServices,
|
|
35
|
+
syncPlatformSecrets,
|
|
36
|
+
listPlatformEcsServices,
|
|
37
|
+
hostsFromServiceEntry,
|
|
38
|
+
RUNTIME_IMAGE,
|
|
39
|
+
CONTAINER_PORT,
|
|
40
|
+
HEALTH_PATH,
|
|
41
|
+
HEALTHCHECK_FETCH_TIMEOUT_MS,
|
|
42
|
+
CONTAINER_ENVIRONMENT_KEYS,
|
|
43
|
+
secretEnvKeysForEcsContainer,
|
|
44
|
+
containerEnvironment,
|
|
45
|
+
ecsContainerHealthCheckCommand,
|
|
46
|
+
rootDomainOf,
|
|
47
|
+
listEdgeAliases,
|
|
48
|
+
planEdgeDomains,
|
|
5
49
|
}
|
|
@@ -2,16 +2,44 @@ const { Stack, Duration } = require('aws-cdk-lib')
|
|
|
2
2
|
const {
|
|
3
3
|
HostedZone,
|
|
4
4
|
ARecord,
|
|
5
|
+
AaaaRecord,
|
|
5
6
|
MxRecord,
|
|
6
7
|
RecordTarget
|
|
7
8
|
} = require('aws-cdk-lib/aws-route53')
|
|
8
9
|
|
|
10
|
+
/** Global Route53 hosted zone id for CloudFront alias records. */
|
|
11
|
+
const CLOUDFRONT_HOSTED_ZONE_ID = 'Z2FDTNDATAQYW2'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Alias target for a CloudFront distribution domain name (cross-region safe).
|
|
15
|
+
* Avoids requiring the Distribution construct in the platform-region DnsStack.
|
|
16
|
+
*/
|
|
17
|
+
class CloudFrontDomainTarget {
|
|
18
|
+
/**
|
|
19
|
+
* @param {string} domainName - e.g. d111111abcdef8.cloudfront.net
|
|
20
|
+
*/
|
|
21
|
+
constructor(domainName) {
|
|
22
|
+
this.domainName = domainName
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @returns {import('aws-cdk-lib/aws-route53').AliasRecordTargetConfig}
|
|
27
|
+
*/
|
|
28
|
+
bind() {
|
|
29
|
+
// IAliasRecordTarget shape (not the raw CloudFormation AliasTargetProperty).
|
|
30
|
+
return {
|
|
31
|
+
hostedZoneId: CLOUDFRONT_HOSTED_ZONE_ID,
|
|
32
|
+
dnsName: this.domainName,
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
9
37
|
/**
|
|
10
38
|
* DnsStackProps
|
|
11
39
|
* @namespace DnsStack
|
|
12
40
|
* @typedef {Object} DnsStackProps
|
|
13
41
|
* @property {PlatformConfig} config - platform config
|
|
14
|
-
* @property {string}
|
|
42
|
+
* @property {string} cloudFrontDomainName - CloudFront app distribution domain (#558 / #560)
|
|
15
43
|
*/
|
|
16
44
|
|
|
17
45
|
/**
|
|
@@ -27,9 +55,13 @@ class DnsStack extends Stack {
|
|
|
27
55
|
constructor(scope, id, props) {
|
|
28
56
|
super(scope, id, props)
|
|
29
57
|
|
|
58
|
+
if (!props.cloudFrontDomainName) {
|
|
59
|
+
throw new Error('[DnsStack] cloudFrontDomainName is required')
|
|
60
|
+
}
|
|
61
|
+
|
|
30
62
|
// Group domains by root domain so we can look up each hosted zone once.
|
|
31
63
|
const domainsByRoot = (props.config.domains ?? []).reduce((map, domain) => {
|
|
32
|
-
const parts = domain.split('.')
|
|
64
|
+
const parts = domain.replace(/^\*\./, '').split('.')
|
|
33
65
|
const rootDomain = parts.slice(-2).join('.')
|
|
34
66
|
map.has(rootDomain)
|
|
35
67
|
? map.set(rootDomain, [...map.get(rootDomain), domain])
|
|
@@ -41,10 +73,20 @@ class DnsStack extends Stack {
|
|
|
41
73
|
const zone = HostedZone.fromLookup(this, `${rootDomain}-zone`, { domainName: rootDomain })
|
|
42
74
|
|
|
43
75
|
domains.forEach(domain => {
|
|
76
|
+
// Known platform domains → CloudFront (not EIP). Alias A + AAAA.
|
|
77
|
+
const cloudFrontTarget = RecordTarget.fromAlias(
|
|
78
|
+
new CloudFrontDomainTarget(props.cloudFrontDomainName)
|
|
79
|
+
)
|
|
44
80
|
new ARecord(this, `${domain}-record`, {
|
|
45
81
|
zone,
|
|
46
82
|
recordName: domain,
|
|
47
|
-
target:
|
|
83
|
+
target: cloudFrontTarget,
|
|
84
|
+
ttl: Duration.seconds(60)
|
|
85
|
+
})
|
|
86
|
+
new AaaaRecord(this, `${domain}-aaaa-record`, {
|
|
87
|
+
zone,
|
|
88
|
+
recordName: domain,
|
|
89
|
+
target: cloudFrontTarget,
|
|
48
90
|
ttl: Duration.seconds(60)
|
|
49
91
|
})
|
|
50
92
|
})
|
|
@@ -73,5 +115,7 @@ class DnsStack extends Stack {
|
|
|
73
115
|
}
|
|
74
116
|
|
|
75
117
|
module.exports = {
|
|
118
|
+
CLOUDFRONT_HOSTED_ZONE_ID,
|
|
119
|
+
CloudFrontDomainTarget,
|
|
76
120
|
DnsStack
|
|
77
121
|
}
|