@fishawack/lab-env 5.7.0-beta.1 → 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.
Files changed (37) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/_Test/provision.js +4 -9
  3. package/_Test/prune.js +8 -5
  4. package/bitbucket-pipelines.yml +2 -2
  5. package/cli.js +1 -0
  6. package/commands/create/cmds/dekey.js +13 -9
  7. package/commands/create/cmds/deprovision.js +36 -0
  8. package/commands/create/cmds/key.js +60 -41
  9. package/commands/create/cmds/provision.js +573 -69
  10. package/commands/create/cmds/rekey.js +218 -0
  11. package/commands/create/cmds/sync-secrets.js +2 -1
  12. package/commands/create/libs/output-credentials.js +59 -0
  13. package/commands/create/libs/parallel-runner.js +204 -0
  14. package/commands/create/libs/resolve-operator.js +59 -0
  15. package/commands/create/libs/utilities.js +73 -8
  16. package/commands/create/libs/vars.js +120 -136
  17. package/commands/create/services/aws/acm.js +61 -0
  18. package/commands/create/services/aws/cloudfront.js +51 -45
  19. package/commands/create/services/aws/ec2.js +675 -48
  20. package/commands/create/services/aws/elasticache.js +159 -0
  21. package/commands/create/services/aws/elasticbeanstalk.js +154 -57
  22. package/commands/create/services/aws/iam.js +402 -82
  23. package/commands/create/services/aws/index.js +1398 -260
  24. package/commands/create/services/aws/opensearch.js +155 -0
  25. package/commands/create/services/aws/rds.js +57 -31
  26. package/commands/create/services/aws/s3.js +52 -31
  27. package/commands/create/services/aws/secretsmanager.js +21 -12
  28. package/commands/create/services/aws/ses.js +195 -0
  29. package/commands/create/templates/elasticbeanstalk/.ebextensions/laravel/cron.config +45 -0
  30. package/commands/create/templates/elasticbeanstalk/.ebextensions/laravel/queue-worker.config +29 -0
  31. package/commands/create/templates/elasticbeanstalk/.ebextensions/laravel/software.config +12 -0
  32. package/commands/create/templates/elasticbeanstalk/.ebextensions/misc/setvars.config +2 -16
  33. package/commands/create/templates/elasticbeanstalk/.platform/confighooks/postdeploy/rewrite-flush.sh +1 -1
  34. package/commands/create/templates/elasticbeanstalk/.platform/confighooks/postdeploy/setvars.sh +19 -0
  35. package/commands/create/templates/elasticbeanstalk/.platform/hooks/postdeploy/restart_queue.sh +4 -0
  36. package/commands/create/templates/elasticbeanstalk/.platform/hooks/prebuild/setvars.sh +19 -0
  37. package/package.json +7 -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 {
@@ -10,9 +8,17 @@ const {
10
8
  SecretsManagerClient,
11
9
  DescribeSecretCommand,
12
10
  } = require("@aws-sdk/client-secrets-manager");
11
+ const {
12
+ OpenSearchClient,
13
+ DescribeDomainCommand,
14
+ } = require("@aws-sdk/client-opensearch");
15
+ const {
16
+ ElastiCacheClient,
17
+ DescribeReplicationGroupsCommand,
18
+ } = require("@aws-sdk/client-elasticache");
13
19
 
14
- const { nameSafe, Spinner } = require("../../libs/utilities.js");
15
- const { eb } = require("../../libs/vars.js");
20
+ const { nameSafe, Spinner, pollAll } = require("../../libs/utilities.js");
21
+ const { eb, secretGroups } = require("../../libs/vars.js");
16
22
 
17
23
  module.exports.s3 = require("./s3.js");
18
24
  module.exports.cloudfront = require("./cloudfront.js");
@@ -20,7 +26,11 @@ module.exports.iam = require("./iam.js");
20
26
  module.exports.elasticbeanstalk = require("./elasticbeanstalk.js");
21
27
  module.exports.ec2 = require("./ec2.js");
22
28
  module.exports.rds = require("./rds.js");
29
+ module.exports.opensearch = require("./opensearch.js");
30
+ module.exports.elasticache = require("./elasticache.js");
23
31
  module.exports.secretsmanager = require("./secretsmanager.js");
32
+ module.exports.acm = require("./acm.js");
33
+ module.exports.ses = require("./ses.js");
24
34
 
25
35
  module.exports.slug = (repo, client, branch, service = "s3") =>
26
36
  nameSafe(`${branch}-${repo}-${client}`, service);
@@ -29,13 +39,27 @@ module.exports.fullstackPreflight = async (
29
39
  name,
30
40
  repo,
31
41
  branch,
32
- { storage = false, database = false } = {},
42
+ {
43
+ storage = false,
44
+ database = false,
45
+ opensearch = false,
46
+ cache = false,
47
+ } = {},
33
48
  ) => {
34
- const conflicts = [];
35
49
  const existing = {
50
+ environment: false,
36
51
  storage: false,
37
52
  database: false,
38
- secrets: { app: null, storage: null, database: null },
53
+ opensearch: false,
54
+ cache: false,
55
+ secrets: {
56
+ app: null,
57
+ storage: null,
58
+ database: null,
59
+ opensearch: null,
60
+ cache: null,
61
+ mail: null,
62
+ },
39
63
  };
40
64
 
41
65
  const spinner = new Spinner("Running pre-flight checks", true);
@@ -50,9 +74,7 @@ module.exports.fullstackPreflight = async (
50
74
  (e) => e.Status !== "Terminated",
51
75
  );
52
76
  if (active.length) {
53
- conflicts.push(
54
- `Elastic Beanstalk environment "${name}" already exists (status: ${active[0].Status})`,
55
- );
77
+ existing.environment = active[0];
56
78
  }
57
79
  } catch {
58
80
  /* does not exist */
@@ -105,8 +127,56 @@ module.exports.fullstackPreflight = async (
105
127
  }
106
128
  }
107
129
 
130
+ // Check OpenSearch
131
+ if (opensearch) {
132
+ const osSlug = module.exports.slug(
133
+ repo,
134
+ process.env.AWS_PROFILE,
135
+ branch,
136
+ "os",
137
+ );
138
+
139
+ spinner.ora.text = "Checking for existing OpenSearch domain";
140
+ try {
141
+ const { DomainStatus } = await new OpenSearchClient({}).send(
142
+ new DescribeDomainCommand({ DomainName: osSlug }),
143
+ );
144
+ if (DomainStatus && !DomainStatus.Deleted) {
145
+ existing.opensearch = true;
146
+ }
147
+ } catch {
148
+ /* does not exist */
149
+ }
150
+ }
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
+
108
179
  // Check for existing secrets
109
- const secretGroups = ["app", "storage", "database"];
110
180
  for (const group of secretGroups) {
111
181
  spinner.ora.text = `Checking for existing ${group} secret`;
112
182
  try {
@@ -125,7 +195,7 @@ module.exports.fullstackPreflight = async (
125
195
  throw e;
126
196
  }
127
197
 
128
- return { conflicts, existing };
198
+ return { existing };
129
199
  };
130
200
 
131
201
  module.exports.clients = [
@@ -272,140 +342,659 @@ module.exports.staticTerminate = async (name, repo, branch, id) => {
272
342
  }
273
343
  };
274
344
 
275
- module.exports.fullstack = async (
276
- name,
277
- tags = [],
278
- credentials = [],
279
- repo,
280
- branch,
281
- framework,
282
- availability,
283
- database = false,
284
- storage = false,
285
- existingSecrets = { app: null, storage: null, database: null },
286
- snapshot = null,
287
- ) => {
288
- const { platform, language } = eb.configurations[framework];
345
+ // Private provisioner functions for parallel execution
289
346
 
290
- let s3Slug;
347
+ const provisionStorage = async (
348
+ aws,
349
+ s3Slug,
350
+ tags,
351
+ isLaravel,
352
+ existingResources,
353
+ ) => {
291
354
  let s3Data = {};
292
355
 
293
- if (storage) {
294
- s3Slug = module.exports.slug(
295
- repo,
296
- process.env.AWS_PROFILE,
297
- branch,
298
- "s3",
299
- );
356
+ if (!existingResources.storage) {
357
+ if (isLaravel) {
358
+ await aws.s3.createS3Bucket(s3Slug, tags, {
359
+ BlockPublicAcls: true,
360
+ IgnorePublicAcls: true,
361
+ BlockPublicPolicy: false,
362
+ RestrictPublicBuckets: false,
363
+ });
300
364
 
301
- await module.exports.iam.createIAMUser(s3Slug, tags);
302
- await module.exports.iam.attachIAMPolicy(
303
- s3Slug,
304
- "arn:aws:iam::aws:policy/AmazonS3FullAccess",
305
- );
306
- const {
307
- AccessKey: { AccessKeyId },
308
- AccessKey: { SecretAccessKey },
309
- } = await module.exports.iam.createAccessKey(s3Slug);
365
+ await aws.s3.setPublicReadPolicy(s3Slug, "public/*");
366
+ } else {
367
+ await aws.iam.createIAMUser(s3Slug, tags);
368
+ await aws.iam.attachIAMPolicy(
369
+ s3Slug,
370
+ "arn:aws:iam::aws:policy/AmazonS3FullAccess",
371
+ );
372
+ const {
373
+ AccessKey: { AccessKeyId },
374
+ AccessKey: { SecretAccessKey },
375
+ } = await aws.iam.createAccessKey(s3Slug);
310
376
 
311
- await module.exports.s3.createS3Bucket(
312
- s3Slug,
313
- tags,
314
- false,
315
- "BucketOwnerPreferred",
316
- );
377
+ await aws.s3.createS3Bucket(
378
+ s3Slug,
379
+ tags,
380
+ false,
381
+ "BucketOwnerPreferred",
382
+ );
317
383
 
318
- s3Data = { s3Slug, AccessKeyId, SecretAccessKey };
384
+ s3Data = { s3Slug, AccessKeyId, SecretAccessKey };
385
+ }
319
386
  }
320
387
 
321
- await module.exports.iam.ensureEBInstanceProfileExists();
322
- await module.exports.iam.ensureEBManagedUpdateProfileExists();
388
+ if (!s3Data.s3Slug) {
389
+ s3Data = { s3Slug };
390
+ }
323
391
 
324
- // Create decoupled RDS instance if database is required
325
- let rdsData = {};
392
+ return s3Data;
393
+ };
326
394
 
327
- if (database) {
328
- const rdsSlug = module.exports.slug(
329
- repo,
330
- process.env.AWS_PROFILE,
331
- branch,
332
- "rds",
333
- );
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({});
334
408
 
409
+ if (!existingResources.database) {
335
410
  const dbPassword = generator.generate({
336
411
  length: 10,
337
412
  numbers: true,
338
413
  });
339
414
 
340
- await module.exports.rds.ensureSSLParameterGroup();
415
+ await aws.rds.ensureSSLParameterGroup();
341
416
 
342
- const rdsSecurityGroupId =
343
- await module.exports.ec2.createRDSSecurityGroup(rdsSlug, tags);
417
+ const rdsSecurityGroupId = await aws.ec2.createRDSSecurityGroup(
418
+ rdsSlug,
419
+ tags,
420
+ );
421
+
422
+ const dbSubnetGroupName = await aws.rds.ensureDBSubnetGroup(
423
+ vpcData.privateSubnetIds,
424
+ );
344
425
 
345
426
  if (snapshot) {
346
- await module.exports.rds.restoreRDSInstanceFromSnapshot(
427
+ await aws.rds.restoreRDSInstanceFromSnapshot(
347
428
  rdsSlug,
348
429
  snapshot,
349
430
  rdsSecurityGroupId,
350
431
  tags,
432
+ dbSubnetGroupName,
351
433
  );
352
434
 
353
- await module.exports.rds.waitForRDSInstance(rdsSlug);
354
-
355
- await module.exports.rds.modifyRDSPassword(rdsSlug, dbPassword);
435
+ await aws.rds.waitForRDSInstance(rdsSlug);
436
+ await aws.rds.modifyRDSPassword(rdsSlug, dbPassword);
356
437
  } else {
357
- await module.exports.rds.createRDSInstance(
438
+ await aws.rds.createRDSInstance(
358
439
  rdsSlug,
359
440
  tags,
360
441
  dbPassword,
361
442
  rdsSecurityGroupId,
443
+ dbSubnetGroupName,
362
444
  );
363
445
  }
364
446
 
365
- const rdsInstance =
366
- await module.exports.rds.waitForRDSInstance(rdsSlug);
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
+ },
463
+ };
464
+ } else if (existingResources.database && !existingSecrets.database) {
465
+ const dbPassword = generator.generate({
466
+ length: 10,
467
+ numbers: true,
468
+ });
469
+
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
+ },
488
+ };
489
+ } else {
490
+ const secretValue = await aws.secretsmanager.getSecretValue(
491
+ `${name}-database`,
492
+ );
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
+ };
501
+ }
502
+ };
503
+
504
+ const provisionOpenSearch = async (
505
+ aws,
506
+ osSlug,
507
+ tags,
508
+ vpcData,
509
+ instanceProfileName,
510
+ existingResources,
511
+ ) => {
512
+ const osClient = new OpenSearchClient({});
513
+
514
+ if (!existingResources.opensearch) {
515
+ await aws.iam.ensureOpenSearchServiceRole();
516
+
517
+ const roleArn = instanceProfileName
518
+ ? (await aws.iam.getRole(instanceProfileName)).Role.Arn
519
+ : undefined;
520
+
521
+ const osSecurityGroupId = await aws.ec2.createOpenSearchSecurityGroup(
522
+ osSlug,
523
+ tags,
524
+ );
525
+
526
+ await aws.opensearch.createOpenSearchDomain(
527
+ osSlug,
528
+ osSecurityGroupId,
529
+ vpcData.privateSubnetIds,
530
+ roleArn,
531
+ tags,
532
+ );
533
+
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);
551
+
552
+ const osEndpoint =
553
+ osDomain.Endpoint || (osDomain.Endpoints && osDomain.Endpoints.vpc);
554
+
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
+ },
610
+ };
611
+ } else if (existingResources.cache && !existingSecrets.cache) {
612
+ const cluster = await aws.elasticache.describeCacheCluster(cacheSlug);
613
+
614
+ const cacheEndpoint = cluster.NodeGroups[0].PrimaryEndpoint;
367
615
 
368
- rdsData = {
369
- RDS_HOSTNAME: rdsInstance.Endpoint.Address,
370
- RDS_PORT: String(rdsInstance.Endpoint.Port),
371
- RDS_PASSWORD: dbPassword,
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
+ },
372
633
  };
