@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,213 @@
|
|
|
1
|
+
/* eslint-disable no-new */
|
|
2
|
+
const { CfnOutput, Stack, Duration } = require('aws-cdk-lib')
|
|
3
|
+
const {
|
|
4
|
+
Certificate,
|
|
5
|
+
CertificateValidation,
|
|
6
|
+
} = require('aws-cdk-lib/aws-certificatemanager')
|
|
7
|
+
const {
|
|
8
|
+
AllowedMethods,
|
|
9
|
+
CachePolicy,
|
|
10
|
+
CachedMethods,
|
|
11
|
+
Distribution,
|
|
12
|
+
OriginProtocolPolicy,
|
|
13
|
+
OriginRequestPolicy,
|
|
14
|
+
PriceClass,
|
|
15
|
+
ViewerProtocolPolicy,
|
|
16
|
+
} = require('aws-cdk-lib/aws-cloudfront')
|
|
17
|
+
const { HttpOrigin } = require('aws-cdk-lib/aws-cloudfront-origins')
|
|
18
|
+
const { HostedZone } = require('aws-cdk-lib/aws-route53')
|
|
19
|
+
const { CfnWebACL } = require('aws-cdk-lib/aws-wafv2')
|
|
20
|
+
const { planEdgeDomains } = require('../edge/platform-edge-domains')
|
|
21
|
+
|
|
22
|
+
/** CloudFront ACM + WAFv2 CloudFront-scope resources must live in us-east-1. */
|
|
23
|
+
const CLOUDFRONT_REGION = 'us-east-1'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @typedef {Object} PlatformEdgeStackProps
|
|
27
|
+
* @property {import('../config/platform-config').PlatformConfig} config
|
|
28
|
+
* @property {string} albDnsName - DNS name of the platform ALB (CloudFront origin)
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* CloudFront + WAF + ACM in front of the platform ALB (#558 / epic #553).
|
|
33
|
+
*
|
|
34
|
+
* - Separate distribution from the `/media` CDN in `storage-static`
|
|
35
|
+
* - Forwards viewer `Host` (and cookies/query) so ALB host rules keep working
|
|
36
|
+
* - ACM certificate in us-east-1 covering known platform domains
|
|
37
|
+
* - WAFv2 web ACL (CLOUDFRONT scope) associated with the distribution
|
|
38
|
+
*
|
|
39
|
+
* Deploy this stack with `env.region = us-east-1`. DNS alias cutover is wired
|
|
40
|
+
* from `DnsStack` using the distribution domain name output.
|
|
41
|
+
*/
|
|
42
|
+
class PlatformEdgeStack extends Stack {
|
|
43
|
+
/**
|
|
44
|
+
* @param {object} scope
|
|
45
|
+
* @param {string} id
|
|
46
|
+
* @param {PlatformEdgeStackProps} props
|
|
47
|
+
*/
|
|
48
|
+
constructor(scope, id, props) {
|
|
49
|
+
super(scope, id, props)
|
|
50
|
+
|
|
51
|
+
if (!props?.config) {
|
|
52
|
+
throw new Error('[PlatformEdgeStack] No config provided')
|
|
53
|
+
}
|
|
54
|
+
if (!props.albDnsName) {
|
|
55
|
+
throw new Error('[PlatformEdgeStack] albDnsName is required')
|
|
56
|
+
}
|
|
57
|
+
if (Stack.of(this).region !== CLOUDFRONT_REGION) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`[PlatformEdgeStack] must be deployed in ${CLOUDFRONT_REGION} (got ${Stack.of(this).region})`
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const platformName = props.config.platformName
|
|
64
|
+
const plan = planEdgeDomains(props.config)
|
|
65
|
+
|
|
66
|
+
/** @type {Map<string, import('aws-cdk-lib/aws-route53').IHostedZone>} */
|
|
67
|
+
const zonesByRoot = new Map(
|
|
68
|
+
[...new Set(plan.validationDomainToRoot.values())].map(rootDomain => [
|
|
69
|
+
rootDomain,
|
|
70
|
+
HostedZone.fromLookup(this, `${toConstructId(rootDomain)}Zone`, {
|
|
71
|
+
domainName: rootDomain,
|
|
72
|
+
}),
|
|
73
|
+
])
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
/** @type {Record<string, import('aws-cdk-lib/aws-route53').IHostedZone>} */
|
|
77
|
+
const validationZones = Object.fromEntries(
|
|
78
|
+
[...plan.validationDomainToRoot.entries()].map(([domain, root]) => [
|
|
79
|
+
domain,
|
|
80
|
+
zonesByRoot.get(root),
|
|
81
|
+
])
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
const certificate = new Certificate(this, 'ViewerCertificate', {
|
|
85
|
+
domainName: plan.primaryDomain,
|
|
86
|
+
subjectAlternativeNames: plan.subjectAlternativeNames,
|
|
87
|
+
validation: CertificateValidation.fromDnsMultiZone(validationZones),
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
const webAcl = new CfnWebACL(this, 'WebAcl', {
|
|
91
|
+
name: truncateName(`${platformName}-app-edge`, 128),
|
|
92
|
+
description: `WAF for ${platformName} CloudFront app distribution`,
|
|
93
|
+
scope: 'CLOUDFRONT',
|
|
94
|
+
defaultAction: { allow: {} },
|
|
95
|
+
visibilityConfig: {
|
|
96
|
+
cloudWatchMetricsEnabled: true,
|
|
97
|
+
metricName: truncateName(`${platformName}AppEdge`, 128),
|
|
98
|
+
sampledRequestsEnabled: true,
|
|
99
|
+
},
|
|
100
|
+
rules: [
|
|
101
|
+
{
|
|
102
|
+
name: 'AWSManagedRulesCommonRuleSet',
|
|
103
|
+
priority: 1,
|
|
104
|
+
overrideAction: { none: {} },
|
|
105
|
+
statement: {
|
|
106
|
+
managedRuleGroupStatement: {
|
|
107
|
+
vendorName: 'AWS',
|
|
108
|
+
name: 'AWSManagedRulesCommonRuleSet',
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
visibilityConfig: {
|
|
112
|
+
cloudWatchMetricsEnabled: true,
|
|
113
|
+
metricName: 'AWSManagedRulesCommonRuleSet',
|
|
114
|
+
sampledRequestsEnabled: true,
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
name: 'AWSManagedRulesKnownBadInputsRuleSet',
|
|
119
|
+
priority: 2,
|
|
120
|
+
overrideAction: { none: {} },
|
|
121
|
+
statement: {
|
|
122
|
+
managedRuleGroupStatement: {
|
|
123
|
+
vendorName: 'AWS',
|
|
124
|
+
name: 'AWSManagedRulesKnownBadInputsRuleSet',
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
visibilityConfig: {
|
|
128
|
+
cloudWatchMetricsEnabled: true,
|
|
129
|
+
metricName: 'AWSManagedRulesKnownBadInputsRuleSet',
|
|
130
|
+
sampledRequestsEnabled: true,
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
const origin = new HttpOrigin(props.albDnsName, {
|
|
137
|
+
protocolPolicy: OriginProtocolPolicy.HTTP_ONLY,
|
|
138
|
+
readTimeout: Duration.seconds(60),
|
|
139
|
+
keepaliveTimeout: Duration.seconds(60),
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
// CloudFront associates WAF via webAclId (not CfnWebACLAssociation).
|
|
143
|
+
const distribution = new Distribution(this, 'AppDistribution', {
|
|
144
|
+
comment: `${platformName} app edge (ALB origin) — not the /media CDN`,
|
|
145
|
+
domainNames: plan.aliases,
|
|
146
|
+
certificate,
|
|
147
|
+
priceClass: PriceClass.PRICE_CLASS_100,
|
|
148
|
+
webAclId: webAcl.attrArn,
|
|
149
|
+
defaultBehavior: {
|
|
150
|
+
origin,
|
|
151
|
+
viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
|
|
152
|
+
allowedMethods: AllowedMethods.ALLOW_ALL,
|
|
153
|
+
cachedMethods: CachedMethods.CACHE_GET_HEAD_OPTIONS,
|
|
154
|
+
// Dynamic app traffic — do not cache at the edge by default.
|
|
155
|
+
cachePolicy: CachePolicy.CACHING_DISABLED,
|
|
156
|
+
// Forward Host so ALB listener host rules match viewer hostnames.
|
|
157
|
+
originRequestPolicy: OriginRequestPolicy.ALL_VIEWER,
|
|
158
|
+
},
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
this.distribution = distribution
|
|
162
|
+
this.certificate = certificate
|
|
163
|
+
this.domainName = distribution.distributionDomainName
|
|
164
|
+
this.distributionId = distribution.distributionId
|
|
165
|
+
this.aliases = plan.aliases
|
|
166
|
+
|
|
167
|
+
new CfnOutput(this, 'DistributionDomainName', {
|
|
168
|
+
value: distribution.distributionDomainName,
|
|
169
|
+
description: 'CloudFront domain for app traffic (Route53 alias target)',
|
|
170
|
+
exportName: `${platformName}-AppCloudFrontDomainName`,
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
new CfnOutput(this, 'DistributionId', {
|
|
174
|
+
value: distribution.distributionId,
|
|
175
|
+
description: 'CloudFront distribution id for app traffic',
|
|
176
|
+
exportName: `${platformName}-AppCloudFrontDistributionId`,
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
new CfnOutput(this, 'ViewerCertificateArn', {
|
|
180
|
+
value: certificate.certificateArn,
|
|
181
|
+
description: 'ACM certificate ARN (us-east-1) for CloudFront aliases',
|
|
182
|
+
exportName: `${platformName}-AppCloudFrontCertificateArn`,
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
new CfnOutput(this, 'WebAclArn', {
|
|
186
|
+
value: webAcl.attrArn,
|
|
187
|
+
description: 'WAFv2 web ACL ARN associated with the app distribution',
|
|
188
|
+
exportName: `${platformName}-AppCloudFrontWebAclArn`,
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* @param {string} key
|
|
195
|
+
* @returns {string}
|
|
196
|
+
*/
|
|
197
|
+
function toConstructId(key) {
|
|
198
|
+
return key.replace(/[^A-Za-z0-9]/g, '')
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* @param {string} name
|
|
203
|
+
* @param {number} max
|
|
204
|
+
* @returns {string}
|
|
205
|
+
*/
|
|
206
|
+
function truncateName(name, max) {
|
|
207
|
+
return name.length <= max ? name : name.slice(0, max)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
module.exports = {
|
|
211
|
+
CLOUDFRONT_REGION,
|
|
212
|
+
PlatformEdgeStack,
|
|
213
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/* eslint-disable no-new */
|
|
2
|
+
const { CfnOutput, RemovalPolicy, Stack } = require('aws-cdk-lib')
|
|
3
|
+
const { Role, ServicePrincipal } = require('aws-cdk-lib/aws-iam')
|
|
4
|
+
const { Secret } = require('aws-cdk-lib/aws-secretsmanager')
|
|
5
|
+
const { listPlatformSecretServices } = require('../secrets/platform-secret-services')
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* PlatformSecretsStackProps
|
|
9
|
+
* @typedef {Object} PlatformSecretsStackProps
|
|
10
|
+
* @property {import('../config/platform-config').PlatformConfig} config - platform config
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Creates one Secrets Manager secret per platform service (runtime + HTTP services[])
|
|
15
|
+
* and an ECS task role that can read that secret. Values are not set here — sync them
|
|
16
|
+
* from `platforms.json` with `npm run sync-secrets` after deploy (see #555).
|
|
17
|
+
*
|
|
18
|
+
* Also creates `{platform}/ghcr-pull` for private GHCR image pulls from ECS (#557).
|
|
19
|
+
* Put `{"username":"<github>","password":"<pat with read:packages>"}` into that secret
|
|
20
|
+
* before tasks can start (not synced from platforms.json).
|
|
21
|
+
*
|
|
22
|
+
* Long-lived secrets + task roles for ECS; independent of edge/DNS stack lifecycle.
|
|
23
|
+
*/
|
|
24
|
+
class PlatformSecretsStack extends Stack {
|
|
25
|
+
/**
|
|
26
|
+
* @param {object} scope
|
|
27
|
+
* @param {string} id
|
|
28
|
+
* @param {PlatformSecretsStackProps} props
|
|
29
|
+
*/
|
|
30
|
+
constructor(scope, id, props) {
|
|
31
|
+
super(scope, id, props)
|
|
32
|
+
|
|
33
|
+
if (!props?.config) {
|
|
34
|
+
throw new Error('[PlatformSecretsStack] No config provided')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const platformName = props.config.platformName
|
|
38
|
+
/** @type {Record<string, import('aws-cdk-lib/aws-secretsmanager').ISecret>} */
|
|
39
|
+
this.secrets = {}
|
|
40
|
+
/** @type {Record<string, import('aws-cdk-lib/aws-iam').IRole>} */
|
|
41
|
+
this.taskRoles = {}
|
|
42
|
+
|
|
43
|
+
this.ghcrPullSecret = new Secret(this, 'GhcrPull', {
|
|
44
|
+
secretName: `${platformName}/ghcr-pull`,
|
|
45
|
+
description: `GHCR docker pull credentials for ${platformName} ECS (username + password)`,
|
|
46
|
+
removalPolicy: RemovalPolicy.RETAIN,
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
new CfnOutput(this, 'GhcrPullSecretArn', {
|
|
50
|
+
value: this.ghcrPullSecret.secretArn,
|
|
51
|
+
description: `Secrets Manager ARN for ${platformName}/ghcr-pull`,
|
|
52
|
+
exportName: `${platformName}-SecretArn-ghcr-pull`,
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const services = listPlatformSecretServices(props.config)
|
|
56
|
+
|
|
57
|
+
for (const service of services) {
|
|
58
|
+
const idSuffix = toConstructId(service.key)
|
|
59
|
+
|
|
60
|
+
const secret = new Secret(this, `Secret${idSuffix}`, {
|
|
61
|
+
secretName: service.secretName,
|
|
62
|
+
description: `Platform secrets for ${service.source} (${platformName})`,
|
|
63
|
+
removalPolicy: RemovalPolicy.RETAIN,
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
// ECS Fargate task role (#557). Grant read only for this service secret.
|
|
67
|
+
const taskRole = new Role(this, `TaskRole${idSuffix}`, {
|
|
68
|
+
roleName: `${platformName}-${service.key}-task`,
|
|
69
|
+
description: `ECS task role for ${service.secretName} (read Secrets Manager)`,
|
|
70
|
+
assumedBy: new ServicePrincipal('ecs-tasks.amazonaws.com'),
|
|
71
|
+
})
|
|
72
|
+
secret.grantRead(taskRole)
|
|
73
|
+
|
|
74
|
+
this.secrets[service.key] = secret
|
|
75
|
+
this.taskRoles[service.key] = taskRole
|
|
76
|
+
|
|
77
|
+
new CfnOutput(this, `SecretArn${idSuffix}`, {
|
|
78
|
+
value: secret.secretArn,
|
|
79
|
+
description: `Secrets Manager ARN for ${service.secretName}`,
|
|
80
|
+
exportName: `${platformName}-SecretArn-${service.key}`,
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
new CfnOutput(this, `TaskRoleArn${idSuffix}`, {
|
|
84
|
+
value: taskRole.roleArn,
|
|
85
|
+
description: `ECS task role ARN for ${service.secretName}`,
|
|
86
|
+
exportName: `${platformName}-TaskRoleArn-${service.key}`,
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* CloudFormation-safe construct id fragment from a service key.
|
|
94
|
+
* @param {string} key
|
|
95
|
+
* @returns {string}
|
|
96
|
+
*/
|
|
97
|
+
function toConstructId(key) {
|
|
98
|
+
return key.replace(/[^A-Za-z0-9]/g, '')
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
module.exports = {
|
|
102
|
+
PlatformSecretsStack,
|
|
103
|
+
}
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
/* eslint-disable no-new */
|
|
2
2
|
const { Stage } = require('aws-cdk-lib')
|
|
3
3
|
const { StorageStaticStack } = require('./storage-static-stack')
|
|
4
|
-
const { DeploymentTargetStack } = require('./deployment-target-stack')
|
|
5
4
|
const { DnsStack } = require('./dns-stack')
|
|
6
5
|
const { SesStack } = require('./ses-stack')
|
|
6
|
+
const { PlatformSecretsStack } = require('./platform-secrets-stack')
|
|
7
|
+
const { PlatformEcsStack } = require('./platform-ecs-stack')
|
|
8
|
+
const { PlatformCiStack } = require('./platform-ci-stack')
|
|
9
|
+
const {
|
|
10
|
+
PlatformEdgeStack,
|
|
11
|
+
CLOUDFRONT_REGION,
|
|
12
|
+
} = require('./platform-edge-stack')
|
|
7
13
|
|
|
8
14
|
/**
|
|
9
15
|
* PlatformStage groups all stacks that make up a single platform deployment.
|
|
@@ -12,7 +18,10 @@ const { SesStack } = require('./ses-stack')
|
|
|
12
18
|
* cdk deploy 'ossybot/**' --profile ossybot
|
|
13
19
|
*
|
|
14
20
|
* Stack names within the stage are automatically prefixed with the platform name,
|
|
15
|
-
* e.g. ossybot/
|
|
21
|
+
* e.g. ossybot/storage-static, ossybot/platform-ecs, ossybot/platform-edge, etc.
|
|
22
|
+
*
|
|
23
|
+
* EC2 + Caddy (`deployment-target`) is removed from the stage (#560). Destroy any
|
|
24
|
+
* leftover CloudFormation stack of that name after edge + DNS are live.
|
|
16
25
|
*/
|
|
17
26
|
class PlatformStage extends Stage {
|
|
18
27
|
constructor(scope, id, props) {
|
|
@@ -20,15 +29,33 @@ class PlatformStage extends Stage {
|
|
|
20
29
|
|
|
21
30
|
const storageStatic = new StorageStaticStack(this, 'storage-static', props)
|
|
22
31
|
|
|
23
|
-
const
|
|
32
|
+
const platformSecrets = new PlatformSecretsStack(this, 'platform-secrets', props)
|
|
33
|
+
|
|
34
|
+
const platformEcs = new PlatformEcsStack(this, 'platform-ecs', {
|
|
24
35
|
...props,
|
|
25
36
|
bucket: storageStatic.bucket,
|
|
26
|
-
cdnDomainName: storageStatic.cdnDomainName
|
|
37
|
+
cdnDomainName: storageStatic.cdnDomainName,
|
|
38
|
+
secrets: platformSecrets.secrets,
|
|
39
|
+
taskRoles: platformSecrets.taskRoles,
|
|
40
|
+
ghcrPullSecret: platformSecrets.ghcrPullSecret,
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
new PlatformCiStack(this, 'platform-ci', props)
|
|
44
|
+
|
|
45
|
+
// CloudFront + WAF + ACM (#558). Must be us-east-1 for viewer certs / CLOUDFRONT WAF.
|
|
46
|
+
const platformEdge = new PlatformEdgeStack(this, 'platform-edge', {
|
|
47
|
+
config: props.config,
|
|
48
|
+
albDnsName: platformEcs.loadBalancer.loadBalancerDnsName,
|
|
49
|
+
env: {
|
|
50
|
+
account: props.env?.account ?? props.config.awsAccountId,
|
|
51
|
+
region: CLOUDFRONT_REGION,
|
|
52
|
+
},
|
|
27
53
|
})
|
|
28
54
|
|
|
55
|
+
// Known Route53 domains → CloudFront (#558 / #560). No EIP path.
|
|
29
56
|
new DnsStack(this, 'dns', {
|
|
30
57
|
...props,
|
|
31
|
-
|
|
58
|
+
cloudFrontDomainName: platformEdge.domainName,
|
|
32
59
|
})
|
|
33
60
|
|
|
34
61
|
new SesStack(this, 'ses', props)
|
|
@@ -9,13 +9,12 @@ const { S3BucketOrigin } = require('aws-cdk-lib/aws-cloudfront-origins')
|
|
|
9
9
|
* StorageStaticStack provisions the S3 bucket used for static assets and media,
|
|
10
10
|
* a CloudFront CDN distribution serving the /media prefix, and a daily backup plan.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* or replaced, avoiding CloudFormation cross-stack export conflicts.
|
|
12
|
+
* Kept separate from compute stacks so media storage is not replaced when ECS/edge
|
|
13
|
+
* stacks change (avoids CloudFormation cross-stack export conflicts).
|
|
15
14
|
*
|
|
16
|
-
* Exports (consumed by
|
|
15
|
+
* Exports (consumed by platform-ecs via cross-stack reference):
|
|
17
16
|
* - `bucket` — the S3 Bucket construct
|
|
18
|
-
* - `cdnDomainName` — the CloudFront distribution domain name
|
|
17
|
+
* - `cdnDomainName` — the CloudFront distribution domain name (media CDN)
|
|
19
18
|
*/
|
|
20
19
|
class StorageStaticStack extends Stack {
|
|
21
20
|
constructor(scope, id, props) {
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform secret service targets for Secrets Manager.
|
|
3
|
+
*
|
|
4
|
+
* Naming and service set come from shared `platform-services` (#557 review).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const { listPlatformServices } = require('../services/platform-services')
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {Object} PlatformSecretService
|
|
11
|
+
* @property {string} key - short key used in the secret name (after platform prefix)
|
|
12
|
+
* @property {string} secretName - full Secrets Manager name `{platform}/{key}`
|
|
13
|
+
* @property {string} source - origin label for docs/logs (`platform-runtime` or `services[].name`)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* List of platform services that should have a Secrets Manager secret.
|
|
18
|
+
*
|
|
19
|
+
* @param {{ platformName: string, services?: Array<{ name: string, type?: string }> }} config
|
|
20
|
+
* @returns {PlatformSecretService[]}
|
|
21
|
+
*/
|
|
22
|
+
function listPlatformSecretServices(config) {
|
|
23
|
+
return listPlatformServices(config).map(({ key, secretName, source }) => ({
|
|
24
|
+
key,
|
|
25
|
+
secretName,
|
|
26
|
+
source,
|
|
27
|
+
}))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
module.exports = {
|
|
31
|
+
listPlatformSecretServices,
|
|
32
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
const { describe, expect, it } = require('@jest/globals')
|
|
2
|
+
const { listPlatformSecretServices } = require('./platform-secret-services')
|
|
3
|
+
|
|
4
|
+
describe('listPlatformSecretServices', () => {
|
|
5
|
+
it('includes runtime and HTTP services, skips tcp and ossy-api', () => {
|
|
6
|
+
const services = listPlatformSecretServices({
|
|
7
|
+
platformName: 'ossybot',
|
|
8
|
+
services: [
|
|
9
|
+
{
|
|
10
|
+
name: 'ossy-website-ossy',
|
|
11
|
+
domain: 'ossy.se',
|
|
12
|
+
image: 'ghcr.io/ossy-se/website-ossy:latest',
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
name: 'ossy-api',
|
|
16
|
+
domain: 'api.ossy.se',
|
|
17
|
+
image: 'ghcr.io/ossy-se/ossy-api:latest',
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
name: 'minecraft',
|
|
21
|
+
type: 'tcp',
|
|
22
|
+
image: 'itzg/minecraft-server',
|
|
23
|
+
ports: [25565],
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
expect(services.map(s => s.secretName)).toEqual([
|
|
29
|
+
'ossybot/runtime',
|
|
30
|
+
'ossybot/website-ossy',
|
|
31
|
+
])
|
|
32
|
+
expect(services.find(s => s.key === 'runtime').source).toBe('platform-runtime')
|
|
33
|
+
expect(services.find(s => s.key === 'website-ossy').source).toBe('ossy-website-ossy')
|
|
34
|
+
expect(services.some(s => s.key === 'api' || s.secretName.endsWith('/api'))).toBe(false)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('requires platformName', () => {
|
|
38
|
+
expect(() => listPlatformSecretServices({})).toThrow(/platformName is required/)
|
|
39
|
+
})
|
|
40
|
+
})
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Upsert Secrets Manager secret *values* from platforms.json env blocks.
|
|
4
|
+
*
|
|
5
|
+
* CDK (`PlatformSecretsStack`) owns the secret resources and IAM. This script
|
|
6
|
+
* only writes values — it never prints secret contents.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* npm run sync-secrets -- --profile ossybot
|
|
10
|
+
* node src/secrets/sync-platform-secrets.js --platforms-path ../infrastructure/platforms.json --profile ossybot
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const { resolve } = require('path')
|
|
14
|
+
const {
|
|
15
|
+
SecretsManagerClient,
|
|
16
|
+
PutSecretValueCommand,
|
|
17
|
+
DescribeSecretCommand,
|
|
18
|
+
} = require('@aws-sdk/client-secrets-manager')
|
|
19
|
+
const { PlatformTemplateService } = require('../template')
|
|
20
|
+
const { PlatformConfigService } = require('../config')
|
|
21
|
+
const { listPlatformSecretServices } = require('./platform-secret-services')
|
|
22
|
+
const { logInfo, logError } = require('../log')
|
|
23
|
+
|
|
24
|
+
function getArg(argv, name) {
|
|
25
|
+
const flag = `--${name}`
|
|
26
|
+
const index = argv.indexOf(flag)
|
|
27
|
+
if (index === -1) return undefined
|
|
28
|
+
return argv[index + 1]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function hasFlag(argv, name) {
|
|
32
|
+
return argv.includes(`--${name}`)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {object} options
|
|
37
|
+
* @param {string} options.platformsPath
|
|
38
|
+
* @param {string=} options.profile
|
|
39
|
+
* @param {string=} options.platformName - sync a single platform when set
|
|
40
|
+
* @param {boolean=} options.dryRun
|
|
41
|
+
*/
|
|
42
|
+
async function syncPlatformSecrets({
|
|
43
|
+
platformsPath,
|
|
44
|
+
profile,
|
|
45
|
+
platformName,
|
|
46
|
+
dryRun = false,
|
|
47
|
+
}) {
|
|
48
|
+
if (profile) {
|
|
49
|
+
process.env.AWS_PROFILE = profile
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const templates = await PlatformTemplateService.readFromFile(platformsPath)
|
|
53
|
+
const configs = templates.map(PlatformConfigService.from)
|
|
54
|
+
.filter(config => !platformName || config.platformName === platformName)
|
|
55
|
+
|
|
56
|
+
if (configs.length === 0) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
platformName
|
|
59
|
+
? `No platform named "${platformName}" in ${platformsPath}`
|
|
60
|
+
: `No platforms found in ${platformsPath}`
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
for (const config of configs) {
|
|
65
|
+
const region = config.awsRegion
|
|
66
|
+
const client = new SecretsManagerClient({ region })
|
|
67
|
+
const services = listPlatformSecretServices(config)
|
|
68
|
+
const env = config.env || {}
|
|
69
|
+
const keyCount = Object.keys(env).length
|
|
70
|
+
|
|
71
|
+
logInfo({
|
|
72
|
+
message: `[sync-platform-secrets] ${config.platformName}: upserting ${services.length} secret(s), ${keyCount} env key(s) each`,
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
for (const service of services) {
|
|
76
|
+
if (dryRun) {
|
|
77
|
+
logInfo({
|
|
78
|
+
message: `[sync-platform-secrets] dry-run: would put ${service.secretName} (${keyCount} keys)`,
|
|
79
|
+
})
|
|
80
|
+
continue
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
await client.send(new DescribeSecretCommand({ SecretId: service.secretName }))
|
|
85
|
+
} catch (error) {
|
|
86
|
+
if (error.name === 'ResourceNotFoundException' || error.__type === 'ResourceNotFoundException') {
|
|
87
|
+
throw new Error(
|
|
88
|
+
`Secret "${service.secretName}" does not exist. Deploy the platform-secrets CDK stack first ` +
|
|
89
|
+
`(npx cdk deploy '${config.platformName}/platform-secrets'), then re-run sync-secrets.`
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
throw error
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
await client.send(new PutSecretValueCommand({
|
|
96
|
+
SecretId: service.secretName,
|
|
97
|
+
SecretString: JSON.stringify(env),
|
|
98
|
+
}))
|
|
99
|
+
|
|
100
|
+
logInfo({
|
|
101
|
+
message: `[sync-platform-secrets] upserted ${service.secretName} (${keyCount} keys)`,
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function main(argv = process.argv.slice(2)) {
|
|
108
|
+
const platformsPath = resolve(
|
|
109
|
+
getArg(argv, 'platforms-path') || '../infrastructure/platforms.json'
|
|
110
|
+
)
|
|
111
|
+
const profile = getArg(argv, 'profile')
|
|
112
|
+
const platformName = getArg(argv, 'platform')
|
|
113
|
+
const dryRun = hasFlag(argv, 'dry-run')
|
|
114
|
+
|
|
115
|
+
await syncPlatformSecrets({
|
|
116
|
+
platformsPath,
|
|
117
|
+
profile,
|
|
118
|
+
platformName,
|
|
119
|
+
dryRun,
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (require.main === module) {
|
|
124
|
+
main().catch(error => {
|
|
125
|
+
logError({
|
|
126
|
+
message: '[sync-platform-secrets] failed',
|
|
127
|
+
error: error?.message || error,
|
|
128
|
+
})
|
|
129
|
+
process.exitCode = 1
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = {
|
|
134
|
+
syncPlatformSecrets,
|
|
135
|
+
main,
|
|
136
|
+
}
|