@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.
Files changed (36) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/_Test/provision.js +1 -8
  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 +29 -0
  8. package/commands/create/cmds/key.js +60 -41
  9. package/commands/create/cmds/provision.js +425 -55
  10. package/commands/create/cmds/rekey.js +218 -0
  11. package/commands/create/libs/output-credentials.js +59 -0
  12. package/commands/create/libs/parallel-runner.js +204 -0
  13. package/commands/create/libs/resolve-operator.js +59 -0
  14. package/commands/create/libs/utilities.js +71 -8
  15. package/commands/create/libs/vars.js +70 -86
  16. package/commands/create/services/aws/acm.js +4 -2
  17. package/commands/create/services/aws/cloudfront.js +51 -45
  18. package/commands/create/services/aws/ec2.js +132 -53
  19. package/commands/create/services/aws/elasticache.js +159 -0
  20. package/commands/create/services/aws/elasticbeanstalk.js +141 -60
  21. package/commands/create/services/aws/iam.js +159 -93
  22. package/commands/create/services/aws/index.js +1209 -466
  23. package/commands/create/services/aws/opensearch.js +25 -17
  24. package/commands/create/services/aws/rds.js +22 -34
  25. package/commands/create/services/aws/s3.js +14 -27
  26. package/commands/create/services/aws/secretsmanager.js +8 -15
  27. package/commands/create/services/aws/ses.js +195 -0
  28. package/commands/create/templates/elasticbeanstalk/.ebextensions/laravel/cron.config +45 -0
  29. package/commands/create/templates/elasticbeanstalk/.ebextensions/laravel/queue-worker.config +29 -0
  30. package/commands/create/templates/elasticbeanstalk/.ebextensions/laravel/software.config +12 -0
  31. package/commands/create/templates/elasticbeanstalk/.ebextensions/misc/setvars.config +2 -16
  32. package/commands/create/templates/elasticbeanstalk/.platform/confighooks/postdeploy/rewrite-flush.sh +1 -1
  33. package/commands/create/templates/elasticbeanstalk/.platform/confighooks/postdeploy/setvars.sh +19 -0
  34. package/commands/create/templates/elasticbeanstalk/.platform/hooks/postdeploy/restart_queue.sh +4 -0
  35. package/commands/create/templates/elasticbeanstalk/.platform/hooks/prebuild/setvars.sh +19 -0
  36. 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