373
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`;
374
657
 
658
+ // Create user in the fishawack account
375
659
  try {
376
- await module.exports.elasticbeanstalk.createElasticBeanstalkApplication(
377
- repo,
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
+ },
677
+ );
678
+ } catch {
679
+ await Spinner.prototype.simple(
680
+ `Retrieving existing SES IAM user ${userName}`,
681
+ () => {
682
+ return iamClient.send(
683
+ new GetUserCommand({ UserName: userName }),
684
+ );
685
+ },
378
686
  );
687
+ }
688
+
689
+ // Add user to SES sending group
690
+ await aws.iam.addUserToGroup(userName, groupName, iamClient);
691
+
692
+ // Create access key
693
+ const existingKeys = await iamClient.send(
694
+ new ListAccessKeysCommand({ UserName: userName }),
695
+ );
696
+
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
+ }
706
+
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
+ );
713
+ },
714
+ );
715
+
716
+ // Derive SMTP credentials
717
+ const smtpPassword = aws.ses.deriveSmtpPassword(
718
+ keyRes.AccessKey.SecretAccessKey,
719
+ sesRegion,
720
+ );
721
+
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
+ };
729
+
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);
745
+ }
379
746
  } catch {
380
747
  /* empty */
381
748
  }
749
+ };
750
+
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;
788
+
789
+ // Compute slugs
790
+ const s3Slug =
791
+ storage || existingSecrets.storage
792
+ ? aws.slug(repo, process.env.AWS_PROFILE, branch, "s3")
793
+ : undefined;
794
+
795
+ const osSlug =
796
+ opensearch || existingSecrets.opensearch
797
+ ? aws.slug(repo, process.env.AWS_PROFILE, branch, "os")
798
+ : undefined;
799
+
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
+ }
382
821
 
383
- let hasKeyPair = false;
822
+ // Phase 2: VPC + EB app in parallel
823
+ const [vpcData] = await Promise.all([
824
+ aws.ec2.ensurePrivateSubnets(),
825
+ aws.iam.ensureEBManagedUpdateProfileExists(),
826
+ ]);
384
827
 
385
828
  try {
386
- await module.exports.ec2.getKeyPair("id_rsa_prev");
387
- hasKeyPair = true;
829
+ await aws.elasticbeanstalk.createElasticBeanstalkApplication(
830
+ repo,
831
+ tags,
832
+ );
388
833
  } catch {
389
834
  /* empty */
390
835
  }
391
836
 
392
- // LetsEncrypt CN records are limited to 64 chars so use only the hash from the generated name
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)
393
935
  const CNAMEPrefix = `fw-auto-${name.split("-")[name.split("-").length - 1]}`;
394
936
  process.env.DOMAIN_LINK = `${CNAMEPrefix}.${process.env.AWS_REGION}.elasticbeanstalk.com`;
395
937
 
396
- // Build secret payloads and create/reuse secrets
938
+ // Provision mail credentials if needed
939
+ let mailCreds = {};
940
+ if (mail && !existingSecrets.mail) {
941
+ mailCreds = await provisionMail(aws, name, tags, mailData);
942
+ }
943
+
397
944
  const payloads = eb.buildSecretPayloads(
398
- { framework, availability, platform, language, database, storage },
399
- { ...s3Data, ...rdsData },
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
+ },
400
969
  );
401
970
 
402
971
  const secretArns = {};
403
972
 
404
- for (const group of ["app", "storage", "database"]) {
973
+ for (const group of secretGroups) {
405
974
  if (existingSecrets[group]) {
406
975
  secretArns[`${group}SecretArn`] = existingSecrets[group];
976
+
977
+ if (Object.keys(payloads[group]).length) {
978
+ const currentValue = await aws.secretsmanager.getSecretValue(
979
+ `${name}-${group}`,
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
+ }
407
996
  } else if (Object.keys(payloads[group]).length) {
408
- const res = await module.exports.secretsmanager.createSecret(
997
+ const res = await aws.secretsmanager.createSecret(
409
998
  `${name}-${group}`,
410
999
  payloads[group],
411
1000
  tags,
@@ -414,20 +1003,18 @@ module.exports.fullstack = async (
414
1003
  }
415
1004
  }
416
1005
 
417
- // Auto-generate environmentsecrets entries from secret keys + ARNs
418
1006
  const secretSettings = [];
419
1007
 
420
- for (const group of ["app", "storage", "database"]) {
1008
+ for (const group of secretGroups) {
421
1009
  const arn = secretArns[`${group}SecretArn`];
422
1010
  if (!arn) continue;
423
1011
 
424
1012
  let keys;
425
1013
 
426
1014
  if (existingSecrets[group]) {
427
- const secretValue =
428
- await module.exports.secretsmanager.getSecretValue(
429
- `${name}-${group}`,
430
- );
1015
+ const secretValue = await aws.secretsmanager.getSecretValue(
1016
+ `${name}-${group}`,
1017
+ );
431
1018
  keys = Object.keys(secretValue);
432
1019
  } else {
433
1020
  keys = Object.keys(payloads[group]);
@@ -443,115 +1030,470 @@ module.exports.fullstack = async (
443
1030
  }
444
1031
  }
445
1032
 
446
- const OptionSettings = eb.merge(
447
- "environments",
448
- {
449
- framework,
450
- availability,
451
- platform,
452
- language,
453
- database,
454
- storage,
455
- },
456
- { ...s3Data, ...rdsData },
457
- );
1033
+ // Phase 5: Create or update EB environment
1034
+ let environment;
458
1035
 
459
- OptionSettings.push(...secretSettings);
1036
+ if (existingEnvironment) {
1037
+ const SECRETS_NAMESPACE =
1038
+ "aws:elasticbeanstalk:application:environmentsecrets";
1039
+ const currentSettings =
1040
+ await aws.elasticbeanstalk.describeConfigurationSettings(
1041
+ repo,
1042
+ name,
1043
+ );
1044
+ const currentSecrets = currentSettings.filter(
1045
+ (s) => s.Namespace === SECRETS_NAMESPACE,
1046
+ );
1047
+
1048
+ const currentKeys = new Set(currentSecrets.map((s) => s.OptionName));
1049
+ const expectedKeys = new Set(secretSettings.map((s) => s.OptionName));
460
1050
 
461
- if (hasKeyPair) {
462
- OptionSettings.push({
463
- OptionName: "EC2KeyName",
464
- Value: "id_rsa_prev",
465
- Namespace: "aws:autoscaling:launchconfiguration",
1051
+ const toAdd = secretSettings.filter(
1052
+ (s) => !currentKeys.has(s.OptionName),
1053
+ );
1054
+ const toUpdate = secretSettings.filter((s) => {
1055
+ const current = currentSecrets.find(
1056
+ (c) => c.OptionName === s.OptionName,
1057
+ );
1058
+ return current && current.Value !== s.Value;
466
1059
  });
467
- }
1060
+ const toRemove = currentSecrets
1061
+ .filter((s) => !expectedKeys.has(s.OptionName))
1062
+ .map((s) => ({
1063
+ OptionName: s.OptionName,
1064
+ Namespace: SECRETS_NAMESPACE,
1065
+ }));
1066
+
1067
+ const optionSettings = [...toAdd, ...toUpdate];
1068
+
1069
+ if (optionSettings.length || toRemove.length) {
1070
+ await aws.elasticbeanstalk.updateEnvironmentSettings(
1071
+ name,
1072
+ optionSettings,
1073
+ toRemove,
1074
+ );
1075
+ }
468
1076
 
469
- const environment =
470
- await module.exports.elasticbeanstalk.createElasticBeanstalkEnvironment(
471
- name,
472
- { framework, availability, platform, language },
473
- repo,
474
- OptionSettings,
475
- CNAMEPrefix,
476
- tags,
1077
+ environment = existingEnvironment;
1078
+ } else {
1079
+ const OptionSettings = eb.merge(
1080
+ "environments",
1081
+ {
1082
+ framework,
1083
+ platform,
1084
+ language,
1085
+ database,
1086
+ storage,
1087
+ opensearch,
1088
+ vpcId: vpcData.vpcId,
1089
+ privateSubnetIds: vpcData.privateSubnetIds.join(","),
1090
+ publicSubnetIds: vpcData.publicSubnetIds.join(","),
1091
+ elbScheme,
1092
+ instanceProfileName,
1093
+ instanceType,
1094
+ },
1095
+ {
1096
+ ...s3Data,
1097
+ ...rdsData,
1098
+ ...osData,
1099
+ ...cacheData,
1100
+ APP_NAME: appName,
1101
+ APP_DOMAIN: appDomain,
1102
+ SESSION_DOMAIN: appDomain
1103
+ ? appDomain.split(".").length > 2
1104
+ ? appDomain.split(".").slice(1).join(".")
1105
+ : appDomain
1106
+ : undefined,
1107
+ },
477
1108
  );
478
1109
 
479
- // Add RDS security group ingress rule from EB security group
480
- const rdsSlugForIngress = module.exports.slug(
1110
+ if (certArn) {
1111
+ OptionSettings.push(
1112
+ {
1113
+ OptionName: "ListenerEnabled",
1114
+ Value: "true",
1115
+ Namespace: "aws:elbv2:listener:443",
1116
+ },
1117
+ {
1118
+ OptionName: "Protocol",
1119
+ Value: "HTTPS",
1120
+ Namespace: "aws:elbv2:listener:443",
1121
+ },
1122
+ {
1123
+ OptionName: "SSLCertificateArns",
1124
+ Value: certArn,
1125
+ Namespace: "aws:elbv2:listener:443",
1126
+ },
1127
+ {
1128
+ OptionName: "SSLPolicy",
1129
+ Value: "ELBSecurityPolicy-FS-1-2-Res-2020-10",
1130
+ Namespace: "aws:elbv2:listener:443",
1131
+ },
1132
+ {
1133
+ OptionName: "DefaultProcess",
1134
+ Value: "default",
1135
+ Namespace: "aws:elbv2:listener:443",
1136
+ },
1137
+ );
1138
+ }
1139
+
1140
+ OptionSettings.push(...secretSettings);
1141
+
1142
+ environment =
1143
+ await aws.elasticbeanstalk.createElasticBeanstalkEnvironment(
1144
+ name,
1145
+ { framework, platform, language },
1146
+ repo,
1147
+ OptionSettings,
1148
+ CNAMEPrefix,
1149
+ tags,
1150
+ );
1151
+ }
1152
+
1153
+ // Phase 6: SG ingress rules in parallel
1154
+ const rdsSlugForIngress = aws.slug(
481
1155
  repo,
482
1156
  process.env.AWS_PROFILE,
483
1157
  branch,
484
1158
  "rds",
485
1159
  );
486
1160
 
487
- try {
488
- const rdsSg = await module.exports.ec2.findSecurityGroupByName(
489
- `${rdsSlugForIngress}-rds`,
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
+ ),
490
1180
  );
491
- const ebSg = await module.exports.ec2.findSecurityGroupByTag([
492
- {
493
- Name: "tag:elasticbeanstalk:environment-name",
494
- Values: [name],
495
- },
496
- {
497
- Name: "tag:aws:cloudformation:logical-id",
498
- Values: ["AWSEBSecurityGroup"],
499
- },
500
- ]);
501
- if (rdsSg && ebSg) {
502
- await module.exports.ec2.authorizeRDSIngress(
503
- rdsSg.GroupId,
504
- ebSg.GroupId,
505
- );
506
- }
507
- } catch {
508
- /* empty */
509
1181
  }
510
1182
 
1183
+ if (cacheSlug) {
1184
+ ingressPromises.push(
1185
+ authorizeIngress(
1186
+ aws,
1187
+ name,
1188
+ cacheSlug,
1189
+ "cache",
1190
+ aws.ec2.authorizeCacheIngress,
1191
+ ),
1192
+ );
1193
+ }
1194
+
1195
+ await Promise.all(ingressPromises);
1196
+
1197
+ // Phase 7: Copy configs and return
511
1198
  const configurations = eb.merge("configurations", {
512
1199
  framework,
513
- availability,
514
1200
  platform,
515
1201
  language,
516
1202
  });
517
1203
 
518
- if (credentials.length) {
519
- configurations.push(
520
- `.platform/${platform}/conf.d/elasticbeanstalk/${platform === "httpd" ? `z-${framework}-` : ""}htpasswd.conf`,
521
- );
522
- fs.outputFileSync(
523
- `.platform/${platform}/conf.d/elasticbeanstalk/.htpasswd-${branch}`,
524
- credentials
525
- .map((d) => `${d.username}:${md5(d.password)}`)
526
- .join("\n"),
1204
+ await aws.elasticbeanstalk.copyElasticBeanstalkConfigs(
1205
+ configurations,
1206
+ branch,
1207
+ );
1208
+
1209
+ let config = {
1210
+ url: `https://${environment.CNAME}`,
1211
+ environment: environment.EnvironmentName,
1212
+ paths: [`_IaC/${branch}/*`],
1213
+ copy: [
1214
+ {
1215
+ src: `_IaC/${branch}/.elasticbeanstalk/config.yml`,
1216
+ dest: `.elasticbeanstalk/config.yml`,
1217
+ },
1218
+ ],
1219
+ };
1220
+
1221
+ return config;
1222
+ };
1223
+
1224
+ module.exports.fullstackClone = async (
1225
+ name,
1226
+ tags,
1227
+ repo,
1228
+ branch,
1229
+ applicationName,
1230
+ sourceEnvironmentName,
1231
+ sourceVersionLabel,
1232
+ sourceBranch,
1233
+ ) => {
1234
+ const aws = module.exports;
1235
+
1236
+ // Phase 1: Read source configuration
1237
+ const sourceSettings =
1238
+ await aws.elasticbeanstalk.describeConfigurationSettings(
1239
+ applicationName,
1240
+ sourceEnvironmentName,
527
1241
  );
1242
+
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
+ );
1287
+
1288
+ const hasOpenSearch = sourceSecrets.some(
1289
+ (s) => s.OptionName === "SEARCH_HOST",
1290
+ );
1291
+
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";
528
1308
  }
