@fishawack/lab-env 5.7.0-beta.2 → 5.7.0-beta.3
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 +42 -0
- package/_Test/provision.js +1 -8
- package/_Test/prune.js +8 -5
- package/bitbucket-pipelines.yml +2 -2
- package/cli.js +1 -0
- package/commands/create/cmds/dekey.js +13 -9
- package/commands/create/cmds/deprovision.js +29 -0
- package/commands/create/cmds/key.js +60 -41
- package/commands/create/cmds/provision.js +425 -55
- package/commands/create/cmds/rekey.js +218 -0
- package/commands/create/libs/output-credentials.js +59 -0
- package/commands/create/libs/parallel-runner.js +204 -0
- package/commands/create/libs/resolve-operator.js +59 -0
- package/commands/create/libs/utilities.js +71 -8
- package/commands/create/libs/vars.js +70 -86
- package/commands/create/services/aws/acm.js +4 -2
- package/commands/create/services/aws/cloudfront.js +51 -45
- package/commands/create/services/aws/ec2.js +132 -53
- package/commands/create/services/aws/elasticache.js +159 -0
- package/commands/create/services/aws/elasticbeanstalk.js +141 -60
- package/commands/create/services/aws/iam.js +159 -93
- package/commands/create/services/aws/index.js +1209 -466
- package/commands/create/services/aws/opensearch.js +25 -17
- package/commands/create/services/aws/rds.js +22 -34
- package/commands/create/services/aws/s3.js +14 -27
- package/commands/create/services/aws/secretsmanager.js +8 -15
- package/commands/create/services/aws/ses.js +195 -0
- package/commands/create/templates/elasticbeanstalk/.ebextensions/laravel/cron.config +45 -0
- package/commands/create/templates/elasticbeanstalk/.ebextensions/laravel/queue-worker.config +29 -0
- package/commands/create/templates/elasticbeanstalk/.ebextensions/laravel/software.config +12 -0
- package/commands/create/templates/elasticbeanstalk/.ebextensions/misc/setvars.config +2 -16
- package/commands/create/templates/elasticbeanstalk/.platform/confighooks/postdeploy/rewrite-flush.sh +1 -1
- package/commands/create/templates/elasticbeanstalk/.platform/confighooks/postdeploy/setvars.sh +19 -0
- package/commands/create/templates/elasticbeanstalk/.platform/hooks/postdeploy/restart_queue.sh +4 -0
- package/commands/create/templates/elasticbeanstalk/.platform/hooks/prebuild/setvars.sh +19 -0
- package/package.json +5 -2
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
const md5 = require("apache-md5");
|
|
2
|
-
const fs = require("fs-extra");
|
|
3
1
|
const generator = require("generate-password");
|
|
4
2
|
const { S3Client, ListBucketsCommand } = require("@aws-sdk/client-s3");
|
|
5
3
|
const {
|
|
@@ -14,8 +12,12 @@ const {
|
|
|
14
12
|
OpenSearchClient,
|
|
15
13
|
DescribeDomainCommand,
|
|
16
14
|
} = require("@aws-sdk/client-opensearch");
|
|
15
|
+
const {
|
|
16
|
+
ElastiCacheClient,
|
|
17
|
+
DescribeReplicationGroupsCommand,
|
|
18
|
+
} = require("@aws-sdk/client-elasticache");
|
|
17
19
|
|
|
18
|
-
const { nameSafe, Spinner } = require("../../libs/utilities.js");
|
|
20
|
+
const { nameSafe, Spinner, pollAll } = require("../../libs/utilities.js");
|
|
19
21
|
const { eb, secretGroups } = require("../../libs/vars.js");
|
|
20
22
|
|
|
21
23
|
module.exports.s3 = require("./s3.js");
|
|
@@ -25,8 +27,10 @@ module.exports.elasticbeanstalk = require("./elasticbeanstalk.js");
|
|
|
25
27
|
module.exports.ec2 = require("./ec2.js");
|
|
26
28
|
module.exports.rds = require("./rds.js");
|
|
27
29
|
module.exports.opensearch = require("./opensearch.js");
|
|
30
|
+
module.exports.elasticache = require("./elasticache.js");
|
|
28
31
|
module.exports.secretsmanager = require("./secretsmanager.js");
|
|
29
32
|
module.exports.acm = require("./acm.js");
|
|
33
|
+
module.exports.ses = require("./ses.js");
|
|
30
34
|
|
|
31
35
|
module.exports.slug = (repo, client, branch, service = "s3") =>
|
|
32
36
|
nameSafe(`${branch}-${repo}-${client}`, service);
|
|
@@ -35,14 +39,27 @@ module.exports.fullstackPreflight = async (
|
|
|
35
39
|
name,
|
|
36
40
|
repo,
|
|
37
41
|
branch,
|
|
38
|
-
{
|
|
42
|
+
{
|
|
43
|
+
storage = false,
|
|
44
|
+
database = false,
|
|
45
|
+
opensearch = false,
|
|
46
|
+
cache = false,
|
|
47
|
+
} = {},
|
|
39
48
|
) => {
|
|
40
49
|
const existing = {
|
|
41
50
|
environment: false,
|
|
42
51
|
storage: false,
|
|
43
52
|
database: false,
|
|
44
53
|
opensearch: false,
|
|
45
|
-
|
|
54
|
+
cache: false,
|
|
55
|
+
secrets: {
|
|
56
|
+
app: null,
|
|
57
|
+
storage: null,
|
|
58
|
+
database: null,
|
|
59
|
+
opensearch: null,
|
|
60
|
+
cache: null,
|
|
61
|
+
mail: null,
|
|
62
|
+
},
|
|
46
63
|
};
|
|
47
64
|
|
|
48
65
|
const spinner = new Spinner("Running pre-flight checks", true);
|
|
@@ -132,6 +149,33 @@ module.exports.fullstackPreflight = async (
|
|
|
132
149
|
}
|
|
133
150
|
}
|
|
134
151
|
|
|
152
|
+
// Check ElastiCache
|
|
153
|
+
if (cache) {
|
|
154
|
+
const cacheSlug = module.exports.slug(
|
|
155
|
+
repo,
|
|
156
|
+
process.env.AWS_PROFILE,
|
|
157
|
+
branch,
|
|
158
|
+
"cache",
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
spinner.ora.text = "Checking for existing ElastiCache cluster";
|
|
162
|
+
try {
|
|
163
|
+
const { ReplicationGroups } = await new ElastiCacheClient(
|
|
164
|
+
{},
|
|
165
|
+
).send(
|
|
166
|
+
new DescribeReplicationGroupsCommand({
|
|
167
|
+
ReplicationGroupId: cacheSlug,
|
|
168
|
+
}),
|
|
169
|
+
);
|
|
170
|
+
const group = ReplicationGroups[0];
|
|
171
|
+
if (group && group.Status !== "deleting") {
|
|
172
|
+
existing.cache = true;
|
|
173
|
+
}
|
|
174
|
+
} catch {
|
|
175
|
+
/* does not exist */
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
135
179
|
// Check for existing secrets
|
|
136
180
|
for (const group of secretGroups) {
|
|
137
181
|
spinner.ora.text = `Checking for existing ${group} secret`;
|
|
@@ -298,75 +342,39 @@ module.exports.staticTerminate = async (name, repo, branch, id) => {
|
|
|
298
342
|
}
|
|
299
343
|
};
|
|
300
344
|
|
|
301
|
-
|
|
302
|
-
name,
|
|
303
|
-
tags = [],
|
|
304
|
-
credentials = [],
|
|
305
|
-
repo,
|
|
306
|
-
branch,
|
|
307
|
-
framework,
|
|
308
|
-
availability,
|
|
309
|
-
database = false,
|
|
310
|
-
storage = false,
|
|
311
|
-
opensearch = false,
|
|
312
|
-
existingSecrets = {
|
|
313
|
-
app: null,
|
|
314
|
-
storage: null,
|
|
315
|
-
database: null,
|
|
316
|
-
opensearch: null,
|
|
317
|
-
},
|
|
318
|
-
snapshot = null,
|
|
319
|
-
elbScheme = "public",
|
|
320
|
-
existingEnvironment = false,
|
|
321
|
-
existingResources = {
|
|
322
|
-
storage: false,
|
|
323
|
-
database: false,
|
|
324
|
-
opensearch: false,
|
|
325
|
-
},
|
|
326
|
-
appName,
|
|
327
|
-
appDomain,
|
|
328
|
-
certArn,
|
|
329
|
-
) => {
|
|
330
|
-
const { platform, language } = eb.configurations[framework];
|
|
331
|
-
const isLaravel = framework === "laravel";
|
|
332
|
-
|
|
333
|
-
// Always compute s3Slug if storage is requested or pre-exists
|
|
334
|
-
// (needed for instance profile S3 access even when bucket creation is skipped)
|
|
335
|
-
const s3Slug =
|
|
336
|
-
storage || existingSecrets.storage
|
|
337
|
-
? module.exports.slug(repo, process.env.AWS_PROFILE, branch, "s3")
|
|
338
|
-
: undefined;
|
|
339
|
-
|
|
340
|
-
// Always compute osSlug if opensearch is requested or pre-exists
|
|
341
|
-
const osSlug =
|
|
342
|
-
opensearch || existingSecrets.opensearch
|
|
343
|
-
? module.exports.slug(repo, process.env.AWS_PROFILE, branch, "os")
|
|
344
|
-
: undefined;
|
|
345
|
+
// Private provisioner functions for parallel execution
|
|
345
346
|
|
|
347
|
+
const provisionStorage = async (
|
|
348
|
+
aws,
|
|
349
|
+
s3Slug,
|
|
350
|
+
tags,
|
|
351
|
+
isLaravel,
|
|
352
|
+
existingResources,
|
|
353
|
+
) => {
|
|
346
354
|
let s3Data = {};
|
|
347
355
|
|
|
348
|
-
if (
|
|
356
|
+
if (!existingResources.storage) {
|
|
349
357
|
if (isLaravel) {
|
|
350
|
-
await
|
|
358
|
+
await aws.s3.createS3Bucket(s3Slug, tags, {
|
|
351
359
|
BlockPublicAcls: true,
|
|
352
360
|
IgnorePublicAcls: true,
|
|
353
361
|
BlockPublicPolicy: false,
|
|
354
362
|
RestrictPublicBuckets: false,
|
|
355
363
|
});
|
|
356
364
|
|
|
357
|
-
await
|
|
365
|
+
await aws.s3.setPublicReadPolicy(s3Slug, "public/*");
|
|
358
366
|
} else {
|
|
359
|
-
await
|
|
360
|
-
await
|
|
367
|
+
await aws.iam.createIAMUser(s3Slug, tags);
|
|
368
|
+
await aws.iam.attachIAMPolicy(
|
|
361
369
|
s3Slug,
|
|
362
370
|
"arn:aws:iam::aws:policy/AmazonS3FullAccess",
|
|
363
371
|
);
|
|
364
372
|
const {
|
|
365
373
|
AccessKey: { AccessKeyId },
|
|
366
374
|
AccessKey: { SecretAccessKey },
|
|
367
|
-
} = await
|
|
375
|
+
} = await aws.iam.createAccessKey(s3Slug);
|
|
368
376
|
|
|
369
|
-
await
|
|
377
|
+
await aws.s3.createS3Bucket(
|
|
370
378
|
s3Slug,
|
|
371
379
|
tags,
|
|
372
380
|
false,
|
|
@@ -377,58 +385,46 @@ module.exports.fullstack = async (
|
|
|
377
385
|
}
|
|
378
386
|
}
|
|
379
387
|
|
|
380
|
-
if (
|
|
388
|
+
if (!s3Data.s3Slug) {
|
|
381
389
|
s3Data = { s3Slug };
|
|
382
390
|
}
|
|
383
391
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
if (osSlug) {
|
|
388
|
-
opensearchDomainArn = `arn:aws:es:${process.env.AWS_REGION}:*:domain/${osSlug}`;
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
if (isLaravel) {
|
|
392
|
-
instanceProfileName =
|
|
393
|
-
await module.exports.iam.ensureEnvironmentInstanceProfile(
|
|
394
|
-
name,
|
|
395
|
-
s3Slug,
|
|
396
|
-
opensearchDomainArn,
|
|
397
|
-
);
|
|
398
|
-
} else {
|
|
399
|
-
await module.exports.iam.ensureEBInstanceProfileExists();
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
await module.exports.iam.ensureEBManagedUpdateProfileExists();
|
|
403
|
-
const vpcData = await module.exports.ec2.ensurePrivateSubnets();
|
|
404
|
-
|
|
405
|
-
// Create decoupled RDS instance if database is required
|
|
406
|
-
let rdsData = {};
|
|
392
|
+
return s3Data;
|
|
393
|
+
};
|
|
407
394
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
395
|
+
const provisionRDS = async (
|
|
396
|
+
aws,
|
|
397
|
+
name,
|
|
398
|
+
repo,
|
|
399
|
+
branch,
|
|
400
|
+
tags,
|
|
401
|
+
vpcData,
|
|
402
|
+
existingResources,
|
|
403
|
+
existingSecrets,
|
|
404
|
+
snapshot,
|
|
405
|
+
) => {
|
|
406
|
+
const rdsSlug = aws.slug(repo, process.env.AWS_PROFILE, branch, "rds");
|
|
407
|
+
const rdsClient = new RDSClient({});
|
|
415
408
|
|
|
409
|
+
if (!existingResources.database) {
|
|
416
410
|
const dbPassword = generator.generate({
|
|
417
411
|
length: 10,
|
|
418
412
|
numbers: true,
|
|
419
413
|
});
|
|
420
414
|
|
|
421
|
-
await
|
|
415
|
+
await aws.rds.ensureSSLParameterGroup();
|
|
422
416
|
|
|
423
|
-
const rdsSecurityGroupId =
|
|
424
|
-
|
|
417
|
+
const rdsSecurityGroupId = await aws.ec2.createRDSSecurityGroup(
|
|
418
|
+
rdsSlug,
|
|
419
|
+
tags,
|
|
420
|
+
);
|
|
425
421
|
|
|
426
|
-
const dbSubnetGroupName = await
|
|
422
|
+
const dbSubnetGroupName = await aws.rds.ensureDBSubnetGroup(
|
|
427
423
|
vpcData.privateSubnetIds,
|
|
428
424
|
);
|
|
429
425
|
|
|
430
426
|
if (snapshot) {
|
|
431
|
-
await
|
|
427
|
+
await aws.rds.restoreRDSInstanceFromSnapshot(
|
|
432
428
|
rdsSlug,
|
|
433
429
|
snapshot,
|
|
434
430
|
rdsSecurityGroupId,
|
|
@@ -436,11 +432,10 @@ module.exports.fullstack = async (
|
|
|
436
432
|
dbSubnetGroupName,
|
|
437
433
|
);
|
|
438
434
|
|
|
439
|
-
await
|
|
440
|
-
|
|
441
|
-
await module.exports.rds.modifyRDSPassword(rdsSlug, dbPassword);
|
|
435
|
+
await aws.rds.waitForRDSInstance(rdsSlug);
|
|
436
|
+
await aws.rds.modifyRDSPassword(rdsSlug, dbPassword);
|
|
442
437
|
} else {
|
|
443
|
-
await
|
|
438
|
+
await aws.rds.createRDSInstance(
|
|
444
439
|
rdsSlug,
|
|
445
440
|
tags,
|
|
446
441
|
dbPassword,
|
|
@@ -449,194 +444,577 @@ module.exports.fullstack = async (
|
|
|
449
444
|
);
|
|
450
445
|
}
|
|
451
446
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
447
|
+
return {
|
|
448
|
+
rdsData: { RDS_PASSWORD: dbPassword },
|
|
449
|
+
rdsSlug,
|
|
450
|
+
poll: {
|
|
451
|
+
name: "RDS",
|
|
452
|
+
cb: async () =>
|
|
453
|
+
(
|
|
454
|
+
await rdsClient.send(
|
|
455
|
+
new DescribeDBInstancesCommand({
|
|
456
|
+
DBInstanceIdentifier: rdsSlug,
|
|
457
|
+
}),
|
|
458
|
+
)
|
|
459
|
+
).DBInstances[0],
|
|
460
|
+
shouldContinue: ({ DBInstanceStatus }) =>
|
|
461
|
+
DBInstanceStatus !== "available",
|
|
462
|
+
},
|
|
459
463
|
};
|
|
460
|
-
} else if (
|
|
461
|
-
database &&
|
|
462
|
-
existingResources.database &&
|
|
463
|
-
!existingSecrets.database
|
|
464
|
-
) {
|
|
465
|
-
// Recovery: RDS exists but secret was lost — reset password and populate data
|
|
466
|
-
const rdsSlug = module.exports.slug(
|
|
467
|
-
repo,
|
|
468
|
-
process.env.AWS_PROFILE,
|
|
469
|
-
branch,
|
|
470
|
-
"rds",
|
|
471
|
-
);
|
|
472
|
-
|
|
464
|
+
} else if (existingResources.database && !existingSecrets.database) {
|
|
473
465
|
const dbPassword = generator.generate({
|
|
474
466
|
length: 10,
|
|
475
467
|
numbers: true,
|
|
476
468
|
});
|
|
477
469
|
|
|
478
|
-
await
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
470
|
+
await aws.rds.modifyRDSPassword(rdsSlug, dbPassword);
|
|
471
|
+
|
|
472
|
+
return {
|
|
473
|
+
rdsData: { RDS_PASSWORD: dbPassword },
|
|
474
|
+
rdsSlug,
|
|
475
|
+
poll: {
|
|
476
|
+
name: "RDS",
|
|
477
|
+
cb: async () =>
|
|
478
|
+
(
|
|
479
|
+
await rdsClient.send(
|
|
480
|
+
new DescribeDBInstancesCommand({
|
|
481
|
+
DBInstanceIdentifier: rdsSlug,
|
|
482
|
+
}),
|
|
483
|
+
)
|
|
484
|
+
).DBInstances[0],
|
|
485
|
+
shouldContinue: ({ DBInstanceStatus }) =>
|
|
486
|
+
DBInstanceStatus !== "available",
|
|
487
|
+
},
|
|
486
488
|
};
|
|
487
|
-
} else
|
|
488
|
-
|
|
489
|
-
existingResources.database &&
|
|
490
|
-
existingSecrets.database
|
|
491
|
-
) {
|
|
492
|
-
// Existing RDS + secret — read secret values for template interpolation
|
|
493
|
-
const secretValue = await module.exports.secretsmanager.getSecretValue(
|
|
489
|
+
} else {
|
|
490
|
+
const secretValue = await aws.secretsmanager.getSecretValue(
|
|
494
491
|
`${name}-database`,
|
|
495
492
|
);
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
493
|
+
return {
|
|
494
|
+
rdsData: {
|
|
495
|
+
RDS_HOSTNAME: secretValue.RDS_HOSTNAME,
|
|
496
|
+
RDS_PORT: secretValue.RDS_PORT,
|
|
497
|
+
RDS_PASSWORD: secretValue.RDS_PASSWORD,
|
|
498
|
+
},
|
|
499
|
+
rdsSlug,
|
|
500
500
|
};
|
|
501
501
|
}
|
|
502
|
+
};
|
|
502
503
|
|
|
503
|
-
|
|
504
|
-
|
|
504
|
+
const provisionOpenSearch = async (
|
|
505
|
+
aws,
|
|
506
|
+
osSlug,
|
|
507
|
+
tags,
|
|
508
|
+
vpcData,
|
|
509
|
+
instanceProfileName,
|
|
510
|
+
existingResources,
|
|
511
|
+
) => {
|
|
512
|
+
const osClient = new OpenSearchClient({});
|
|
505
513
|
|
|
506
|
-
if (
|
|
507
|
-
await
|
|
514
|
+
if (!existingResources.opensearch) {
|
|
515
|
+
await aws.iam.ensureOpenSearchServiceRole();
|
|
508
516
|
|
|
509
517
|
const roleArn = instanceProfileName
|
|
510
|
-
? (await
|
|
518
|
+
? (await aws.iam.getRole(instanceProfileName)).Role.Arn
|
|
511
519
|
: undefined;
|
|
512
520
|
|
|
513
|
-
const osSecurityGroupId =
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
);
|
|
521
|
+
const osSecurityGroupId = await aws.ec2.createOpenSearchSecurityGroup(
|
|
522
|
+
osSlug,
|
|
523
|
+
tags,
|
|
524
|
+
);
|
|
518
525
|
|
|
519
|
-
await
|
|
526
|
+
await aws.opensearch.createOpenSearchDomain(
|
|
520
527
|
osSlug,
|
|
521
528
|
osSecurityGroupId,
|
|
522
529
|
vpcData.privateSubnetIds,
|
|
523
530
|
roleArn,
|
|
531
|
+
tags,
|
|
524
532
|
);
|
|
525
533
|
|
|
526
|
-
|
|
527
|
-
|
|
534
|
+
return {
|
|
535
|
+
osData: {},
|
|
536
|
+
poll: {
|
|
537
|
+
name: "OpenSearch",
|
|
538
|
+
cb: async () => {
|
|
539
|
+
const { DomainStatus } = await osClient.send(
|
|
540
|
+
new DescribeDomainCommand({ DomainName: osSlug }),
|
|
541
|
+
);
|
|
542
|
+
return DomainStatus;
|
|
543
|
+
},
|
|
544
|
+
shouldContinue: (status) =>
|
|
545
|
+
status.Processing ||
|
|
546
|
+
(!status.Endpoint && !status.Endpoints),
|
|
547
|
+
},
|
|
548
|
+
};
|
|
549
|
+
} else {
|
|
550
|
+
const osDomain = await aws.opensearch.describeOpenSearchDomain(osSlug);
|
|
528
551
|
|
|
529
552
|
const osEndpoint =
|
|
530
553
|
osDomain.Endpoint || (osDomain.Endpoints && osDomain.Endpoints.vpc);
|
|
531
554
|
|
|
532
|
-
osData
|
|
533
|
-
|
|
555
|
+
return { osData: { SEARCH_HOST: `https://${osEndpoint}` } };
|
|
556
|
+
}
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
const provisionCache = async (
|
|
560
|
+
aws,
|
|
561
|
+
name,
|
|
562
|
+
cacheSlug,
|
|
563
|
+
tags,
|
|
564
|
+
vpcData,
|
|
565
|
+
existingResources,
|
|
566
|
+
existingSecrets,
|
|
567
|
+
) => {
|
|
568
|
+
const cacheClient = new ElastiCacheClient({});
|
|
569
|
+
|
|
570
|
+
if (!existingResources.cache) {
|
|
571
|
+
const cachePassword = generator.generate({
|
|
572
|
+
length: 16,
|
|
573
|
+
numbers: true,
|
|
574
|
+
symbols: false,
|
|
575
|
+
strict: true,
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
const cacheSubnetGroupName =
|
|
579
|
+
await aws.elasticache.ensureCacheSubnetGroup(
|
|
580
|
+
vpcData.privateSubnetIds,
|
|
581
|
+
);
|
|
582
|
+
|
|
583
|
+
const cacheSecurityGroupId = await aws.ec2.createCacheSecurityGroup(
|
|
584
|
+
cacheSlug,
|
|
585
|
+
tags,
|
|
586
|
+
);
|
|
587
|
+
|
|
588
|
+
await aws.elasticache.createCacheCluster(
|
|
589
|
+
cacheSlug,
|
|
590
|
+
cacheSecurityGroupId,
|
|
591
|
+
cacheSubnetGroupName,
|
|
592
|
+
cachePassword,
|
|
593
|
+
tags,
|
|
594
|
+
);
|
|
595
|
+
|
|
596
|
+
return {
|
|
597
|
+
cacheData: { REDIS_PASSWORD: cachePassword },
|
|
598
|
+
poll: {
|
|
599
|
+
name: "ElastiCache",
|
|
600
|
+
cb: async () => {
|
|
601
|
+
const { ReplicationGroups } = await cacheClient.send(
|
|
602
|
+
new DescribeReplicationGroupsCommand({
|
|
603
|
+
ReplicationGroupId: cacheSlug,
|
|
604
|
+
}),
|
|
605
|
+
);
|
|
606
|
+
return ReplicationGroups[0];
|
|
607
|
+
},
|
|
608
|
+
shouldContinue: (group) => group.Status !== "available",
|
|
609
|
+
},
|
|
534
610
|
};
|
|
535
|
-
} else if (
|
|
536
|
-
|
|
537
|
-
const osDomain =
|
|
538
|
-
await module.exports.opensearch.describeOpenSearchDomain(osSlug);
|
|
611
|
+
} else if (existingResources.cache && !existingSecrets.cache) {
|
|
612
|
+
const cluster = await aws.elasticache.describeCacheCluster(cacheSlug);
|
|
539
613
|
|
|
540
|
-
const
|
|
541
|
-
osDomain.Endpoint || (osDomain.Endpoints && osDomain.Endpoints.vpc);
|
|
614
|
+
const cacheEndpoint = cluster.NodeGroups[0].PrimaryEndpoint;
|
|
542
615
|
|
|
543
|
-
|
|
544
|
-
|
|
616
|
+
return {
|
|
617
|
+
cacheData: {
|
|
618
|
+
REDIS_HOST: cacheEndpoint.Address,
|
|
619
|
+
REDIS_PORT: String(cacheEndpoint.Port),
|
|
620
|
+
REDIS_PASSWORD: "",
|
|
621
|
+
},
|
|
622
|
+
};
|
|
623
|
+
} else {
|
|
624
|
+
const secretValue = await aws.secretsmanager.getSecretValue(
|
|
625
|
+
`${name}-cache`,
|
|
626
|
+
);
|
|
627
|
+
return {
|
|
628
|
+
cacheData: {
|
|
629
|
+
REDIS_HOST: secretValue.REDIS_HOST,
|
|
630
|
+
REDIS_PORT: secretValue.REDIS_PORT,
|
|
631
|
+
REDIS_PASSWORD: secretValue.REDIS_PASSWORD,
|
|
632
|
+
},
|
|
545
633
|
};
|
|
546
634
|
}
|
|
635
|
+
};
|
|
636
|
+
|
|
637
|
+
const provisionMail = async (aws, name, tags, mailData) => {
|
|
638
|
+
const {
|
|
639
|
+
IAMClient,
|
|
640
|
+
CreateUserCommand,
|
|
641
|
+
GetUserCommand,
|
|
642
|
+
CreateAccessKeyCommand,
|
|
643
|
+
ListAccessKeysCommand,
|
|
644
|
+
} = require("@aws-sdk/client-iam");
|
|
645
|
+
const { fromIni } = require("@aws-sdk/credential-providers");
|
|
646
|
+
|
|
647
|
+
const sesProfile = "fishawack";
|
|
648
|
+
const sesRegion = "us-east-1";
|
|
649
|
+
const groupName = "AWSSESSendingGroupDoNotRename";
|
|
650
|
+
|
|
651
|
+
const iamClient = new IAMClient({
|
|
652
|
+
region: sesRegion,
|
|
653
|
+
credentials: fromIni({ profile: sesProfile }),
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
const userName = `${name}-ses`;
|
|
547
657
|
|
|
658
|
+
// Create user in the fishawack account
|
|
548
659
|
try {
|
|
549
|
-
await
|
|
550
|
-
|
|
660
|
+
await Spinner.prototype.simple(
|
|
661
|
+
`Creating SES IAM user ${userName}`,
|
|
662
|
+
() => {
|
|
663
|
+
return iamClient.send(
|
|
664
|
+
new CreateUserCommand({
|
|
665
|
+
UserName: userName,
|
|
666
|
+
Tags: [
|
|
667
|
+
{ Key: "client", Value: process.env.AWS_PROFILE },
|
|
668
|
+
].concat(
|
|
669
|
+
tags.map((t) => ({
|
|
670
|
+
Key: t.Key,
|
|
671
|
+
Value: String(t.Value),
|
|
672
|
+
})),
|
|
673
|
+
),
|
|
674
|
+
}),
|
|
675
|
+
);
|
|
676
|
+
},
|
|
551
677
|
);
|
|
552
678
|
} catch {
|
|
553
|
-
|
|
679
|
+
await Spinner.prototype.simple(
|
|
680
|
+
`Retrieving existing SES IAM user ${userName}`,
|
|
681
|
+
() => {
|
|
682
|
+
return iamClient.send(
|
|
683
|
+
new GetUserCommand({ UserName: userName }),
|
|
684
|
+
);
|
|
685
|
+
},
|
|
686
|
+
);
|
|
554
687
|
}
|
|
555
688
|
|
|
556
|
-
|
|
689
|
+
// Add user to SES sending group
|
|
690
|
+
await aws.iam.addUserToGroup(userName, groupName, iamClient);
|
|
557
691
|
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
/* empty */
|
|
563
|
-
}
|
|
692
|
+
// Create access key
|
|
693
|
+
const existingKeys = await iamClient.send(
|
|
694
|
+
new ListAccessKeysCommand({ UserName: userName }),
|
|
695
|
+
);
|
|
564
696
|
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
697
|
+
if (existingKeys.AccessKeyMetadata.length) {
|
|
698
|
+
// Keys already exist — we can't retrieve the secret again so return what we have in secrets
|
|
699
|
+
return {
|
|
700
|
+
MAIL_USERNAME: existingKeys.AccessKeyMetadata[0].AccessKeyId,
|
|
701
|
+
MAIL_PASSWORD: "",
|
|
702
|
+
MAIL_FROM_ADDRESS: mailData.fromAddress,
|
|
703
|
+
MAIL_FROM_NAME: mailData.fromName,
|
|
704
|
+
};
|
|
705
|
+
}
|
|
568
706
|
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
{
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
language,
|
|
576
|
-
database,
|
|
577
|
-
storage,
|
|
578
|
-
opensearch,
|
|
579
|
-
},
|
|
580
|
-
{
|
|
581
|
-
...s3Data,
|
|
582
|
-
...rdsData,
|
|
583
|
-
...osData,
|
|
584
|
-
APP_NAME: appName,
|
|
585
|
-
APP_DOMAIN: appDomain,
|
|
707
|
+
const keyRes = await Spinner.prototype.simple(
|
|
708
|
+
`Creating access key for SES user`,
|
|
709
|
+
() => {
|
|
710
|
+
return iamClient.send(
|
|
711
|
+
new CreateAccessKeyCommand({ UserName: userName }),
|
|
712
|
+
);
|
|
586
713
|
},
|
|
587
714
|
);
|
|
588
715
|
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
716
|
+
// Derive SMTP credentials
|
|
717
|
+
const smtpPassword = aws.ses.deriveSmtpPassword(
|
|
718
|
+
keyRes.AccessKey.SecretAccessKey,
|
|
719
|
+
sesRegion,
|
|
720
|
+
);
|
|
594
721
|
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
(k) => !(k in currentValue),
|
|
603
|
-
);
|
|
722
|
+
return {
|
|
723
|
+
MAIL_USERNAME: keyRes.AccessKey.AccessKeyId,
|
|
724
|
+
MAIL_PASSWORD: smtpPassword,
|
|
725
|
+
MAIL_FROM_ADDRESS: mailData.fromAddress,
|
|
726
|
+
MAIL_FROM_NAME: mailData.fromName,
|
|
727
|
+
};
|
|
728
|
+
};
|
|
604
729
|
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
tags,
|
|
621
|
-
);
|
|
622
|
-
secretArns[`${group}SecretArn`] = res.ARN;
|
|
730
|
+
const authorizeIngress = async (aws, name, slug, service, authorizer) => {
|
|
731
|
+
try {
|
|
732
|
+
const sg = await aws.ec2.findSecurityGroupByName(`${slug}-${service}`);
|
|
733
|
+
const ebSg = await aws.ec2.findSecurityGroupByTag([
|
|
734
|
+
{
|
|
735
|
+
Name: "tag:elasticbeanstalk:environment-name",
|
|
736
|
+
Values: [name],
|
|
737
|
+
},
|
|
738
|
+
{
|
|
739
|
+
Name: "tag:aws:cloudformation:logical-id",
|
|
740
|
+
Values: ["AWSEBSecurityGroup"],
|
|
741
|
+
},
|
|
742
|
+
]);
|
|
743
|
+
if (sg && ebSg) {
|
|
744
|
+
await authorizer(sg.GroupId, ebSg.GroupId);
|
|
623
745
|
}
|
|
746
|
+
} catch {
|
|
747
|
+
/* empty */
|
|
624
748
|
}
|
|
749
|
+
};
|
|
625
750
|
|
|
626
|
-
|
|
627
|
-
|
|
751
|
+
module.exports.fullstack = async (
|
|
752
|
+
name,
|
|
753
|
+
tags = [],
|
|
754
|
+
repo,
|
|
755
|
+
branch,
|
|
756
|
+
framework,
|
|
757
|
+
database = false,
|
|
758
|
+
storage = false,
|
|
759
|
+
opensearch = false,
|
|
760
|
+
cache = false,
|
|
761
|
+
mail = false,
|
|
762
|
+
existingSecrets = {
|
|
763
|
+
app: null,
|
|
764
|
+
storage: null,
|
|
765
|
+
database: null,
|
|
766
|
+
opensearch: null,
|
|
767
|
+
cache: null,
|
|
768
|
+
mail: null,
|
|
769
|
+
},
|
|
770
|
+
snapshot = null,
|
|
771
|
+
elbScheme = "public",
|
|
772
|
+
existingEnvironment = false,
|
|
773
|
+
existingResources = {
|
|
774
|
+
storage: false,
|
|
775
|
+
database: false,
|
|
776
|
+
opensearch: false,
|
|
777
|
+
cache: false,
|
|
778
|
+
},
|
|
779
|
+
appName,
|
|
780
|
+
appDomain,
|
|
781
|
+
certArn,
|
|
782
|
+
instanceType = "t3.micro",
|
|
783
|
+
mailData = {},
|
|
784
|
+
) => {
|
|
785
|
+
const { platform, language } = eb.configurations[framework];
|
|
786
|
+
const isLaravel = framework === "laravel";
|
|
787
|
+
const aws = module.exports;
|
|
628
788
|
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
789
|
+
// Compute slugs
|
|
790
|
+
const s3Slug =
|
|
791
|
+
storage || existingSecrets.storage
|
|
792
|
+
? aws.slug(repo, process.env.AWS_PROFILE, branch, "s3")
|
|
793
|
+
: undefined;
|
|
632
794
|
|
|
633
|
-
|
|
795
|
+
const osSlug =
|
|
796
|
+
opensearch || existingSecrets.opensearch
|
|
797
|
+
? aws.slug(repo, process.env.AWS_PROFILE, branch, "os")
|
|
798
|
+
: undefined;
|
|
634
799
|
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
800
|
+
const cacheSlug =
|
|
801
|
+
cache || existingSecrets.cache
|
|
802
|
+
? aws.slug(repo, process.env.AWS_PROFILE, branch, "cache")
|
|
803
|
+
: undefined;
|
|
804
|
+
|
|
805
|
+
const opensearchDomainArn = osSlug
|
|
806
|
+
? `arn:aws:es:${process.env.AWS_REGION}:*:domain/${osSlug}`
|
|
807
|
+
: undefined;
|
|
808
|
+
|
|
809
|
+
// Phase 1: IAM prerequisites (needed before OpenSearch and EB env)
|
|
810
|
+
let instanceProfileName;
|
|
811
|
+
|
|
812
|
+
if (isLaravel) {
|
|
813
|
+
instanceProfileName = await aws.iam.ensureEnvironmentInstanceProfile(
|
|
814
|
+
name,
|
|
815
|
+
s3Slug,
|
|
816
|
+
opensearchDomainArn,
|
|
817
|
+
);
|
|
818
|
+
} else {
|
|
819
|
+
await aws.iam.ensureEBInstanceProfileExists();
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// Phase 2: VPC + EB app in parallel
|
|
823
|
+
const [vpcData] = await Promise.all([
|
|
824
|
+
aws.ec2.ensurePrivateSubnets(),
|
|
825
|
+
aws.iam.ensureEBManagedUpdateProfileExists(),
|
|
826
|
+
]);
|
|
827
|
+
|
|
828
|
+
try {
|
|
829
|
+
await aws.elasticbeanstalk.createElasticBeanstalkApplication(
|
|
830
|
+
repo,
|
|
831
|
+
tags,
|
|
832
|
+
);
|
|
833
|
+
} catch {
|
|
834
|
+
/* empty */
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// Phase 3: Provision services in parallel
|
|
838
|
+
const servicePromises = [];
|
|
839
|
+
|
|
840
|
+
if (storage) {
|
|
841
|
+
servicePromises.push(
|
|
842
|
+
provisionStorage(aws, s3Slug, tags, isLaravel, existingResources),
|
|
843
|
+
);
|
|
844
|
+
} else {
|
|
845
|
+
servicePromises.push(Promise.resolve(s3Slug ? { s3Slug } : {}));
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
if (database) {
|
|
849
|
+
servicePromises.push(
|
|
850
|
+
provisionRDS(
|
|
851
|
+
aws,
|
|
852
|
+
name,
|
|
853
|
+
repo,
|
|
854
|
+
branch,
|
|
855
|
+
tags,
|
|
856
|
+
vpcData,
|
|
857
|
+
existingResources,
|
|
858
|
+
existingSecrets,
|
|
859
|
+
snapshot,
|
|
860
|
+
),
|
|
861
|
+
);
|
|
862
|
+
} else {
|
|
863
|
+
servicePromises.push(Promise.resolve({ rdsData: {}, rdsSlug: null }));
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
if (opensearch) {
|
|
867
|
+
servicePromises.push(
|
|
868
|
+
provisionOpenSearch(
|
|
869
|
+
aws,
|
|
870
|
+
osSlug,
|
|
871
|
+
tags,
|
|
872
|
+
vpcData,
|
|
873
|
+
instanceProfileName,
|
|
874
|
+
existingResources,
|
|
875
|
+
),
|
|
876
|
+
);
|
|
877
|
+
} else {
|
|
878
|
+
servicePromises.push(Promise.resolve({ osData: {} }));
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
if (cache) {
|
|
882
|
+
servicePromises.push(
|
|
883
|
+
provisionCache(
|
|
884
|
+
aws,
|
|
885
|
+
name,
|
|
886
|
+
cacheSlug,
|
|
887
|
+
tags,
|
|
888
|
+
vpcData,
|
|
889
|
+
existingResources,
|
|
890
|
+
existingSecrets,
|
|
891
|
+
),
|
|
892
|
+
);
|
|
893
|
+
} else {
|
|
894
|
+
servicePromises.push(Promise.resolve({ cacheData: {} }));
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
const [s3Data, rdsResult, osResult, cacheResult] =
|
|
898
|
+
await Promise.all(servicePromises);
|
|
899
|
+
|
|
900
|
+
// Phase 3b: Wait for all pending services with single spinner
|
|
901
|
+
const pendingPolls = [rdsResult, osResult, cacheResult]
|
|
902
|
+
.filter((r) => r && r.poll)
|
|
903
|
+
.map((r) => r.poll);
|
|
904
|
+
|
|
905
|
+
const pollResults = pendingPolls.length ? await pollAll(pendingPolls) : [];
|
|
906
|
+
|
|
907
|
+
// Extract endpoint data from poll results
|
|
908
|
+
let rdsData = rdsResult.rdsData || {};
|
|
909
|
+
|
|
910
|
+
if (rdsResult.poll) {
|
|
911
|
+
const rdsInstance = pollResults[pendingPolls.indexOf(rdsResult.poll)];
|
|
912
|
+
rdsData.RDS_HOSTNAME = rdsInstance.Endpoint.Address;
|
|
913
|
+
rdsData.RDS_PORT = String(rdsInstance.Endpoint.Port);
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
let osData = osResult.osData || {};
|
|
917
|
+
|
|
918
|
+
if (osResult.poll) {
|
|
919
|
+
const osDomain = pollResults[pendingPolls.indexOf(osResult.poll)];
|
|
920
|
+
const osEndpoint =
|
|
921
|
+
osDomain.Endpoint || (osDomain.Endpoints && osDomain.Endpoints.vpc);
|
|
922
|
+
osData.SEARCH_HOST = `https://${osEndpoint}`;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
let cacheData = cacheResult.cacheData || {};
|
|
926
|
+
|
|
927
|
+
if (cacheResult.poll) {
|
|
928
|
+
const cluster = pollResults[pendingPolls.indexOf(cacheResult.poll)];
|
|
929
|
+
const cacheEndpoint = cluster.NodeGroups[0].PrimaryEndpoint;
|
|
930
|
+
cacheData.REDIS_HOST = cacheEndpoint.Address;
|
|
931
|
+
cacheData.REDIS_PORT = String(cacheEndpoint.Port);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// Phase 4: Build secrets and EB environment (sequential)
|
|
935
|
+
const CNAMEPrefix = `fw-auto-${name.split("-")[name.split("-").length - 1]}`;
|
|
936
|
+
process.env.DOMAIN_LINK = `${CNAMEPrefix}.${process.env.AWS_REGION}.elasticbeanstalk.com`;
|
|
937
|
+
|
|
938
|
+
// Provision mail credentials if needed
|
|
939
|
+
let mailCreds = {};
|
|
940
|
+
if (mail && !existingSecrets.mail) {
|
|
941
|
+
mailCreds = await provisionMail(aws, name, tags, mailData);
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
const payloads = eb.buildSecretPayloads(
|
|
945
|
+
{
|
|
946
|
+
framework,
|
|
947
|
+
platform,
|
|
948
|
+
language,
|
|
949
|
+
database,
|
|
950
|
+
storage,
|
|
951
|
+
opensearch,
|
|
952
|
+
cache,
|
|
953
|
+
mail: mail && !existingSecrets.mail,
|
|
954
|
+
},
|
|
955
|
+
{
|
|
956
|
+
...s3Data,
|
|
957
|
+
...rdsData,
|
|
958
|
+
...osData,
|
|
959
|
+
...cacheData,
|
|
960
|
+
...mailCreds,
|
|
961
|
+
APP_NAME: appName,
|
|
962
|
+
APP_DOMAIN: appDomain,
|
|
963
|
+
SESSION_DOMAIN: appDomain
|
|
964
|
+
? appDomain.split(".").length > 2
|
|
965
|
+
? appDomain.split(".").slice(1).join(".")
|
|
966
|
+
: appDomain
|
|
967
|
+
: undefined,
|
|
968
|
+
},
|
|
969
|
+
);
|
|
970
|
+
|
|
971
|
+
const secretArns = {};
|
|
972
|
+
|
|
973
|
+
for (const group of secretGroups) {
|
|
974
|
+
if (existingSecrets[group]) {
|
|
975
|
+
secretArns[`${group}SecretArn`] = existingSecrets[group];
|
|
976
|
+
|
|
977
|
+
if (Object.keys(payloads[group]).length) {
|
|
978
|
+
const currentValue = await aws.secretsmanager.getSecretValue(
|
|
638
979
|
`${name}-${group}`,
|
|
639
980
|
);
|
|
981
|
+
const newKeys = Object.keys(payloads[group]).filter(
|
|
982
|
+
(k) => !(k in currentValue),
|
|
983
|
+
);
|
|
984
|
+
|
|
985
|
+
if (newKeys.length) {
|
|
986
|
+
const merged = { ...currentValue };
|
|
987
|
+
for (const k of newKeys) {
|
|
988
|
+
merged[k] = payloads[group][k];
|
|
989
|
+
}
|
|
990
|
+
await aws.secretsmanager.updateSecretValue(
|
|
991
|
+
`${name}-${group}`,
|
|
992
|
+
merged,
|
|
993
|
+
);
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
} else if (Object.keys(payloads[group]).length) {
|
|
997
|
+
const res = await aws.secretsmanager.createSecret(
|
|
998
|
+
`${name}-${group}`,
|
|
999
|
+
payloads[group],
|
|
1000
|
+
tags,
|
|
1001
|
+
);
|
|
1002
|
+
secretArns[`${group}SecretArn`] = res.ARN;
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
const secretSettings = [];
|
|
1007
|
+
|
|
1008
|
+
for (const group of secretGroups) {
|
|
1009
|
+
const arn = secretArns[`${group}SecretArn`];
|
|
1010
|
+
if (!arn) continue;
|
|
1011
|
+
|
|
1012
|
+
let keys;
|
|
1013
|
+
|
|
1014
|
+
if (existingSecrets[group]) {
|
|
1015
|
+
const secretValue = await aws.secretsmanager.getSecretValue(
|
|
1016
|
+
`${name}-${group}`,
|
|
1017
|
+
);
|
|
640
1018
|
keys = Object.keys(secretValue);
|
|
641
1019
|
} else {
|
|
642
1020
|
keys = Object.keys(payloads[group]);
|
|
@@ -652,14 +1030,14 @@ module.exports.fullstack = async (
|
|
|
652
1030
|
}
|
|
653
1031
|
}
|
|
654
1032
|
|
|
1033
|
+
// Phase 5: Create or update EB environment
|
|
655
1034
|
let environment;
|
|
656
1035
|
|
|
657
1036
|
if (existingEnvironment) {
|
|
658
|
-
// Re-provision: sync secrets to existing EB environment
|
|
659
1037
|
const SECRETS_NAMESPACE =
|
|
660
1038
|
"aws:elasticbeanstalk:application:environmentsecrets";
|
|
661
1039
|
const currentSettings =
|
|
662
|
-
await
|
|
1040
|
+
await aws.elasticbeanstalk.describeConfigurationSettings(
|
|
663
1041
|
repo,
|
|
664
1042
|
name,
|
|
665
1043
|
);
|
|
@@ -689,7 +1067,7 @@ module.exports.fullstack = async (
|
|
|
689
1067
|
const optionSettings = [...toAdd, ...toUpdate];
|
|
690
1068
|
|
|
691
1069
|
if (optionSettings.length || toRemove.length) {
|
|
692
|
-
await
|
|
1070
|
+
await aws.elasticbeanstalk.updateEnvironmentSettings(
|
|
693
1071
|
name,
|
|
694
1072
|
optionSettings,
|
|
695
1073
|
toRemove,
|
|
@@ -702,7 +1080,6 @@ module.exports.fullstack = async (
|
|
|
702
1080
|
"environments",
|
|
703
1081
|
{
|
|
704
1082
|
framework,
|
|
705
|
-
availability,
|
|
706
1083
|
platform,
|
|
707
1084
|
language,
|
|
708
1085
|
database,
|
|
@@ -713,17 +1090,23 @@ module.exports.fullstack = async (
|
|
|
713
1090
|
publicSubnetIds: vpcData.publicSubnetIds.join(","),
|
|
714
1091
|
elbScheme,
|
|
715
1092
|
instanceProfileName,
|
|
1093
|
+
instanceType,
|
|
716
1094
|
},
|
|
717
1095
|
{
|
|
718
1096
|
...s3Data,
|
|
719
1097
|
...rdsData,
|
|
720
1098
|
...osData,
|
|
1099
|
+
...cacheData,
|
|
721
1100
|
APP_NAME: appName,
|
|
722
1101
|
APP_DOMAIN: appDomain,
|
|
1102
|
+
SESSION_DOMAIN: appDomain
|
|
1103
|
+
? appDomain.split(".").length > 2
|
|
1104
|
+
? appDomain.split(".").slice(1).join(".")
|
|
1105
|
+
: appDomain
|
|
1106
|
+
: undefined,
|
|
723
1107
|
},
|
|
724
1108
|
);
|
|
725
1109
|
|
|
726
|
-
// HTTPS listener via ACM cert
|
|
727
1110
|
if (certArn) {
|
|
728
1111
|
OptionSettings.push(
|
|
729
1112
|
{
|
|
@@ -756,18 +1139,10 @@ module.exports.fullstack = async (
|
|
|
756
1139
|
|
|
757
1140
|
OptionSettings.push(...secretSettings);
|
|
758
1141
|
|
|
759
|
-
if (hasKeyPair) {
|
|
760
|
-
OptionSettings.push({
|
|
761
|
-
OptionName: "EC2KeyName",
|
|
762
|
-
Value: "id_rsa_prev",
|
|
763
|
-
Namespace: "aws:autoscaling:launchconfiguration",
|
|
764
|
-
});
|
|
765
|
-
}
|
|
766
|
-
|
|
767
1142
|
environment =
|
|
768
|
-
await
|
|
1143
|
+
await aws.elasticbeanstalk.createElasticBeanstalkEnvironment(
|
|
769
1144
|
name,
|
|
770
|
-
{ framework,
|
|
1145
|
+
{ framework, platform, language },
|
|
771
1146
|
repo,
|
|
772
1147
|
OptionSettings,
|
|
773
1148
|
CNAMEPrefix,
|
|
@@ -775,309 +1150,677 @@ module.exports.fullstack = async (
|
|
|
775
1150
|
);
|
|
776
1151
|
}
|
|
777
1152
|
|
|
778
|
-
//
|
|
779
|
-
const rdsSlugForIngress =
|
|
1153
|
+
// Phase 6: SG ingress rules in parallel
|
|
1154
|
+
const rdsSlugForIngress = aws.slug(
|
|
780
1155
|
repo,
|
|
781
1156
|
process.env.AWS_PROFILE,
|
|
782
1157
|
branch,
|
|
783
1158
|
"rds",
|
|
784
1159
|
);
|
|
785
1160
|
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
1161
|
+
const ingressPromises = [
|
|
1162
|
+
authorizeIngress(
|
|
1163
|
+
aws,
|
|
1164
|
+
name,
|
|
1165
|
+
rdsSlugForIngress,
|
|
1166
|
+
"rds",
|
|
1167
|
+
aws.ec2.authorizeRDSIngress,
|
|
1168
|
+
),
|
|
1169
|
+
];
|
|
1170
|
+
|
|
1171
|
+
if (osSlug) {
|
|
1172
|
+
ingressPromises.push(
|
|
1173
|
+
authorizeIngress(
|
|
1174
|
+
aws,
|
|
1175
|
+
name,
|
|
1176
|
+
osSlug,
|
|
1177
|
+
"os",
|
|
1178
|
+
aws.ec2.authorizeOpenSearchIngress,
|
|
1179
|
+
),
|
|
789
1180
|
);
|
|
790
|
-
const ebSg = await module.exports.ec2.findSecurityGroupByTag([
|
|
791
|
-
{
|
|
792
|
-
Name: "tag:elasticbeanstalk:environment-name",
|
|
793
|
-
Values: [name],
|
|
794
|
-
},
|
|
795
|
-
{
|
|
796
|
-
Name: "tag:aws:cloudformation:logical-id",
|
|
797
|
-
Values: ["AWSEBSecurityGroup"],
|
|
798
|
-
},
|
|
799
|
-
]);
|
|
800
|
-
if (rdsSg && ebSg) {
|
|
801
|
-
await module.exports.ec2.authorizeRDSIngress(
|
|
802
|
-
rdsSg.GroupId,
|
|
803
|
-
ebSg.GroupId,
|
|
804
|
-
);
|
|
805
|
-
}
|
|
806
|
-
} catch {
|
|
807
|
-
/* empty */
|
|
808
1181
|
}
|
|
809
1182
|
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
},
|
|
821
|
-
{
|
|
822
|
-
Name: "tag:aws:cloudformation:logical-id",
|
|
823
|
-
Values: ["AWSEBSecurityGroup"],
|
|
824
|
-
},
|
|
825
|
-
]);
|
|
826
|
-
if (osSg && ebSgForOs) {
|
|
827
|
-
await module.exports.ec2.authorizeOpenSearchIngress(
|
|
828
|
-
osSg.GroupId,
|
|
829
|
-
ebSgForOs.GroupId,
|
|
830
|
-
);
|
|
831
|
-
}
|
|
832
|
-
} catch {
|
|
833
|
-
/* empty */
|
|
834
|
-
}
|
|
1183
|
+
if (cacheSlug) {
|
|
1184
|
+
ingressPromises.push(
|
|
1185
|
+
authorizeIngress(
|
|
1186
|
+
aws,
|
|
1187
|
+
name,
|
|
1188
|
+
cacheSlug,
|
|
1189
|
+
"cache",
|
|
1190
|
+
aws.ec2.authorizeCacheIngress,
|
|
1191
|
+
),
|
|
1192
|
+
);
|
|
835
1193
|
}
|
|
836
1194
|
|
|
1195
|
+
await Promise.all(ingressPromises);
|
|
1196
|
+
|
|
1197
|
+
// Phase 7: Copy configs and return
|
|
837
1198
|
const configurations = eb.merge("configurations", {
|
|
838
1199
|
framework,
|
|
839
|
-
availability,
|
|
840
1200
|
platform,
|
|
841
1201
|
language,
|
|
842
1202
|
});
|
|
843
1203
|
|
|
844
|
-
|
|
845
|
-
configurations.push(
|
|
846
|
-
`.platform/${platform}/conf.d/elasticbeanstalk/${platform === "httpd" ? `z-${framework}-` : ""}htpasswd.conf`,
|
|
847
|
-
);
|
|
848
|
-
fs.outputFileSync(
|
|
849
|
-
`.platform/${platform}/conf.d/elasticbeanstalk/.htpasswd-${branch}`,
|
|
850
|
-
credentials
|
|
851
|
-
.map((d) => `${d.username}:${md5(d.password)}`)
|
|
852
|
-
.join("\n"),
|
|
853
|
-
);
|
|
854
|
-
}
|
|
855
|
-
|
|
856
|
-
await module.exports.elasticbeanstalk.copyElasticBeanstalkConfigs(
|
|
1204
|
+
await aws.elasticbeanstalk.copyElasticBeanstalkConfigs(
|
|
857
1205
|
configurations,
|
|
1206
|
+
branch,
|
|
858
1207
|
);
|
|
859
1208
|
|
|
860
|
-
if (credentials.length) {
|
|
861
|
-
configurations.push(
|
|
862
|
-
`.platform/${platform}/conf.d/elasticbeanstalk/.htpasswd-${branch}`,
|
|
863
|
-
);
|
|
864
|
-
}
|
|
865
|
-
|
|
866
1209
|
let config = {
|
|
867
1210
|
url: `https://${environment.CNAME}`,
|
|
868
1211
|
environment: environment.EnvironmentName,
|
|
869
|
-
paths:
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
return obj;
|
|
877
|
-
}),
|
|
1212
|
+
paths: [`_IaC/${branch}/*`],
|
|
1213
|
+
copy: [
|
|
1214
|
+
{
|
|
1215
|
+
src: `_IaC/${branch}/.elasticbeanstalk/config.yml`,
|
|
1216
|
+
dest: `.elasticbeanstalk/config.yml`,
|
|
1217
|
+
},
|
|
1218
|
+
],
|
|
878
1219
|
};
|
|
879
1220
|
|
|
880
1221
|
return config;
|
|
881
1222
|
};
|
|
882
1223
|
|
|
883
|
-
module.exports.
|
|
1224
|
+
module.exports.fullstackClone = async (
|
|
884
1225
|
name,
|
|
1226
|
+
tags,
|
|
885
1227
|
repo,
|
|
886
1228
|
branch,
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
deleteAppSecret = false,
|
|
892
|
-
} = {},
|
|
1229
|
+
applicationName,
|
|
1230
|
+
sourceEnvironmentName,
|
|
1231
|
+
sourceVersionLabel,
|
|
1232
|
+
sourceBranch,
|
|
893
1233
|
) => {
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
1234
|
+
const aws = module.exports;
|
|
1235
|
+
|
|
1236
|
+
// Phase 1: Read source configuration
|
|
1237
|
+
const sourceSettings =
|
|
1238
|
+
await aws.elasticbeanstalk.describeConfigurationSettings(
|
|
1239
|
+
applicationName,
|
|
1240
|
+
sourceEnvironmentName,
|
|
900
1241
|
);
|
|
901
1242
|
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
1243
|
+
// Phase 2: Filter settings — keep relevant namespaces, exclude immutable/auto-managed
|
|
1244
|
+
const excludedOptions = new Set([
|
|
1245
|
+
"aws:elasticbeanstalk:environment::EnvironmentName",
|
|
1246
|
+
]);
|
|
1247
|
+
|
|
1248
|
+
const includedNamespaces = new Set([
|
|
1249
|
+
"aws:elasticbeanstalk:application:environment",
|
|
1250
|
+
"aws:elasticbeanstalk:application:environmentsecrets",
|
|
1251
|
+
"aws:autoscaling:launchconfiguration",
|
|
1252
|
+
"aws:autoscaling:asg",
|
|
1253
|
+
"aws:ec2:vpc",
|
|
1254
|
+
"aws:elbv2:listener:443",
|
|
1255
|
+
"aws:elbv2:listener:default",
|
|
1256
|
+
"aws:elasticbeanstalk:environment",
|
|
1257
|
+
"aws:elasticbeanstalk:environment:process:default",
|
|
1258
|
+
"aws:elasticbeanstalk:healthreporting:system",
|
|
1259
|
+
"aws:elasticbeanstalk:managedactions",
|
|
1260
|
+
"aws:elasticbeanstalk:managedactions:platformupdate",
|
|
1261
|
+
"aws:elasticbeanstalk:cloudwatch:logs",
|
|
1262
|
+
"aws:elasticbeanstalk:cloudwatch:logs:health",
|
|
1263
|
+
]);
|
|
1264
|
+
|
|
1265
|
+
const OptionSettings = sourceSettings
|
|
1266
|
+
.filter((s) => {
|
|
1267
|
+
if (!includedNamespaces.has(s.Namespace)) return false;
|
|
1268
|
+
const key = `${s.Namespace}::${s.OptionName}`;
|
|
1269
|
+
if (excludedOptions.has(key)) return false;
|
|
1270
|
+
// Exclude empty values
|
|
1271
|
+
if (s.Value === undefined || s.Value === null || s.Value === "")
|
|
1272
|
+
return false;
|
|
1273
|
+
return true;
|
|
1274
|
+
})
|
|
1275
|
+
.map((s) => ({
|
|
1276
|
+
Namespace: s.Namespace,
|
|
1277
|
+
OptionName: s.OptionName,
|
|
1278
|
+
Value: s.Value,
|
|
1279
|
+
}));
|
|
1280
|
+
|
|
1281
|
+
// Detect which services exist from source secrets
|
|
1282
|
+
const sourceSecrets = sourceSettings.filter(
|
|
1283
|
+
(s) =>
|
|
1284
|
+
s.Namespace ===
|
|
1285
|
+
"aws:elasticbeanstalk:application:environmentsecrets",
|
|
1286
|
+
);
|
|
907
1287
|
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
/* empty */
|
|
912
|
-
}
|
|
1288
|
+
const hasOpenSearch = sourceSecrets.some(
|
|
1289
|
+
(s) => s.OptionName === "SEARCH_HOST",
|
|
1290
|
+
);
|
|
913
1291
|
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
1292
|
+
// Set SECRET_MIRROR_ENV so setvars.sh reads secrets from the source env
|
|
1293
|
+
OptionSettings.push({
|
|
1294
|
+
Namespace: "aws:elasticbeanstalk:application:environment",
|
|
1295
|
+
OptionName: "SECRET_MIRROR_ENV",
|
|
1296
|
+
Value: sourceEnvironmentName,
|
|
1297
|
+
});
|
|
1298
|
+
|
|
1299
|
+
// Override HealthCheckGracePeriod for initial deploy window
|
|
1300
|
+
const healthGraceIdx = OptionSettings.findIndex(
|
|
1301
|
+
(s) =>
|
|
1302
|
+
s.Namespace ===
|
|
1303
|
+
"aws:elasticbeanstalk:environment:process:default" &&
|
|
1304
|
+
s.OptionName === "HealthCheckGracePeriod",
|
|
1305
|
+
);
|
|
1306
|
+
if (healthGraceIdx !== -1) {
|
|
1307
|
+
OptionSettings[healthGraceIdx].Value = "600";
|
|
919
1308
|
}
|
|
920
1309
|
|
|
921
|
-
//
|
|
922
|
-
const
|
|
1310
|
+
// Phase 4: Create the environment
|
|
1311
|
+
const CNAMEPrefix = `fw-auto-${name.split("-")[name.split("-").length - 1]}`;
|
|
1312
|
+
|
|
1313
|
+
const environment =
|
|
1314
|
+
await aws.elasticbeanstalk.createElasticBeanstalkEnvironment(
|
|
1315
|
+
name,
|
|
1316
|
+
{ language: detectLanguageFromSettings(sourceSettings) },
|
|
1317
|
+
applicationName,
|
|
1318
|
+
OptionSettings,
|
|
1319
|
+
CNAMEPrefix,
|
|
1320
|
+
tags,
|
|
1321
|
+
sourceVersionLabel,
|
|
1322
|
+
);
|
|
1323
|
+
|
|
1324
|
+
// Phase 5: Authorize SG ingress on source service security groups
|
|
1325
|
+
const rdsSlug = aws.slug(
|
|
923
1326
|
repo,
|
|
924
1327
|
process.env.AWS_PROFILE,
|
|
925
|
-
|
|
1328
|
+
sourceBranch,
|
|
926
1329
|
"rds",
|
|
927
1330
|
);
|
|
1331
|
+
const cacheSlug = aws.slug(
|
|
1332
|
+
repo,
|
|
1333
|
+
process.env.AWS_PROFILE,
|
|
1334
|
+
sourceBranch,
|
|
1335
|
+
"cache",
|
|
1336
|
+
);
|
|
928
1337
|
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
1338
|
+
const ingressPromises = [
|
|
1339
|
+
authorizeIngress(
|
|
1340
|
+
aws,
|
|
1341
|
+
name,
|
|
1342
|
+
rdsSlug,
|
|
1343
|
+
"rds",
|
|
1344
|
+
aws.ec2.authorizeRDSIngress,
|
|
1345
|
+
),
|
|
1346
|
+
];
|
|
1347
|
+
|
|
1348
|
+
if (hasOpenSearch) {
|
|
1349
|
+
const sourceOsSlug = aws.slug(
|
|
1350
|
+
repo,
|
|
1351
|
+
process.env.AWS_PROFILE,
|
|
1352
|
+
sourceBranch,
|
|
1353
|
+
"os",
|
|
1354
|
+
);
|
|
1355
|
+
ingressPromises.push(
|
|
1356
|
+
authorizeIngress(
|
|
1357
|
+
aws,
|
|
1358
|
+
name,
|
|
1359
|
+
sourceOsSlug,
|
|
1360
|
+
"os",
|
|
1361
|
+
aws.ec2.authorizeOpenSearchIngress,
|
|
1362
|
+
),
|
|
932
1363
|
);
|
|
933
|
-
const ebSg = await module.exports.ec2.findSecurityGroupByTag([
|
|
934
|
-
{
|
|
935
|
-
Name: "tag:elasticbeanstalk:environment-name",
|
|
936
|
-
Values: [name],
|
|
937
|
-
},
|
|
938
|
-
{
|
|
939
|
-
Name: "tag:aws:cloudformation:logical-id",
|
|
940
|
-
Values: ["AWSEBSecurityGroup"],
|
|
941
|
-
},
|
|
942
|
-
]);
|
|
943
|
-
if (rdsSg && ebSg) {
|
|
944
|
-
await module.exports.ec2.revokeRDSIngress(
|
|
945
|
-
rdsSg.GroupId,
|
|
946
|
-
ebSg.GroupId,
|
|
947
|
-
);
|
|
948
|
-
}
|
|
949
|
-
} catch {
|
|
950
|
-
/* empty */
|
|
951
1364
|
}
|
|
952
1365
|
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
1366
|
+
if (sourceSecrets.some((s) => s.OptionName === "REDIS_HOST")) {
|
|
1367
|
+
ingressPromises.push(
|
|
1368
|
+
authorizeIngress(
|
|
1369
|
+
aws,
|
|
1370
|
+
name,
|
|
1371
|
+
cacheSlug,
|
|
1372
|
+
"cache",
|
|
1373
|
+
aws.ec2.authorizeCacheIngress,
|
|
1374
|
+
),
|
|
1375
|
+
);
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
await Promise.all(ingressPromises);
|
|
1379
|
+
|
|
1380
|
+
// Phase 6: Copy configs
|
|
1381
|
+
const framework = detectFrameworkFromSettings(sourceSettings);
|
|
1382
|
+
const { platform, language } = eb.configurations[framework];
|
|
1383
|
+
const configurations = eb.merge("configurations", {
|
|
1384
|
+
framework,
|
|
1385
|
+
platform,
|
|
1386
|
+
language,
|
|
1387
|
+
});
|
|
1388
|
+
|
|
1389
|
+
await aws.elasticbeanstalk.copyElasticBeanstalkConfigs(
|
|
1390
|
+
configurations,
|
|
957
1391
|
branch,
|
|
958
|
-
"os",
|
|
959
1392
|
);
|
|
960
1393
|
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
1394
|
+
return {
|
|
1395
|
+
url: `https://${environment.CNAME}`,
|
|
1396
|
+
environment: environment.EnvironmentName,
|
|
1397
|
+
paths: [`_IaC/${branch}/*`],
|
|
1398
|
+
copy: [
|
|
966
1399
|
{
|
|
967
|
-
|
|
968
|
-
|
|
1400
|
+
src: `_IaC/${branch}/.elasticbeanstalk/config.yml`,
|
|
1401
|
+
dest: `.elasticbeanstalk/config.yml`,
|
|
969
1402
|
},
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
},
|
|
974
|
-
]);
|
|
975
|
-
if (osSg && ebSgForOs) {
|
|
976
|
-
await module.exports.ec2.revokeOpenSearchIngress(
|
|
977
|
-
osSg.GroupId,
|
|
978
|
-
ebSgForOs.GroupId,
|
|
979
|
-
);
|
|
980
|
-
}
|
|
981
|
-
} catch {
|
|
982
|
-
/* empty */
|
|
983
|
-
}
|
|
1403
|
+
],
|
|
1404
|
+
};
|
|
1405
|
+
};
|
|
984
1406
|
|
|
985
|
-
|
|
986
|
-
|
|
1407
|
+
const detectLanguageFromSettings = (settings) => {
|
|
1408
|
+
const envVars = settings.filter(
|
|
1409
|
+
(s) =>
|
|
1410
|
+
s.Namespace === "aws:elasticbeanstalk:application:environment" ||
|
|
1411
|
+
s.Namespace ===
|
|
1412
|
+
"aws:elasticbeanstalk:application:environmentsecrets",
|
|
1413
|
+
);
|
|
1414
|
+
const keys = envVars.map((s) => s.OptionName);
|
|
1415
|
+
if (keys.includes("DJANGO_SETTINGS_MODULE")) return "python";
|
|
1416
|
+
if (keys.includes("NODE_ENV")) return "node";
|
|
1417
|
+
return "php";
|
|
1418
|
+
};
|
|
1419
|
+
|
|
1420
|
+
const detectFrameworkFromSettings = (settings) => {
|
|
1421
|
+
const envVars = settings.filter(
|
|
1422
|
+
(s) =>
|
|
1423
|
+
s.Namespace === "aws:elasticbeanstalk:application:environment" ||
|
|
1424
|
+
s.Namespace ===
|
|
1425
|
+
"aws:elasticbeanstalk:application:environmentsecrets",
|
|
1426
|
+
);
|
|
1427
|
+
const keys = envVars.map((s) => s.OptionName);
|
|
1428
|
+
if (keys.includes("DJANGO_SETTINGS_MODULE")) return "python";
|
|
1429
|
+
if (keys.includes("ADONIS_ACE_CWD")) return "adonis";
|
|
1430
|
+
if (keys.includes("APP_KEY")) return "laravel";
|
|
1431
|
+
return "laravel";
|
|
1432
|
+
};
|
|
1433
|
+
|
|
1434
|
+
module.exports.fullstackCloneTerminate = async (
|
|
1435
|
+
name,
|
|
1436
|
+
repo,
|
|
1437
|
+
branch,
|
|
1438
|
+
sourceBranch,
|
|
1439
|
+
) => {
|
|
1440
|
+
const aws = module.exports;
|
|
1441
|
+
|
|
1442
|
+
const rdsSlug = aws.slug(
|
|
1443
|
+
repo,
|
|
1444
|
+
process.env.AWS_PROFILE,
|
|
1445
|
+
sourceBranch,
|
|
1446
|
+
"rds",
|
|
1447
|
+
);
|
|
1448
|
+
const osSlug = aws.slug(repo, process.env.AWS_PROFILE, sourceBranch, "os");
|
|
1449
|
+
const cacheSlug = aws.slug(
|
|
1450
|
+
repo,
|
|
1451
|
+
process.env.AWS_PROFILE,
|
|
1452
|
+
sourceBranch,
|
|
1453
|
+
"cache",
|
|
1454
|
+
);
|
|
1455
|
+
|
|
1456
|
+
// Phase 1: Revoke SG ingress rules
|
|
1457
|
+
await Promise.all([
|
|
1458
|
+
authorizeIngress(aws, name, rdsSlug, "rds", aws.ec2.revokeRDSIngress),
|
|
1459
|
+
authorizeIngress(
|
|
1460
|
+
aws,
|
|
987
1461
|
name,
|
|
988
|
-
|
|
1462
|
+
osSlug,
|
|
1463
|
+
"os",
|
|
1464
|
+
aws.ec2.revokeOpenSearchIngress,
|
|
1465
|
+
),
|
|
1466
|
+
authorizeIngress(
|
|
1467
|
+
aws,
|
|
1468
|
+
name,
|
|
1469
|
+
cacheSlug,
|
|
1470
|
+
"cache",
|
|
1471
|
+
aws.ec2.revokeCacheIngress,
|
|
1472
|
+
),
|
|
1473
|
+
]);
|
|
1474
|
+
|
|
1475
|
+
// Phase 2: Terminate EB environment
|
|
1476
|
+
try {
|
|
1477
|
+
await aws.elasticbeanstalk.removeElasticBeanstalkEnvironment(name);
|
|
989
1478
|
} catch {
|
|
990
1479
|
/* empty */
|
|
991
1480
|
}
|
|
992
1481
|
|
|
993
|
-
try
|
|
994
|
-
|
|
995
|
-
|
|
1482
|
+
// Phase 3: Terminate EB environment + try removing app if empty
|
|
1483
|
+
await Promise.all([
|
|
1484
|
+
(async () => {
|
|
1485
|
+
try {
|
|
1486
|
+
const envs = await aws.elasticbeanstalk.listEnvironments(repo);
|
|
1487
|
+
if (!envs.length) {
|
|
1488
|
+
await aws.elasticbeanstalk.removeElasticBeanstalkApplication(
|
|
1489
|
+
repo,
|
|
1490
|
+
);
|
|
1491
|
+
}
|
|
1492
|
+
} catch {
|
|
1493
|
+
/* empty */
|
|
1494
|
+
}
|
|
1495
|
+
})(),
|
|
1496
|
+
]);
|
|
1497
|
+
};
|
|
1498
|
+
|
|
1499
|
+
module.exports.fullstackTerminate = async (
|
|
1500
|
+
name,
|
|
1501
|
+
repo,
|
|
1502
|
+
branch,
|
|
1503
|
+
{
|
|
1504
|
+
deleteStorage = false,
|
|
1505
|
+
deleteDatabase = false,
|
|
1506
|
+
deleteOpenSearch = false,
|
|
1507
|
+
deleteCache = false,
|
|
1508
|
+
deleteMail = false,
|
|
1509
|
+
deleteAppSecret = false,
|
|
1510
|
+
} = {},
|
|
1511
|
+
) => {
|
|
1512
|
+
const aws = module.exports;
|
|
1513
|
+
|
|
1514
|
+
const rdsSlug = aws.slug(repo, process.env.AWS_PROFILE, branch, "rds");
|
|
1515
|
+
const osSlug = aws.slug(repo, process.env.AWS_PROFILE, branch, "os");
|
|
1516
|
+
const cacheSlug = aws.slug(repo, process.env.AWS_PROFILE, branch, "cache");
|
|
1517
|
+
|
|
1518
|
+
// Phase 1: Storage cleanup + revoke all SG ingress rules in parallel
|
|
1519
|
+
const phase1 = [];
|
|
1520
|
+
|
|
1521
|
+
if (deleteStorage) {
|
|
1522
|
+
const s3Slug = aws.slug(repo, process.env.AWS_PROFILE, branch, "s3");
|
|
1523
|
+
|
|
1524
|
+
phase1.push(
|
|
1525
|
+
(async () => {
|
|
1526
|
+
try {
|
|
1527
|
+
await aws.iam.removeIAMUser(s3Slug);
|
|
1528
|
+
} catch {
|
|
1529
|
+
/* empty */
|
|
1530
|
+
}
|
|
1531
|
+
try {
|
|
1532
|
+
await aws.s3.emptyS3Bucket(s3Slug);
|
|
1533
|
+
} catch {
|
|
1534
|
+
/* empty */
|
|
1535
|
+
}
|
|
1536
|
+
try {
|
|
1537
|
+
await aws.s3.removeS3Bucket(s3Slug);
|
|
1538
|
+
} catch {
|
|
1539
|
+
/* empty */
|
|
1540
|
+
}
|
|
1541
|
+
})(),
|
|
996
1542
|
);
|
|
997
|
-
} catch {
|
|
998
|
-
/* empty */
|
|
999
1543
|
}
|
|
1000
1544
|
|
|
1545
|
+
phase1.push(
|
|
1546
|
+
authorizeIngress(aws, name, rdsSlug, "rds", aws.ec2.revokeRDSIngress),
|
|
1547
|
+
authorizeIngress(
|
|
1548
|
+
aws,
|
|
1549
|
+
name,
|
|
1550
|
+
osSlug,
|
|
1551
|
+
"os",
|
|
1552
|
+
aws.ec2.revokeOpenSearchIngress,
|
|
1553
|
+
),
|
|
1554
|
+
authorizeIngress(
|
|
1555
|
+
aws,
|
|
1556
|
+
name,
|
|
1557
|
+
cacheSlug,
|
|
1558
|
+
"cache",
|
|
1559
|
+
aws.ec2.revokeCacheIngress,
|
|
1560
|
+
),
|
|
1561
|
+
);
|
|
1562
|
+
|
|
1563
|
+
await Promise.all(phase1);
|
|
1564
|
+
|
|
1565
|
+
// Phase 2: Terminate EB environment (must complete before SG deletion)
|
|
1001
1566
|
try {
|
|
1002
|
-
await
|
|
1567
|
+
await aws.elasticbeanstalk.removeElasticBeanstalkEnvironment(name);
|
|
1003
1568
|
} catch {
|
|
1004
1569
|
/* empty */
|
|
1005
1570
|
}
|
|
1006
1571
|
|
|
1572
|
+
// Phase 3: EB app + instance profile + initiate service deletions in parallel
|
|
1573
|
+
const phase3 = [
|
|
1574
|
+
(async () => {
|
|
1575
|
+
try {
|
|
1576
|
+
await aws.elasticbeanstalk.removeElasticBeanstalkApplication(
|
|
1577
|
+
repo,
|
|
1578
|
+
);
|
|
1579
|
+
} catch {
|
|
1580
|
+
/* empty */
|
|
1581
|
+
}
|
|
1582
|
+
})(),
|
|
1583
|
+
(async () => {
|
|
1584
|
+
try {
|
|
1585
|
+
await aws.iam.removeEnvironmentInstanceProfile(name);
|
|
1586
|
+
} catch {
|
|
1587
|
+
/* empty */
|
|
1588
|
+
}
|
|
1589
|
+
})(),
|
|
1590
|
+
];
|
|
1591
|
+
|
|
1007
1592
|
if (deleteDatabase) {
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
await module.exports.rds.waitForRDSDeletion(rdsSlug);
|
|
1011
|
-
} catch {
|
|
1012
|
-
/* empty */
|
|
1013
|
-
}
|
|
1593
|
+
phase3.push(aws.rds.deleteRDSInstance(rdsSlug).catch(() => {}));
|
|
1594
|
+
}
|
|
1014
1595
|
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1596
|
+
if (deleteOpenSearch) {
|
|
1597
|
+
phase3.push(
|
|
1598
|
+
aws.opensearch.deleteOpenSearchDomain(osSlug).catch(() => {}),
|
|
1599
|
+
);
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
if (deleteCache) {
|
|
1603
|
+
phase3.push(
|
|
1604
|
+
aws.elasticache.deleteCacheCluster(cacheSlug).catch(() => {}),
|
|
1605
|
+
);
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
await Promise.all(phase3);
|
|
1609
|
+
|
|
1610
|
+
// Phase 4: Wait for service deletions with round-robin polling
|
|
1611
|
+
const deletionPolls = [];
|
|
1612
|
+
|
|
1613
|
+
if (deleteDatabase) {
|
|
1614
|
+
const rdsClient = new RDSClient({});
|
|
1615
|
+
|
|
1616
|
+
deletionPolls.push({
|
|
1617
|
+
name: "RDS",
|
|
1618
|
+
cb: async () => {
|
|
1619
|
+
try {
|
|
1620
|
+
const res = await rdsClient.send(
|
|
1621
|
+
new DescribeDBInstancesCommand({
|
|
1622
|
+
DBInstanceIdentifier: rdsSlug,
|
|
1623
|
+
}),
|
|
1624
|
+
);
|
|
1625
|
+
return res.DBInstances[0];
|
|
1626
|
+
} catch (e) {
|
|
1627
|
+
if (e.name === "DBInstanceNotFoundFault") {
|
|
1628
|
+
return { DBInstanceStatus: "deleted" };
|
|
1629
|
+
}
|
|
1630
|
+
throw e;
|
|
1631
|
+
}
|
|
1632
|
+
},
|
|
1633
|
+
shouldContinue: ({ DBInstanceStatus }) =>
|
|
1634
|
+
DBInstanceStatus !== "deleted",
|
|
1635
|
+
});
|
|
1025
1636
|
}
|
|
1026
1637
|
|
|
1027
1638
|
if (deleteOpenSearch) {
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1639
|
+
const osClient = new OpenSearchClient({});
|
|
1640
|
+
|
|
1641
|
+
deletionPolls.push({
|
|
1642
|
+
name: "OpenSearch",
|
|
1643
|
+
cb: async () => {
|
|
1644
|
+
try {
|
|
1645
|
+
const { DomainStatus } = await osClient.send(
|
|
1646
|
+
new DescribeDomainCommand({ DomainName: osSlug }),
|
|
1647
|
+
);
|
|
1648
|
+
return DomainStatus;
|
|
1649
|
+
} catch (e) {
|
|
1650
|
+
if (e.name === "ResourceNotFoundException") {
|
|
1651
|
+
return { deleted: true };
|
|
1652
|
+
}
|
|
1653
|
+
throw e;
|
|
1654
|
+
}
|
|
1655
|
+
},
|
|
1656
|
+
shouldContinue: (status) => !status.deleted,
|
|
1657
|
+
});
|
|
1658
|
+
}
|
|
1034
1659
|
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1660
|
+
if (deleteCache) {
|
|
1661
|
+
const cacheClient = new ElastiCacheClient({});
|
|
1662
|
+
|
|
1663
|
+
deletionPolls.push({
|
|
1664
|
+
name: "ElastiCache",
|
|
1665
|
+
cb: async () => {
|
|
1666
|
+
try {
|
|
1667
|
+
const { ReplicationGroups } = await cacheClient.send(
|
|
1668
|
+
new DescribeReplicationGroupsCommand({
|
|
1669
|
+
ReplicationGroupId: cacheSlug,
|
|
1670
|
+
}),
|
|
1671
|
+
);
|
|
1672
|
+
return ReplicationGroups[0];
|
|
1673
|
+
} catch (e) {
|
|
1674
|
+
if (e.name === "ReplicationGroupNotFoundFault") {
|
|
1675
|
+
return { deleted: true };
|
|
1676
|
+
}
|
|
1677
|
+
throw e;
|
|
1678
|
+
}
|
|
1679
|
+
},
|
|
1680
|
+
shouldContinue: (group) => !group.deleted,
|
|
1681
|
+
});
|
|
1045
1682
|
}
|
|
1046
1683
|
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
try {
|
|
1050
|
-
await module.exports.secretsmanager.deleteSecret(`${name}-storage`);
|
|
1051
|
-
} catch {
|
|
1052
|
-
/* empty */
|
|
1053
|
-
}
|
|
1684
|
+
if (deletionPolls.length) {
|
|
1685
|
+
await pollAll(deletionPolls, "Waiting for services to terminate");
|
|
1054
1686
|
}
|
|
1055
1687
|
|
|
1688
|
+
// Phase 5: Delete security groups after services are gone
|
|
1689
|
+
const sgDeletions = [];
|
|
1690
|
+
|
|
1056
1691
|
if (deleteDatabase) {
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1692
|
+
sgDeletions.push(
|
|
1693
|
+
(async () => {
|
|
1694
|
+
try {
|
|
1695
|
+
const sg = await aws.ec2.findSecurityGroupByName(
|
|
1696
|
+
`${rdsSlug}-rds`,
|
|
1697
|
+
);
|
|
1698
|
+
if (sg) {
|
|
1699
|
+
await aws.ec2.deleteSecurityGroup(sg.GroupId);
|
|
1700
|
+
}
|
|
1701
|
+
} catch {
|
|
1702
|
+
/* empty */
|
|
1703
|
+
}
|
|
1704
|
+
})(),
|
|
1705
|
+
);
|
|
1064
1706
|
}
|
|
1065
1707
|
|
|
1066
1708
|
if (deleteOpenSearch) {
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1709
|
+
sgDeletions.push(
|
|
1710
|
+
(async () => {
|
|
1711
|
+
try {
|
|
1712
|
+
const sg = await aws.ec2.findSecurityGroupByName(
|
|
1713
|
+
`${osSlug}-os`,
|
|
1714
|
+
);
|
|
1715
|
+
if (sg) {
|
|
1716
|
+
await aws.ec2.deleteSecurityGroup(sg.GroupId);
|
|
1717
|
+
}
|
|
1718
|
+
} catch {
|
|
1719
|
+
/* empty */
|
|
1720
|
+
}
|
|
1721
|
+
})(),
|
|
1722
|
+
);
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
if (deleteCache) {
|
|
1726
|
+
sgDeletions.push(
|
|
1727
|
+
(async () => {
|
|
1728
|
+
try {
|
|
1729
|
+
const sg = await aws.ec2.findSecurityGroupByName(
|
|
1730
|
+
`${cacheSlug}-cache`,
|
|
1731
|
+
);
|
|
1732
|
+
if (sg) {
|
|
1733
|
+
await aws.ec2.deleteSecurityGroup(sg.GroupId);
|
|
1734
|
+
}
|
|
1735
|
+
} catch {
|
|
1736
|
+
/* empty */
|
|
1737
|
+
}
|
|
1738
|
+
})(),
|
|
1739
|
+
);
|
|
1074
1740
|
}
|
|
1075
1741
|
|
|
1742
|
+
if (sgDeletions.length) {
|
|
1743
|
+
await Promise.all(sgDeletions);
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
// Phase 6: Delete secrets in parallel
|
|
1747
|
+
const secretDeletions = [];
|
|
1748
|
+
|
|
1749
|
+
if (deleteStorage) {
|
|
1750
|
+
secretDeletions.push(
|
|
1751
|
+
aws.secretsmanager.deleteSecret(`${name}-storage`).catch(() => {}),
|
|
1752
|
+
);
|
|
1753
|
+
}
|
|
1754
|
+
if (deleteDatabase) {
|
|
1755
|
+
secretDeletions.push(
|
|
1756
|
+
aws.secretsmanager.deleteSecret(`${name}-database`).catch(() => {}),
|
|
1757
|
+
);
|
|
1758
|
+
}
|
|
1759
|
+
if (deleteOpenSearch) {
|
|
1760
|
+
secretDeletions.push(
|
|
1761
|
+
aws.secretsmanager
|
|
1762
|
+
.deleteSecret(`${name}-opensearch`)
|
|
1763
|
+
.catch(() => {}),
|
|
1764
|
+
);
|
|
1765
|
+
}
|
|
1766
|
+
if (deleteCache) {
|
|
1767
|
+
secretDeletions.push(
|
|
1768
|
+
aws.secretsmanager.deleteSecret(`${name}-cache`).catch(() => {}),
|
|
1769
|
+
);
|
|
1770
|
+
}
|
|
1076
1771
|
if (deleteAppSecret) {
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
/* empty */
|
|
1081
|
-
}
|
|
1772
|
+
secretDeletions.push(
|
|
1773
|
+
aws.secretsmanager.deleteSecret(`${name}-app`).catch(() => {}),
|
|
1774
|
+
);
|
|
1082
1775
|
}
|
|
1776
|
+
if (deleteMail) {
|
|
1777
|
+
secretDeletions.push(
|
|
1778
|
+
aws.secretsmanager.deleteSecret(`${name}-mail`).catch(() => {}),
|
|
1779
|
+
);
|
|
1780
|
+
secretDeletions.push(
|
|
1781
|
+
(async () => {
|
|
1782
|
+
const {
|
|
1783
|
+
IAMClient,
|
|
1784
|
+
DeleteAccessKeyCommand,
|
|
1785
|
+
ListAccessKeysCommand,
|
|
1786
|
+
RemoveUserFromGroupCommand,
|
|
1787
|
+
DeleteUserCommand,
|
|
1788
|
+
} = require("@aws-sdk/client-iam");
|
|
1789
|
+
const { fromIni } = require("@aws-sdk/credential-providers");
|
|
1790
|
+
|
|
1791
|
+
const iamClient = new IAMClient({
|
|
1792
|
+
region: "us-east-1",
|
|
1793
|
+
credentials: fromIni({ profile: "fishawack" }),
|
|
1794
|
+
});
|
|
1795
|
+
const userName = `${name}-ses`;
|
|
1796
|
+
|
|
1797
|
+
try {
|
|
1798
|
+
const keys = await iamClient.send(
|
|
1799
|
+
new ListAccessKeysCommand({ UserName: userName }),
|
|
1800
|
+
);
|
|
1801
|
+
for (const key of keys.AccessKeyMetadata || []) {
|
|
1802
|
+
await iamClient.send(
|
|
1803
|
+
new DeleteAccessKeyCommand({
|
|
1804
|
+
UserName: userName,
|
|
1805
|
+
AccessKeyId: key.AccessKeyId,
|
|
1806
|
+
}),
|
|
1807
|
+
);
|
|
1808
|
+
}
|
|
1809
|
+
await iamClient.send(
|
|
1810
|
+
new RemoveUserFromGroupCommand({
|
|
1811
|
+
UserName: userName,
|
|
1812
|
+
GroupName: "AWSSESSendingGroupDoNotRename",
|
|
1813
|
+
}),
|
|
1814
|
+
);
|
|
1815
|
+
await iamClient.send(
|
|
1816
|
+
new DeleteUserCommand({ UserName: userName }),
|
|
1817
|
+
);
|
|
1818
|
+
} catch {
|
|
1819
|
+
/* user may not exist */
|
|
1820
|
+
}
|
|
1821
|
+
})(),
|
|
1822
|
+
);
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
await Promise.all(secretDeletions);
|
|
1083
1826
|
};
|