@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,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} containerDeploymentTargetPublicIp - public ip address of ec2 instance
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: RecordTarget.fromIpAddresses(props.containerDeploymentTargetPublicIp),
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
  }