529
1309
 
530
- await module.exports.elasticbeanstalk.copyElasticBeanstalkConfigs(
531
- configurations,
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(
1326
+ repo,
1327
+ process.env.AWS_PROFILE,
1328
+ sourceBranch,
1329
+ "rds",
1330
+ );
1331
+ const cacheSlug = aws.slug(
1332
+ repo,
1333
+ process.env.AWS_PROFILE,
1334
+ sourceBranch,
1335
+ "cache",
532
1336
  );
533
1337
 
534
- if (credentials.length) {
535
- configurations.push(
536
- `.platform/${platform}/conf.d/elasticbeanstalk/.htpasswd-${branch}`,
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
+ ),
537
1363
  );
538
1364
  }
539
1365
 
540
- let config = {
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,
1391
+ branch,
1392
+ );
1393
+
1394
+ return {
541
1395
  url: `https://${environment.CNAME}`,
542
1396
  environment: environment.EnvironmentName,
543
- paths: configurations.map((d) => {
544
- const obj = { src: d, dest: d };
545
- // Fix for core mistakenly mapping .htpasswd to directory
546
- if (d.includes(`.htpasswd-${branch}`)) {
547
- obj.file = true;
548
- obj.dest = d.split(`-${branch}`)[0];
549
- }
550
- return obj;
551
- }),
1397
+ paths: [`_IaC/${branch}/*`],
1398
+ copy: [
1399
+ {
1400
+ src: `_IaC/${branch}/.elasticbeanstalk/config.yml`,
1401
+ dest: `.elasticbeanstalk/config.yml`,
1402
+ },
1403
+ ],
552
1404
  };
