@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,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/trust-ci, ossybot/storage-static, ossybot/deployment-target, etc.
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 deploymentTarget = new DeploymentTargetStack(this, 'deployment-target', {
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
- containerDeploymentTargetPublicIp: deploymentTarget.containerDeploymentTargetPublicIp
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
- * This stack is intentionally kept separate from the deployment-target stack so that
13
- * the long-lived storage resources are never touched when the EC2 instance is updated
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 deployment-target-stack via cross-stack reference):
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
+ }