- { storage = false, database = false, opensearch = false } = {},
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
- secrets: { app: null, storage: null, database: null, opensearch: null },
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
- module.exports.fullstack = async (
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 (storage && !existingResources.storage) {
356
+ if (!existingResources.storage) {
349
357
  if (isLaravel) {
350
- await module.exports.s3.createS3Bucket(s3Slug, tags, {
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 module.exports.s3.setPublicReadPolicy(s3Slug, "public/*");
365
+ await aws.s3.setPublicReadPolicy(s3Slug, "public/*");
358
366
  } else {
359
- await module.exports.iam.createIAMUser(s3Slug, tags);
360
- await module.exports.iam.attachIAMPolicy(
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 module.exports.iam.createAccessKey(s3Slug);
375
+ } = await aws.iam.createAccessKey(s3Slug);
368
376
 
369
- await module.exports.s3.createS3Bucket(
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 (storage && !s3Data.s3Slug) {
388
+ if (!s3Data.s3Slug) {
381
389
  s3Data = { s3Slug };
382
390
  }
383
391
 
384
- let instanceProfileName;
385
- let opensearchDomainArn;
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
- if (database && !existingResources.database) {
409
- const rdsSlug = module.exports.slug(
410
- repo,
411
- process.env.AWS_PROFILE,
412
- branch,
413
- "rds",
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 module.exports.rds.ensureSSLParameterGroup();
415
+ await aws.rds.ensureSSLParameterGroup();
422
416
 
423
- const rdsSecurityGroupId =
424
- await module.exports.ec2.createRDSSecurityGroup(rdsSlug, tags);
417
+ const rdsSecurityGroupId = await aws.ec2.createRDSSecurityGroup(
418
+ rdsSlug,
419
+ tags,
420
+ );
425
421
 
426
- const dbSubnetGroupName = await module.exports.rds.ensureDBSubnetGroup(
422
+ const dbSubnetGroupName = await aws.rds.ensureDBSubnetGroup(
427
423
  vpcData.privateSubnetIds,
428
424
  );
429
425
 
430
426
  if (snapshot) {
431
- await module.exports.rds.restoreRDSInstanceFromSnapshot(
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 module.exports.rds.waitForRDSInstance(rdsSlug);
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 module.exports.rds.createRDSInstance(
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
- const rdsInstance =
453
- await module.exports.rds.waitForRDSInstance(rdsSlug);
454
-
455
- rdsData = {
456
- RDS_HOSTNAME: rdsInstance.Endpoint.Address,
457
- RDS_PORT: String(rdsInstance.Endpoint.Port),
458
- RDS_PASSWORD: dbPassword,
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 module.exports.rds.modifyRDSPassword(rdsSlug, dbPassword);
479
- const rdsInstance =
480
- await module.exports.rds.waitForRDSInstance(rdsSlug);
481
-
482
- rdsData = {
483
- RDS_HOSTNAME: rdsInstance.Endpoint.Address,
484
- RDS_PORT: String(rdsInstance.Endpoint.Port),
485
- RDS_PASSWORD: dbPassword,
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 if (
488
- database &&
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
- rdsData = {
497
- RDS_HOSTNAME: secretValue.RDS_HOSTNAME,
498
- RDS_PORT: secretValue.RDS_PORT,
499
- RDS_PASSWORD: secretValue.RDS_PASSWORD,
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
- // Create decoupled OpenSearch domain if requested
504
- let osData = {};
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 (opensearch && !existingResources.opensearch) {
507
- await module.exports.iam.ensureOpenSearchServiceRole();
514
+ if (!existingResources.opensearch) {
515
+ await aws.iam.ensureOpenSearchServiceRole();
508
516
 
509
517
  const roleArn = instanceProfileName
510
- ? (await module.exports.iam.getRole(instanceProfileName)).Role.Arn
518
+ ? (await aws.iam.getRole(instanceProfileName)).Role.Arn
511
519
  : undefined;
512
520
 
513
- const osSecurityGroupId =
514
- await module.exports.ec2.createOpenSearchSecurityGroup(
515
- osSlug,
516
- tags,
517
- );
521
+ const osSecurityGroupId = await aws.ec2.createOpenSearchSecurityGroup(
522
+ osSlug,
523
+ tags,
524
+ );
518
525
 
519
- await module.exports.opensearch.createOpenSearchDomain(
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
- const osDomain =
527
- await module.exports.opensearch.waitForOpenSearchDomain(osSlug);
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
- SEARCH_HOST: `https://${osEndpoint}`,
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 (opensearch && existingResources.opensearch) {
536
- // Existing domain describe to populate osData for secret payloads
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 osEndpoint =
541
- osDomain.Endpoint || (osDomain.Endpoints && osDomain.Endpoints.vpc);
614
+ const cacheEndpoint = cluster.NodeGroups[0].PrimaryEndpoint;
542
615
 
543
- osData = {
544
- SEARCH_HOST: `https://${osEndpoint}`,
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 module.exports.elasticbeanstalk.createElasticBeanstalkApplication(
550
- 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
+ },
551
677
  );
552
678
  } catch {
553
- /* empty */
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
- let hasKeyPair = false;
689
+ // Add user to SES sending group
690
+ await aws.iam.addUserToGroup(userName, groupName, iamClient);
557
691
 
558
- try {
559
- await module.exports.ec2.getKeyPair("id_rsa_prev");
560
- hasKeyPair = true;
561
- } catch {
562
- /* empty */
563
- }
692
+ // Create access key
693
+ const existingKeys = await iamClient.send(
694
+ new ListAccessKeysCommand({ UserName: userName }),
695
+ );
564
696
 
565
- // LetsEncrypt CN records are limited to 64 chars so use only the hash from the generated name
566
- const CNAMEPrefix = `fw-auto-${name.split("-")[name.split("-").length - 1]}`;
567
- process.env.DOMAIN_LINK = `${CNAMEPrefix}.${process.env.AWS_REGION}.elasticbeanstalk.com`;
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
- // Build secret payloads and create/reuse secrets
570
- const payloads = eb.buildSecretPayloads(
571
- {
572
- framework,
573
- availability,
574
- platform,
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
- const secretArns = {};
590
-
591
- for (const group of secretGroups) {
592
- if (existingSecrets[group]) {
593
- secretArns[`${group}SecretArn`] = existingSecrets[group];
716
+ // Derive SMTP credentials
717
+ const smtpPassword = aws.ses.deriveSmtpPassword(
718
+ keyRes.AccessKey.SecretAccessKey,
719
+ sesRegion,
720
+ );
594
721
 
595
- // Merge new keys into existing secret if payload has additions
596
- if (Object.keys(payloads[group]).length) {
597
- const currentValue =
598
- await module.exports.secretsmanager.getSecretValue(
599
- `${name}-${group}`,
600
- );
601
- const newKeys = Object.keys(payloads[group]).filter(
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
- if (newKeys.length) {
606
- const merged = { ...currentValue };
607
- for (const k of newKeys) {
608
- merged[k] = payloads[group][k];
609
- }
610
- await module.exports.secretsmanager.updateSecretValue(
611
- `${name}-${group}`,
612
- merged,
613
- );
614
- }
615
- }
616
- } else if (Object.keys(payloads[group]).length) {
617
- const res = await module.exports.secretsmanager.createSecret(
618
- `${name}-${group}`,
619
- payloads[group],
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
- // Auto-generate environmentsecrets entries from secret keys + ARNs
627
- const secretSettings = [];
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
- for (const group of secretGroups) {
630
- const arn = secretArns[`${group}SecretArn`];
631
- if (!arn) continue;
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
- let keys;
795
+ const osSlug =
796
+ opensearch || existingSecrets.opensearch
797
+ ? aws.slug(repo, process.env.AWS_PROFILE, branch, "os")
798
+ : undefined;
634
799
 
635
- if (existingSecrets[group]) {
636
- const secretValue =
637
- await module.exports.secretsmanager.getSecretValue(
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 module.exports.elasticbeanstalk.describeConfigurationSettings(
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 module.exports.elasticbeanstalk.updateEnvironmentSettings(
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 module.exports.elasticbeanstalk.createElasticBeanstalkEnvironment(
1143
+ await aws.elasticbeanstalk.createElasticBeanstalkEnvironment(
769
1144
  name,
770
- { framework, availability, platform, language },
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
- // Add RDS security group ingress rule from EB security group
779
- const rdsSlugForIngress = module.exports.slug(
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
- try {
787
- const rdsSg = await module.exports.ec2.findSecurityGroupByName(
788
- `${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
+ ),
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
- // Add OpenSearch security group ingress rule from EB security group
811
- if (osSlug) {
812
- try {
813
- const osSg = await module.exports.ec2.findSecurityGroupByName(
814
- `${osSlug}-os`,
815
- );
816
- const ebSgForOs = await module.exports.ec2.findSecurityGroupByTag([
817
- {
818
- Name: "tag:elasticbeanstalk:environment-name",
819
- Values: [name],
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
- if (credentials.length) {
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: configurations.map((d) => {
870
- const obj = { src: d, dest: d };
871
- // Fix for core mistakenly mapping .htpasswd to directory
872
- if (d.includes(`.htpasswd-${branch}`)) {
873
- obj.file = true;
874
- obj.dest = d.split(`-${branch}`)[0];
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.fullstackTerminate = async (
1224
+ module.exports.fullstackClone = async (
884
1225
  name,
1226
+ tags,
885
1227
  repo,
886
1228
  branch,
887
- {
888
- deleteStorage = false,
889
- deleteDatabase = false,
890
- deleteOpenSearch = false,
891
- deleteAppSecret = false,
892
- } = {},
1229
+ applicationName,
1230
+ sourceEnvironmentName,
1231
+ sourceVersionLabel,
1232
+ sourceBranch,
893
1233
  ) => {
894
- if (deleteStorage) {
895
- const s3Slug = module.exports.slug(
896
- repo,
897
- process.env.AWS_PROFILE,
898
- branch,
899
- "s3",
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
- try {
903
- await module.exports.iam.removeIAMUser(s3Slug);
904
- } catch {
905
- /* empty */
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
- try {
909
- await module.exports.s3.emptyS3Bucket(s3Slug);
910
- } catch {
911
- /* empty */
912
- }
1288
+ const hasOpenSearch = sourceSecrets.some(
1289
+ (s) => s.OptionName === "SEARCH_HOST",
1290
+ );
913
1291
 
914
- try {
915
- await module.exports.s3.removeS3Bucket(s3Slug);
916
- } catch {
917
- /* empty */
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
- // Remove RDS inbound rule referencing EB security group before terminating EB
922
- const rdsSlug = module.exports.slug(
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
- branch,
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
- try {
930
- const rdsSg = await module.exports.ec2.findSecurityGroupByName(
931
- `${rdsSlug}-rds`,
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
- // Remove OpenSearch inbound rule referencing EB security group before terminating EB
954
- const osSlug = module.exports.slug(
955
- repo,
956
- process.env.AWS_PROFILE,
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
- try {
962
- const osSg = await module.exports.ec2.findSecurityGroupByName(
963
- `${osSlug}-os`,
964
- );
965
- const ebSgForOs = await module.exports.ec2.findSecurityGroupByTag([
1394
+ return {
1395
+ url: `https://${environment.CNAME}`,
1396
+ environment: environment.EnvironmentName,
1397
+ paths: [`_IaC/${branch}/*`],
1398
+ copy: [
966
1399
  {
967
- Name: "tag:elasticbeanstalk:environment-name",
968
- Values: [name],
1400
+ src: `_IaC/${branch}/.elasticbeanstalk/config.yml`,
1401
+ dest: `.elasticbeanstalk/config.yml`,
969
1402
  },
970
- {
971
- Name: "tag:aws:cloudformation:logical-id",
972
- Values: ["AWSEBSecurityGroup"],
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
- try {
986
- await module.exports.elasticbeanstalk.removeElasticBeanstalkEnvironment(
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
- await module.exports.elasticbeanstalk.removeElasticBeanstalkApplication(
995
- repo,
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 module.exports.iam.removeEnvironmentInstanceProfile(name);
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
- try {
1009
- await module.exports.rds.deleteRDSInstance(rdsSlug);
1010
- await module.exports.rds.waitForRDSDeletion(rdsSlug);
1011
- } catch {
1012
- /* empty */
1013
- }
1593
+ phase3.push(aws.rds.deleteRDSInstance(rdsSlug).catch(() => {}));
1594
+ }
1014
1595
 
1015
- try {
1016
- const sg = await module.exports.ec2.findSecurityGroupByName(
1017
- `${rdsSlug}-rds`,
1018
- );
1019
- if (sg) {
1020
- await module.exports.ec2.deleteSecurityGroup(sg.GroupId);
1021
- }
1022
- } catch {
1023
- /* empty */
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
- try {
1029
- await module.exports.opensearch.deleteOpenSearchDomain(osSlug);
1030
- await module.exports.opensearch.waitForOpenSearchDeletion(osSlug);
1031
- } catch {
1032
- /* empty */
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
- try {
1036
- const sg = await module.exports.ec2.findSecurityGroupByName(
1037
- `${osSlug}-os`,
1038
- );
1039
- if (sg) {
1040
- await module.exports.ec2.deleteSecurityGroup(sg.GroupId);
1041
- }
1042
- } catch {
1043
- /* empty */
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
- // Delete secrets
1048
- if (deleteStorage) {
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
- try {
1058
- await module.exports.secretsmanager.deleteSecret(
1059
- `${name}-database`,
1060
- );
1061
- } catch {
1062
- /* empty */
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
- try {
1068
- await module.exports.secretsmanager.deleteSecret(
1069
- `${name}-opensearch`,
1070
- );
1071
- } catch {
1072
- /* empty */
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
- try {
1078
- await module.exports.secretsmanager.deleteSecret(`${name}-app`);
1079
- } catch {
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
  };