1405
+ };
553
1406
 
554
- return config;
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,
1461
+ name,
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);
1478
+ } catch {
1479
+ /* empty */
1480
+ }
1481
+
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
+ ]);
555
1497
  };
556
1498
 
557
1499
  module.exports.fullstackTerminate = async (
@@ -561,128 +1503,324 @@ module.exports.fullstackTerminate = async (
561
1503
  {
562
1504
  deleteStorage = false,
563
1505
  deleteDatabase = false,
1506
+ deleteOpenSearch = false,
1507
+ deleteCache = false,
1508
+ deleteMail = false,
564
1509
  deleteAppSecret = false,
565
1510
  } = {},
566
1511
  ) => {
567
- if (deleteStorage) {
568
- const s3Slug = module.exports.slug(
569
- repo,
570
- process.env.AWS_PROFILE,
571
- branch,
572
- "s3",
573
- );
1512
+ const aws = module.exports;
574
1513
 
575
- try {
576
- await module.exports.iam.removeIAMUser(s3Slug);
577
- } catch {
578
- /* empty */
579
- }
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");
580
1517
 
581
- try {
582
- await module.exports.s3.emptyS3Bucket(s3Slug);
583
- } catch {
584
- /* empty */
585
- }
1518
+ // Phase 1: Storage cleanup + revoke all SG ingress rules in parallel
1519
+ const phase1 = [];
586
1520
 
587
- try {
588
- await module.exports.s3.removeS3Bucket(s3Slug);
589
- } catch {
590
- /* empty */
591
- }
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
+ })(),
1542
+ );
592
1543
  }
