@ossy/deployment-tools 3.0.8 → 3.1.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 +18 -0
- package/README.md +160 -113
- 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 +111 -0
- package/src/ecs/platform-ecs-services.spec.js +77 -0
- package/src/edge/platform-edge-domains.js +101 -0
- package/src/edge/platform-edge-domains.spec.js +87 -0
- package/src/index.js +33 -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 +326 -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,77 @@
|
|
|
1
|
+
const { describe, expect, it } = require('@jest/globals')
|
|
2
|
+
const {
|
|
3
|
+
RUNTIME_IMAGE,
|
|
4
|
+
hostsFromServiceEntry,
|
|
5
|
+
listPlatformEcsServices,
|
|
6
|
+
} = require('./platform-ecs-services')
|
|
7
|
+
|
|
8
|
+
describe('hostsFromServiceEntry', () => {
|
|
9
|
+
it('uses hosts when provided', () => {
|
|
10
|
+
expect(hostsFromServiceEntry({
|
|
11
|
+
domain: 'ossy.se',
|
|
12
|
+
hosts: ['ossy.se', 'api.ossy.se'],
|
|
13
|
+
})).toEqual(['ossy.se', 'api.ossy.se'])
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('falls back to domain', () => {
|
|
17
|
+
expect(hostsFromServiceEntry({ domain: 'ossy.se' })).toEqual(['ossy.se'])
|
|
18
|
+
})
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
describe('listPlatformEcsServices', () => {
|
|
22
|
+
it('includes runtime (default) and HTTP services; skips tcp and ossy-api', () => {
|
|
23
|
+
const services = listPlatformEcsServices({
|
|
24
|
+
platformName: 'ossybot',
|
|
25
|
+
domains: [
|
|
26
|
+
'api.ossy.se',
|
|
27
|
+
'ossy.se',
|
|
28
|
+
'*.ossy.se',
|
|
29
|
+
'www.plexus-sanitas.com',
|
|
30
|
+
'worker.ossy.se',
|
|
31
|
+
],
|
|
32
|
+
services: [
|
|
33
|
+
{
|
|
34
|
+
name: 'ossy-website-ossy',
|
|
35
|
+
domain: 'ossy.se',
|
|
36
|
+
hosts: ['ossy.se', 'api.ossy.se'],
|
|
37
|
+
image: 'ghcr.io/ossy-se/website-ossy:latest',
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: 'ossy-website-plexus-sanitas',
|
|
41
|
+
domain: 'www.plexus-sanitas.com',
|
|
42
|
+
image: 'ghcr.io/ossy-se/website-plexus-sanitas:latest',
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: 'minecraft',
|
|
46
|
+
type: 'tcp',
|
|
47
|
+
image: 'itzg/minecraft-server',
|
|
48
|
+
ports: [25565],
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
expect(services.map(s => s.key)).toEqual([
|
|
54
|
+
'runtime',
|
|
55
|
+
'website-ossy',
|
|
56
|
+
'website-plexus-sanitas',
|
|
57
|
+
])
|
|
58
|
+
|
|
59
|
+
const runtime = services.find(s => s.key === 'runtime')
|
|
60
|
+
expect(runtime.isDefault).toBe(true)
|
|
61
|
+
expect(runtime.image).toBe(RUNTIME_IMAGE)
|
|
62
|
+
expect(runtime.hosts).toEqual(['*.ossy.se', 'worker.ossy.se'])
|
|
63
|
+
expect(runtime.containerPort).toBe(3000)
|
|
64
|
+
|
|
65
|
+
const website = services.find(s => s.key === 'website-ossy')
|
|
66
|
+
expect(website.isDefault).toBe(false)
|
|
67
|
+
expect(website.hosts).toEqual(['ossy.se', 'api.ossy.se'])
|
|
68
|
+
expect(website.image).toBe('ghcr.io/ossy-se/website-ossy:latest')
|
|
69
|
+
expect(website.secretName).toBe('ossybot/website-ossy')
|
|
70
|
+
|
|
71
|
+
expect(services.some(s => s.key.includes('api'))).toBe(false)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('requires platformName', () => {
|
|
75
|
+
expect(() => listPlatformEcsServices({})).toThrow(/platformName is required/)
|
|
76
|
+
})
|
|
77
|
+
})
|
|
@@ -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,37 @@
|
|
|
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
|
+
} = require('./ecs/platform-ecs-services')
|
|
16
|
+
const {
|
|
17
|
+
rootDomainOf,
|
|
18
|
+
listEdgeAliases,
|
|
19
|
+
planEdgeDomains,
|
|
20
|
+
} = require('./edge/platform-edge-domains')
|
|
2
21
|
|
|
3
22
|
module.exports = {
|
|
4
|
-
PlatformTemplateService
|
|
23
|
+
PlatformTemplateService,
|
|
24
|
+
listPlatformServices,
|
|
25
|
+
listHttpServiceEntries,
|
|
26
|
+
serviceKeyFromName,
|
|
27
|
+
secretNameFor,
|
|
28
|
+
listPlatformSecretServices,
|
|
29
|
+
syncPlatformSecrets,
|
|
30
|
+
listPlatformEcsServices,
|
|
31
|
+
hostsFromServiceEntry,
|
|
32
|
+
RUNTIME_IMAGE,
|
|
33
|
+
CONTAINER_PORT,
|
|
34
|
+
rootDomainOf,
|
|
35
|
+
listEdgeAliases,
|
|
36
|
+
planEdgeDomains,
|
|
5
37
|
}
|
|
@@ -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
|
}
|
|
@@ -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
|
+
}
|