593
1544
 
594
- // Remove RDS inbound rule referencing EB security group before terminating EB
595
- const rdsSlug = module.exports.slug(
596
- repo,
597
- process.env.AWS_PROFILE,
598
- branch,
599
- "rds",
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
+ ),
600
1561
  );
601
1562
 
1563
+ await Promise.all(phase1);
1564
+
1565
+ // Phase 2: Terminate EB environment (must complete before SG deletion)
602
1566
  try {
603
- const rdsSg = await module.exports.ec2.findSecurityGroupByName(
604
- `${rdsSlug}-rds`,
605
- );
606
- const ebSg = await module.exports.ec2.findSecurityGroupByTag([
607
- {
608
- Name: "tag:elasticbeanstalk:environment-name",
609
- Values: [name],
610
- },
611
- {
612
- Name: "tag:aws:cloudformation:logical-id",
613
- Values: ["AWSEBSecurityGroup"],
614
- },
615
- ]);
616
- if (rdsSg && ebSg) {
617
- await module.exports.ec2.revokeRDSIngress(
618
- rdsSg.GroupId,
619
- ebSg.GroupId,
620
- );
621
- }
1567
+ await aws.elasticbeanstalk.removeElasticBeanstalkEnvironment(name);
622
1568
  } catch {
623
1569
  /* empty */
624
1570
  }
625
1571
 
626
- try {
627
- await module.exports.elasticbeanstalk.removeElasticBeanstalkEnvironment(
628
- name,
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
+
1592
+ if (deleteDatabase) {
1593
+ phase3.push(aws.rds.deleteRDSInstance(rdsSlug).catch(() => {}));
1594
+ }
1595
+
1596
+ if (deleteOpenSearch) {
1597
+ phase3.push(
1598
+ aws.opensearch.deleteOpenSearchDomain(osSlug).catch(() => {}),
629
1599
  );
630
- } catch {
631
- /* empty */
632
1600
  }
633
1601
 
634
- try {
635
- await module.exports.elasticbeanstalk.removeElasticBeanstalkApplication(
636
- repo,
1602
+ if (deleteCache) {
1603
+ phase3.push(
1604
+ aws.elasticache.deleteCacheCluster(cacheSlug).catch(() => {}),
637
1605
  );
638
- } catch {
639
- /* empty */
640
1606
  }
641
1607
 
1608
+ await Promise.all(phase3);
1609
+
1610
+ // Phase 4: Wait for service deletions with round-robin polling
1611
+ const deletionPolls = [];
1612
+
642
1613
  if (deleteDatabase) {
643
- try {
644
- await module.exports.rds.deleteRDSInstance(rdsSlug);
645
- await module.exports.rds.waitForRDSDeletion(rdsSlug);
646
- } catch {
647
- /* empty */
648
- }
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
+ });
1636
+ }
649
1637
 
650
- try {
651
- const sg = await module.exports.ec2.findSecurityGroupByName(
652
- `${rdsSlug}-rds`,
653
- );
654
- if (sg) {
655
- await module.exports.ec2.deleteSecurityGroup(sg.GroupId);
656
- }
657
- } catch {
658
- /* empty */
659
- }
1638
+ if (deleteOpenSearch) {
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
+ });
660
1658
  }
661
1659
 
662
- // Delete secrets
663
- if (deleteStorage) {
664
- try {
665
- await module.exports.secretsmanager.deleteSecret(`${name}-storage`);
666
- } catch {
667
- /* empty */
668
- }
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
+ });
1682
+ }
1683
+
1684
+ if (deletionPolls.length) {
1685
+ await pollAll(deletionPolls, "Waiting for services to terminate");
669
1686
  }
670
1687
 
1688
+ // Phase 5: Delete security groups after services are gone
1689
+ const sgDeletions = [];
1690
+
671
1691
  if (deleteDatabase) {
672
- try {
673
- await module.exports.secretsmanager.deleteSecret(
674
- `${name}-database`,
675
- );
676
- } catch {
677
- /* empty */
678
- }
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
+ );
679
1706
  }
680
1707
 
1708
+ if (deleteOpenSearch) {
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
+ );
1740
+ }
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
+ }
681
1771
  if (deleteAppSecret) {
682
- try {
683
- await module.exports.secretsmanager.deleteSecret(`${name}-app`);
684
- } catch {
685
- /* empty */
686
- }
1772
+ secretDeletions.push(
1773
+ aws.secretsmanager.deleteSecret(`${name}-app`).catch(() => {}),
1774
+ );
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
+ );
687
1823
  }
1824
+
1825
+ await Promise.all(secretDeletions);
688
1826
  };