@fishawack/lab-env 5.7.0-beta.2 → 5.7.0-beta.4

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 +51 -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 +178 -98
  22. package/commands/create/services/aws/index.js +1202 -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/commands/scan.js +1 -1
  37. 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,574 @@ 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
+ tags,
818
+ );
819
+ } else {
820
+ await aws.iam.ensureEBInstanceProfileExists();
821
+ }
822
+
823
+ // Phase 2: VPC + EB app in parallel
824
+ const [vpcData] = await Promise.all([
825
+ aws.ec2.ensurePrivateSubnets(),
826
+ aws.iam.ensureEBManagedUpdateProfileExists(),
827
+ ]);
828
+
829
+ try {
830
+ await aws.elasticbeanstalk.createElasticBeanstalkApplication(
831
+ repo,
832
+ tags,
833
+ );
834
+ } catch {
835
+ /* empty */
836
+ }
837
+
838
+ // Phase 3: Provision services in parallel
839
+ const servicePromises = [];
840
+
841
+ if (storage) {
842
+ servicePromises.push(
843
+ provisionStorage(aws, s3Slug, tags, isLaravel, existingResources),
844
+ );
845
+ } else {
846
+ servicePromises.push(Promise.resolve(s3Slug ? { s3Slug } : {}));
847
+ }
848
+
849
+ if (database) {
850
+ servicePromises.push(
851
+ provisionRDS(
852
+ aws,
853
+ name,
854
+ repo,
855
+ branch,
856
+ tags,
857
+ vpcData,
858
+ existingResources,
859
+ existingSecrets,
860
+ snapshot,
861
+ ),
862
+ );
863
+ } else {
864
+ servicePromises.push(Promise.resolve({ rdsData: {}, rdsSlug: null }));
865
+ }
866
+
867
+ if (opensearch) {
868
+ servicePromises.push(
869
+ provisionOpenSearch(
870
+ aws,
871
+ osSlug,
872
+ tags,
873
+ vpcData,
874
+ instanceProfileName,
875
+ existingResources,
876
+ ),
877
+ );
878
+ } else {
879
+ servicePromises.push(Promise.resolve({ osData: {} }));
880
+ }
881
+
882
+ if (cache) {
883
+ servicePromises.push(
884
+ provisionCache(
885
+ aws,
886
+ name,
887
+ cacheSlug,
888
+ tags,
889
+ vpcData,
890
+ existingResources,
891
+ existingSecrets,
892
+ ),
893
+ );
894
+ } else {
895
+ servicePromises.push(Promise.resolve({ cacheData: {} }));
896
+ }
897
+
898
+ const [s3Data, rdsResult, osResult, cacheResult] =
899
+ await Promise.all(servicePromises);
900
+
901
+ // Phase 3b: Wait for all pending services with single spinner
902
+ const pendingPolls = [rdsResult, osResult, cacheResult]
903
+ .filter((r) => r && r.poll)
904
+ .map((r) => r.poll);
905
+
906
+ const pollResults = pendingPolls.length ? await pollAll(pendingPolls) : [];
907
+
908
+ // Extract endpoint data from poll results
909
+ let rdsData = rdsResult.rdsData || {};
910
+
911
+ if (rdsResult.poll) {
912
+ const rdsInstance = pollResults[pendingPolls.indexOf(rdsResult.poll)];
913
+ rdsData.RDS_HOSTNAME = rdsInstance.Endpoint.Address;
914
+ rdsData.RDS_PORT = String(rdsInstance.Endpoint.Port);
915
+ }
916
+
917
+ let osData = osResult.osData || {};
918
+
919
+ if (osResult.poll) {
920
+ const osDomain = pollResults[pendingPolls.indexOf(osResult.poll)];
921
+ const osEndpoint =
922
+ osDomain.Endpoint || (osDomain.Endpoints && osDomain.Endpoints.vpc);
923
+ osData.SEARCH_HOST = `https://${osEndpoint}`;
924
+ }
925
+
926
+ let cacheData = cacheResult.cacheData || {};
927
+
928
+ if (cacheResult.poll) {
929
+ const cluster = pollResults[pendingPolls.indexOf(cacheResult.poll)];
930
+ const cacheEndpoint = cluster.NodeGroups[0].PrimaryEndpoint;
931
+ cacheData.REDIS_HOST = cacheEndpoint.Address;
932
+ cacheData.REDIS_PORT = String(cacheEndpoint.Port);
933
+ }
934
+
935
+ // Phase 4: Build secrets and EB environment (sequential)
936
+ const CNAMEPrefix = `fw-auto-${name.split("-")[name.split("-").length - 1]}`;
937
+ process.env.DOMAIN_LINK = `${CNAMEPrefix}.${process.env.AWS_REGION}.elasticbeanstalk.com`;
938
+
939
+ // Provision mail credentials if needed
940
+ let mailCreds = {};
941
+ if (mail && !existingSecrets.mail) {
942
+ mailCreds = await provisionMail(aws, name, tags, mailData);
943
+ }
944
+
945
+ const payloads = eb.buildSecretPayloads(
946
+ {
947
+ framework,
948
+ platform,
949
+ language,
950
+ database,
951
+ storage,
952
+ opensearch,
953
+ cache,
954
+ mail: mail && !existingSecrets.mail,
955
+ },
956
+ {
957
+ ...s3Data,
958
+ ...rdsData,
959
+ ...osData,
960
+ ...cacheData,
961
+ ...mailCreds,
962
+ APP_NAME: appName,
963
+ APP_DOMAIN: appDomain,
964
+ SESSION_DOMAIN: appDomain || undefined,
965
+ },
966
+ );
967
+
968
+ const secretArns = {};
969
+
970
+ for (const group of secretGroups) {
971
+ if (existingSecrets[group]) {
972
+ secretArns[`${group}SecretArn`] = existingSecrets[group];
973
+
974
+ if (Object.keys(payloads[group]).length) {
975
+ const currentValue = await aws.secretsmanager.getSecretValue(
638
976
  `${name}-${group}`,
639
977
  );
978
+ const newKeys = Object.keys(payloads[group]).filter(
979
+ (k) => !(k in currentValue),
980
+ );
981
+
982
+ if (newKeys.length) {
983
+ const merged = { ...currentValue };
984
+ for (const k of newKeys) {
985
+ merged[k] = payloads[group][k];
986
+ }
987
+ await aws.secretsmanager.updateSecretValue(
988
+ `${name}-${group}`,
989
+ merged,
990
+ );
991
+ }
992
+ }
993
+ } else if (Object.keys(payloads[group]).length) {
994
+ const res = await aws.secretsmanager.createSecret(
995
+ `${name}-${group}`,
996
+ payloads[group],
997
+ tags,
998
+ );
999
+ secretArns[`${group}SecretArn`] = res.ARN;
1000
+ }
1001
+ }
1002
+
1003
+ const secretSettings = [];
1004
+
1005
+ for (const group of secretGroups) {
1006
+ const arn = secretArns[`${group}SecretArn`];
1007
+ if (!arn) continue;
1008
+
1009
+ let keys;
1010
+
1011
+ if (existingSecrets[group]) {
1012
+ const secretValue = await aws.secretsmanager.getSecretValue(
1013
+ `${name}-${group}`,
1014
+ );
640
1015
  keys = Object.keys(secretValue);
641
1016
  } else {
642
1017
  keys = Object.keys(payloads[group]);
@@ -652,14 +1027,14 @@ module.exports.fullstack = async (
652
1027
  }
653
1028
  }
654
1029
 
1030
+ // Phase 5: Create or update EB environment
655
1031
  let environment;
656
1032
 
657
1033
  if (existingEnvironment) {
658
- // Re-provision: sync secrets to existing EB environment
659
1034
  const SECRETS_NAMESPACE =
660
1035
  "aws:elasticbeanstalk:application:environmentsecrets";
661
1036
  const currentSettings =
662
- await module.exports.elasticbeanstalk.describeConfigurationSettings(
1037
+ await aws.elasticbeanstalk.describeConfigurationSettings(
663
1038
  repo,
664
1039
  name,
665
1040
  );
@@ -689,7 +1064,7 @@ module.exports.fullstack = async (
689
1064
  const optionSettings = [...toAdd, ...toUpdate];
690
1065
 
691
1066
  if (optionSettings.length || toRemove.length) {
692
- await module.exports.elasticbeanstalk.updateEnvironmentSettings(
1067
+ await aws.elasticbeanstalk.updateEnvironmentSettings(
693
1068
  name,
694
1069
  optionSettings,
695
1070
  toRemove,
@@ -702,7 +1077,6 @@ module.exports.fullstack = async (
702
1077
  "environments",
703
1078
  {
704
1079
  framework,
705
- availability,
706
1080
  platform,
707
1081
  language,
708
1082
  database,
@@ -713,17 +1087,19 @@ module.exports.fullstack = async (
713
1087
  publicSubnetIds: vpcData.publicSubnetIds.join(","),
714
1088
  elbScheme,
715
1089
  instanceProfileName,
1090
+ instanceType,
716
1091
  },
717
1092
  {
718
1093
  ...s3Data,
719
1094
  ...rdsData,
720
1095
  ...osData,
1096
+ ...cacheData,
721
1097
  APP_NAME: appName,
722
1098
  APP_DOMAIN: appDomain,
1099
+ SESSION_DOMAIN: appDomain || undefined,
723
1100
  },
724
1101
  );
725
1102
 
726
- // HTTPS listener via ACM cert
727
1103
  if (certArn) {
728
1104
  OptionSettings.push(
729
1105
  {
@@ -756,18 +1132,10 @@ module.exports.fullstack = async (
756
1132
 
757
1133
  OptionSettings.push(...secretSettings);
758
1134
 
759
- if (hasKeyPair) {
760
- OptionSettings.push({
761
- OptionName: "EC2KeyName",
762
- Value: "id_rsa_prev",
763
- Namespace: "aws:autoscaling:launchconfiguration",
764
- });
765
- }
766
-
767
1135
  environment =
768
- await module.exports.elasticbeanstalk.createElasticBeanstalkEnvironment(
1136
+ await aws.elasticbeanstalk.createElasticBeanstalkEnvironment(
769
1137
  name,
770
- { framework, availability, platform, language },
1138
+ { framework, platform, language },
771
1139
  repo,
772
1140
  OptionSettings,
773
1141
  CNAMEPrefix,
@@ -775,309 +1143,677 @@ module.exports.fullstack = async (
775
1143
  );
776
1144
  }
777
1145
 
778
- // Add RDS security group ingress rule from EB security group
779
- const rdsSlugForIngress = module.exports.slug(
1146
+ // Phase 6: SG ingress rules in parallel
1147
+ const rdsSlugForIngress = aws.slug(
780
1148
  repo,
781
1149
  process.env.AWS_PROFILE,
782
1150
  branch,
783
1151
  "rds",
784
1152
  );
785
1153
 
786
- try {
787
- const rdsSg = await module.exports.ec2.findSecurityGroupByName(
788
- `${rdsSlugForIngress}-rds`,
1154
+ const ingressPromises = [
1155
+ authorizeIngress(
1156
+ aws,
1157
+ name,
1158
+ rdsSlugForIngress,
1159
+ "rds",
1160
+ aws.ec2.authorizeRDSIngress,
1161
+ ),
1162
+ ];
1163
+
1164
+ if (osSlug) {
1165
+ ingressPromises.push(
1166
+ authorizeIngress(
1167
+ aws,
1168
+ name,
1169
+ osSlug,
1170
+ "os",
1171
+ aws.ec2.authorizeOpenSearchIngress,
1172
+ ),
789
1173
  );
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
1174
  }
809
1175
 
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
- }
1176
+ if (cacheSlug) {
1177
+ ingressPromises.push(
1178
+ authorizeIngress(
1179
+ aws,
1180
+ name,
1181
+ cacheSlug,
1182
+ "cache",
1183
+ aws.ec2.authorizeCacheIngress,
1184
+ ),
1185
+ );
835
1186
  }
836
1187
 
1188
+ await Promise.all(ingressPromises);
1189
+
1190
+ // Phase 7: Copy configs and return
837
1191
  const configurations = eb.merge("configurations", {
838
1192
  framework,
839
- availability,
840
1193
  platform,
841
1194
  language,
842
1195
  });
843
1196
 
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(
1197
+ await aws.elasticbeanstalk.copyElasticBeanstalkConfigs(
857
1198
  configurations,
1199
+ branch,
858
1200
  );
859
1201
 
860
- if (credentials.length) {
861
- configurations.push(
862
- `.platform/${platform}/conf.d/elasticbeanstalk/.htpasswd-${branch}`,
863
- );
864
- }
865
-
866
1202
  let config = {
867
1203
  url: `https://${environment.CNAME}`,
868
1204
  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
- }),
1205
+ paths: [`_IaC/${branch}/*`],
1206
+ copy: [
1207
+ {
1208
+ src: `_IaC/${branch}/.elasticbeanstalk/config.yml`,
1209
+ dest: `.elasticbeanstalk/config.yml`,
1210
+ },
1211
+ ],
878
1212
  };
879
1213
 
880
1214
  return config;
881
1215
  };
882
1216
 
883
- module.exports.fullstackTerminate = async (
1217
+ module.exports.fullstackClone = async (
884
1218
  name,
1219
+ tags,
885
1220
  repo,
886
1221
  branch,
887
- {
888
- deleteStorage = false,
889
- deleteDatabase = false,
890
- deleteOpenSearch = false,
891
- deleteAppSecret = false,
892
- } = {},
1222
+ applicationName,
1223
+ sourceEnvironmentName,
1224
+ sourceVersionLabel,
1225
+ sourceBranch,
893
1226
  ) => {
894
- if (deleteStorage) {
895
- const s3Slug = module.exports.slug(
896
- repo,
897
- process.env.AWS_PROFILE,
898
- branch,
899
- "s3",
1227
+ const aws = module.exports;
1228
+
1229
+ // Phase 1: Read source configuration
1230
+ const sourceSettings =
1231
+ await aws.elasticbeanstalk.describeConfigurationSettings(
1232
+ applicationName,
1233
+ sourceEnvironmentName,
900
1234
  );
901
1235
 
902
- try {
903
- await module.exports.iam.removeIAMUser(s3Slug);
904
- } catch {
905
- /* empty */
906
- }
1236
+ // Phase 2: Filter settings — keep relevant namespaces, exclude immutable/auto-managed
1237
+ const excludedOptions = new Set([
1238
+ "aws:elasticbeanstalk:environment::EnvironmentName",
1239
+ ]);
1240
+
1241
+ const includedNamespaces = new Set([
1242
+ "aws:elasticbeanstalk:application:environment",
1243
+ "aws:elasticbeanstalk:application:environmentsecrets",
1244
+ "aws:autoscaling:launchconfiguration",
1245
+ "aws:autoscaling:asg",
1246
+ "aws:ec2:vpc",
1247
+ "aws:elbv2:listener:443",
1248
+ "aws:elbv2:listener:default",
1249
+ "aws:elasticbeanstalk:environment",
1250
+ "aws:elasticbeanstalk:environment:process:default",
1251
+ "aws:elasticbeanstalk:healthreporting:system",
1252
+ "aws:elasticbeanstalk:managedactions",
1253
+ "aws:elasticbeanstalk:managedactions:platformupdate",
1254
+ "aws:elasticbeanstalk:cloudwatch:logs",
1255
+ "aws:elasticbeanstalk:cloudwatch:logs:health",
1256
+ ]);
1257
+
1258
+ const OptionSettings = sourceSettings
1259
+ .filter((s) => {
1260
+ if (!includedNamespaces.has(s.Namespace)) return false;
1261
+ const key = `${s.Namespace}::${s.OptionName}`;
1262
+ if (excludedOptions.has(key)) return false;
1263
+ // Exclude empty values
1264
+ if (s.Value === undefined || s.Value === null || s.Value === "")
1265
+ return false;
1266
+ return true;
1267
+ })
1268
+ .map((s) => ({
1269
+ Namespace: s.Namespace,
1270
+ OptionName: s.OptionName,
1271
+ Value: s.Value,
1272
+ }));
1273
+
1274
+ // Detect which services exist from source secrets
1275
+ const sourceSecrets = sourceSettings.filter(
1276
+ (s) =>
1277
+ s.Namespace ===
1278
+ "aws:elasticbeanstalk:application:environmentsecrets",
1279
+ );
907
1280
 
908
- try {
909
- await module.exports.s3.emptyS3Bucket(s3Slug);
910
- } catch {
911
- /* empty */
912
- }
1281
+ const hasOpenSearch = sourceSecrets.some(
1282
+ (s) => s.OptionName === "SEARCH_HOST",
1283
+ );
913
1284
 
914
- try {
915
- await module.exports.s3.removeS3Bucket(s3Slug);
916
- } catch {
917
- /* empty */
918
- }
1285
+ // Set SECRET_MIRROR_ENV so setvars.sh reads secrets from the source env
1286
+ OptionSettings.push({
1287
+ Namespace: "aws:elasticbeanstalk:application:environment",
1288
+ OptionName: "SECRET_MIRROR_ENV",
1289
+ Value: sourceEnvironmentName,
1290
+ });
1291
+
1292
+ // Override HealthCheckGracePeriod for initial deploy window
1293
+ const healthGraceIdx = OptionSettings.findIndex(
1294
+ (s) =>
1295
+ s.Namespace ===
1296
+ "aws:elasticbeanstalk:environment:process:default" &&
1297
+ s.OptionName === "HealthCheckGracePeriod",
1298
+ );
1299
+ if (healthGraceIdx !== -1) {
1300
+ OptionSettings[healthGraceIdx].Value = "600";
919
1301
  }
920
1302
 
921
- // Remove RDS inbound rule referencing EB security group before terminating EB
922
- const rdsSlug = module.exports.slug(
1303
+ // Phase 4: Create the environment
1304
+ const CNAMEPrefix = `fw-auto-${name.split("-")[name.split("-").length - 1]}`;
1305
+
1306
+ const environment =
1307
+ await aws.elasticbeanstalk.createElasticBeanstalkEnvironment(
1308
+ name,
1309
+ { language: detectLanguageFromSettings(sourceSettings) },
1310
+ applicationName,
1311
+ OptionSettings,
1312
+ CNAMEPrefix,
1313
+ tags,
1314
+ sourceVersionLabel,
1315
+ );
1316
+
1317
+ // Phase 5: Authorize SG ingress on source service security groups
1318
+ const rdsSlug = aws.slug(
923
1319
  repo,
924
1320
  process.env.AWS_PROFILE,
925
- branch,
1321
+ sourceBranch,
926
1322
  "rds",
927
1323
  );
1324
+ const cacheSlug = aws.slug(
1325
+ repo,
1326
+ process.env.AWS_PROFILE,
1327
+ sourceBranch,
1328
+ "cache",
1329
+ );
928
1330
 
929
- try {
930
- const rdsSg = await module.exports.ec2.findSecurityGroupByName(
931
- `${rdsSlug}-rds`,
1331
+ const ingressPromises = [
1332
+ authorizeIngress(
1333
+ aws,
1334
+ name,
1335
+ rdsSlug,
1336
+ "rds",
1337
+ aws.ec2.authorizeRDSIngress,
1338
+ ),
1339
+ ];
1340
+
1341
+ if (hasOpenSearch) {
1342
+ const sourceOsSlug = aws.slug(
1343
+ repo,
1344
+ process.env.AWS_PROFILE,
1345
+ sourceBranch,
1346
+ "os",
1347
+ );
1348
+ ingressPromises.push(
1349
+ authorizeIngress(
1350
+ aws,
1351
+ name,
1352
+ sourceOsSlug,
1353
+ "os",
1354
+ aws.ec2.authorizeOpenSearchIngress,
1355
+ ),
932
1356
  );
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
1357
  }
952
1358
 
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,
1359
+ if (sourceSecrets.some((s) => s.OptionName === "REDIS_HOST")) {
1360
+ ingressPromises.push(
1361
+ authorizeIngress(
1362
+ aws,
1363
+ name,
1364
+ cacheSlug,
1365
+ "cache",
1366
+ aws.ec2.authorizeCacheIngress,
1367
+ ),
1368
+ );
1369
+ }
1370
+
1371
+ await Promise.all(ingressPromises);
1372
+
1373
+ // Phase 6: Copy configs
1374
+ const framework = detectFrameworkFromSettings(sourceSettings);
1375
+ const { platform, language } = eb.configurations[framework];
1376
+ const configurations = eb.merge("configurations", {
1377
+ framework,
1378
+ platform,
1379
+ language,
1380
+ });
1381
+
1382
+ await aws.elasticbeanstalk.copyElasticBeanstalkConfigs(
1383
+ configurations,
957
1384
  branch,
958
- "os",
959
1385
  );
960
1386
 
961
- try {
962
- const osSg = await module.exports.ec2.findSecurityGroupByName(
963
- `${osSlug}-os`,
964
- );
965
- const ebSgForOs = await module.exports.ec2.findSecurityGroupByTag([
1387
+ return {
1388
+ url: `https://${environment.CNAME}`,
1389
+ environment: environment.EnvironmentName,
1390
+ paths: [`_IaC/${branch}/*`],
1391
+ copy: [
966
1392
  {
967
- Name: "tag:elasticbeanstalk:environment-name",
968
- Values: [name],
1393
+ src: `_IaC/${branch}/.elasticbeanstalk/config.yml`,
1394
+ dest: `.elasticbeanstalk/config.yml`,
969
1395
  },
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
- }
1396
+ ],
1397
+ };
1398
+ };
984
1399
 
985
- try {
986
- await module.exports.elasticbeanstalk.removeElasticBeanstalkEnvironment(
1400
+ const detectLanguageFromSettings = (settings) => {
1401
+ const envVars = settings.filter(
1402
+ (s) =>
1403
+ s.Namespace === "aws:elasticbeanstalk:application:environment" ||
1404
+ s.Namespace ===
1405
+ "aws:elasticbeanstalk:application:environmentsecrets",
1406
+ );
1407
+ const keys = envVars.map((s) => s.OptionName);
1408
+ if (keys.includes("DJANGO_SETTINGS_MODULE")) return "python";
1409
+ if (keys.includes("NODE_ENV")) return "node";
1410
+ return "php";
1411
+ };
1412
+
1413
+ const detectFrameworkFromSettings = (settings) => {
1414
+ const envVars = settings.filter(
1415
+ (s) =>
1416
+ s.Namespace === "aws:elasticbeanstalk:application:environment" ||
1417
+ s.Namespace ===
1418
+ "aws:elasticbeanstalk:application:environmentsecrets",
1419
+ );
1420
+ const keys = envVars.map((s) => s.OptionName);
1421
+ if (keys.includes("DJANGO_SETTINGS_MODULE")) return "python";
1422
+ if (keys.includes("ADONIS_ACE_CWD")) return "adonis";
1423
+ if (keys.includes("APP_KEY")) return "laravel";
1424
+ return "laravel";
1425
+ };
1426
+
1427
+ module.exports.fullstackCloneTerminate = async (
1428
+ name,
1429
+ repo,
1430
+ branch,
1431
+ sourceBranch,
1432
+ ) => {
1433
+ const aws = module.exports;
1434
+
1435
+ const rdsSlug = aws.slug(
1436
+ repo,
1437
+ process.env.AWS_PROFILE,
1438
+ sourceBranch,
1439
+ "rds",
1440
+ );
1441
+ const osSlug = aws.slug(repo, process.env.AWS_PROFILE, sourceBranch, "os");
1442
+ const cacheSlug = aws.slug(
1443
+ repo,
1444
+ process.env.AWS_PROFILE,
1445
+ sourceBranch,
1446
+ "cache",
1447
+ );
1448
+
1449
+ // Phase 1: Revoke SG ingress rules
1450
+ await Promise.all([
1451
+ authorizeIngress(aws, name, rdsSlug, "rds", aws.ec2.revokeRDSIngress),
1452
+ authorizeIngress(
1453
+ aws,
987
1454
  name,
988
- );
1455
+ osSlug,
1456
+ "os",
1457
+ aws.ec2.revokeOpenSearchIngress,
1458
+ ),
1459
+ authorizeIngress(
1460
+ aws,
1461
+ name,
1462
+ cacheSlug,
1463
+ "cache",
1464
+ aws.ec2.revokeCacheIngress,
1465
+ ),
1466
+ ]);
1467
+
1468
+ // Phase 2: Terminate EB environment
1469
+ try {
1470
+ await aws.elasticbeanstalk.removeElasticBeanstalkEnvironment(name);
989
1471
  } catch {
990
1472
  /* empty */
991
1473
  }
992
1474
 
993
- try {
994
- await module.exports.elasticbeanstalk.removeElasticBeanstalkApplication(
995
- repo,
1475
+ // Phase 3: Terminate EB environment + try removing app if empty
1476
+ await Promise.all([
1477
+ (async () => {
1478
+ try {
1479
+ const envs = await aws.elasticbeanstalk.listEnvironments(repo);
1480
+ if (!envs.length) {
1481
+ await aws.elasticbeanstalk.removeElasticBeanstalkApplication(
1482
+ repo,
1483
+ );
1484
+ }
1485
+ } catch {
1486
+ /* empty */
1487
+ }
1488
+ })(),
1489
+ ]);
1490
+ };
1491
+
1492
+ module.exports.fullstackTerminate = async (
1493
+ name,
1494
+ repo,
1495
+ branch,
1496
+ {
1497
+ deleteStorage = false,
1498
+ deleteDatabase = false,
1499
+ deleteOpenSearch = false,
1500
+ deleteCache = false,
1501
+ deleteMail = false,
1502
+ deleteAppSecret = false,
1503
+ } = {},
1504
+ ) => {
1505
+ const aws = module.exports;
1506
+
1507
+ const rdsSlug = aws.slug(repo, process.env.AWS_PROFILE, branch, "rds");
1508
+ const osSlug = aws.slug(repo, process.env.AWS_PROFILE, branch, "os");
1509
+ const cacheSlug = aws.slug(repo, process.env.AWS_PROFILE, branch, "cache");
1510
+
1511
+ // Phase 1: Storage cleanup + revoke all SG ingress rules in parallel
1512
+ const phase1 = [];
1513
+
1514
+ if (deleteStorage) {
1515
+ const s3Slug = aws.slug(repo, process.env.AWS_PROFILE, branch, "s3");
1516
+
1517
+ phase1.push(
1518
+ (async () => {
1519
+ try {
1520
+ await aws.iam.removeIAMUser(s3Slug);
1521
+ } catch {
1522
+ /* empty */
1523
+ }
1524
+ try {
1525
+ await aws.s3.emptyS3Bucket(s3Slug);
1526
+ } catch {
1527
+ /* empty */
1528
+ }
1529
+ try {
1530
+ await aws.s3.removeS3Bucket(s3Slug);
1531
+ } catch {
1532
+ /* empty */
1533
+ }
1534
+ })(),
996
1535
  );
997
- } catch {
998
- /* empty */
999
1536
  }
1000
1537
 
1538
+ phase1.push(
1539
+ authorizeIngress(aws, name, rdsSlug, "rds", aws.ec2.revokeRDSIngress),
1540
+ authorizeIngress(
1541
+ aws,
1542
+ name,
1543
+ osSlug,
1544
+ "os",
1545
+ aws.ec2.revokeOpenSearchIngress,
1546
+ ),
1547
+ authorizeIngress(
1548
+ aws,
1549
+ name,
1550
+ cacheSlug,
1551
+ "cache",
1552
+ aws.ec2.revokeCacheIngress,
1553
+ ),
1554
+ );
1555
+
1556
+ await Promise.all(phase1);
1557
+
1558
+ // Phase 2: Terminate EB environment (must complete before SG deletion)
1001
1559
  try {
1002
- await module.exports.iam.removeEnvironmentInstanceProfile(name);
1560
+ await aws.elasticbeanstalk.removeElasticBeanstalkEnvironment(name);
1003
1561
  } catch {
1004
1562
  /* empty */
1005
1563
  }
1006
1564
 
1565
+ // Phase 3: EB app + instance profile + initiate service deletions in parallel
1566
+ const phase3 = [
1567
+ (async () => {
1568
+ try {
1569
+ await aws.elasticbeanstalk.removeElasticBeanstalkApplication(
1570
+ repo,
1571
+ );
1572
+ } catch {
1573
+ /* empty */
1574
+ }
1575
+ })(),
1576
+ (async () => {
1577
+ try {
1578
+ await aws.iam.removeEnvironmentInstanceProfile(name);
1579
+ } catch {
1580
+ /* empty */
1581
+ }
1582
+ })(),
1583
+ ];
1584
+
1007
1585
  if (deleteDatabase) {
1008
- try {
1009
- await module.exports.rds.deleteRDSInstance(rdsSlug);
1010
- await module.exports.rds.waitForRDSDeletion(rdsSlug);
1011
- } catch {
1012
- /* empty */
1013
- }
1586
+ phase3.push(aws.rds.deleteRDSInstance(rdsSlug).catch(() => {}));
1587
+ }
1014
1588
 
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
- }
1589
+ if (deleteOpenSearch) {
1590
+ phase3.push(
1591
+ aws.opensearch.deleteOpenSearchDomain(osSlug).catch(() => {}),
1592
+ );
1593
+ }
1594
+
1595
+ if (deleteCache) {
1596
+ phase3.push(
1597
+ aws.elasticache.deleteCacheCluster(cacheSlug).catch(() => {}),
1598
+ );
1599
+ }
1600
+
1601
+ await Promise.all(phase3);
1602
+
1603
+ // Phase 4: Wait for service deletions with round-robin polling
1604
+ const deletionPolls = [];
1605
+
1606
+ if (deleteDatabase) {
1607
+ const rdsClient = new RDSClient({});
1608
+
1609
+ deletionPolls.push({
1610
+ name: "RDS",
1611
+ cb: async () => {
1612
+ try {
1613
+ const res = await rdsClient.send(
1614
+ new DescribeDBInstancesCommand({
1615
+ DBInstanceIdentifier: rdsSlug,
1616
+ }),
1617
+ );
1618
+ return res.DBInstances[0];
1619
+ } catch (e) {
1620
+ if (e.name === "DBInstanceNotFoundFault") {
1621
+ return { DBInstanceStatus: "deleted" };
1622
+ }
1623
+ throw e;
1624
+ }
1625
+ },
1626
+ shouldContinue: ({ DBInstanceStatus }) =>
1627
+ DBInstanceStatus !== "deleted",
1628
+ });
1025
1629
  }
1026
1630
 
1027
1631
  if (deleteOpenSearch) {
1028
- try {
1029
- await module.exports.opensearch.deleteOpenSearchDomain(osSlug);
1030
- await module.exports.opensearch.waitForOpenSearchDeletion(osSlug);
1031
- } catch {
1032
- /* empty */
1033
- }
1632
+ const osClient = new OpenSearchClient({});
1633
+
1634
+ deletionPolls.push({
1635
+ name: "OpenSearch",
1636
+ cb: async () => {
1637
+ try {
1638
+ const { DomainStatus } = await osClient.send(
1639
+ new DescribeDomainCommand({ DomainName: osSlug }),
1640
+ );
1641
+ return DomainStatus;
1642
+ } catch (e) {
1643
+ if (e.name === "ResourceNotFoundException") {
1644
+ return { deleted: true };
1645
+ }
1646
+ throw e;
1647
+ }
1648
+ },
1649
+ shouldContinue: (status) => !status.deleted,
1650
+ });
1651
+ }
1034
1652
 
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
- }
1653
+ if (deleteCache) {
1654
+ const cacheClient = new ElastiCacheClient({});
1655
+
1656
+ deletionPolls.push({
1657
+ name: "ElastiCache",
1658
+ cb: async () => {
1659
+ try {
1660
+ const { ReplicationGroups } = await cacheClient.send(
1661
+ new DescribeReplicationGroupsCommand({
1662
+ ReplicationGroupId: cacheSlug,
1663
+ }),
1664
+ );
1665
+ return ReplicationGroups[0];
1666
+ } catch (e) {
1667
+ if (e.name === "ReplicationGroupNotFoundFault") {
1668
+ return { deleted: true };
1669
+ }
1670
+ throw e;
1671
+ }
1672
+ },
1673
+ shouldContinue: (group) => !group.deleted,
1674
+ });
1045
1675
  }
1046
1676
 
1047
- // Delete secrets
1048
- if (deleteStorage) {
1049
- try {
1050
- await module.exports.secretsmanager.deleteSecret(`${name}-storage`);
1051
- } catch {
1052
- /* empty */
1053
- }
1677
+ if (deletionPolls.length) {
1678
+ await pollAll(deletionPolls, "Waiting for services to terminate");
1054
1679
  }
1055
1680
 
1681
+ // Phase 5: Delete security groups after services are gone
1682
+ const sgDeletions = [];
1683
+
1056
1684
  if (deleteDatabase) {
1057
- try {
1058
- await module.exports.secretsmanager.deleteSecret(
1059
- `${name}-database`,
1060
- );
1061
- } catch {
1062
- /* empty */
1063
- }
1685
+ sgDeletions.push(
1686
+ (async () => {
1687
+ try {
1688
+ const sg = await aws.ec2.findSecurityGroupByName(
1689
+ `${rdsSlug}-rds`,
1690
+ );
1691
+ if (sg) {
1692
+ await aws.ec2.deleteSecurityGroup(sg.GroupId);
1693
+ }
1694
+ } catch {
1695
+ /* empty */
1696
+ }
1697
+ })(),
1698
+ );
1064
1699
  }
1065
1700
 
1066
1701
  if (deleteOpenSearch) {
1067
- try {
1068
- await module.exports.secretsmanager.deleteSecret(
1069
- `${name}-opensearch`,
1070
- );
1071
- } catch {
1072
- /* empty */
1073
- }
1702
+ sgDeletions.push(
1703
+ (async () => {
1704
+ try {
1705
+ const sg = await aws.ec2.findSecurityGroupByName(
1706
+ `${osSlug}-os`,
1707
+ );
1708
+ if (sg) {
1709
+ await aws.ec2.deleteSecurityGroup(sg.GroupId);
1710
+ }
1711
+ } catch {
1712
+ /* empty */
1713
+ }
1714
+ })(),
1715
+ );
1716
+ }
1717
+
1718
+ if (deleteCache) {
1719
+ sgDeletions.push(
1720
+ (async () => {
1721
+ try {
1722
+ const sg = await aws.ec2.findSecurityGroupByName(
1723
+ `${cacheSlug}-cache`,
1724
+ );
1725
+ if (sg) {
1726
+ await aws.ec2.deleteSecurityGroup(sg.GroupId);
1727
+ }
1728
+ } catch {
1729
+ /* empty */
1730
+ }
1731
+ })(),
1732
+ );
1074
1733
  }
1075
1734
 
1735
+ if (sgDeletions.length) {
1736
+ await Promise.all(sgDeletions);
1737
+ }
1738
+
1739
+ // Phase 6: Delete secrets in parallel
1740
+ const secretDeletions = [];
1741
+
1742
+ if (deleteStorage) {
1743
+ secretDeletions.push(
1744
+ aws.secretsmanager.deleteSecret(`${name}-storage`).catch(() => {}),
1745
+ );
1746
+ }
1747
+ if (deleteDatabase) {
1748
+ secretDeletions.push(
1749
+ aws.secretsmanager.deleteSecret(`${name}-database`).catch(() => {}),
1750
+ );
1751
+ }
1752
+ if (deleteOpenSearch) {
1753
+ secretDeletions.push(
1754
+ aws.secretsmanager
1755
+ .deleteSecret(`${name}-opensearch`)
1756
+ .catch(() => {}),
1757
+ );
1758
+ }
1759
+ if (deleteCache) {
1760
+ secretDeletions.push(
1761
+ aws.secretsmanager.deleteSecret(`${name}-cache`).catch(() => {}),
1762
+ );
1763
+ }
1076
1764
  if (deleteAppSecret) {
1077
- try {
1078
- await module.exports.secretsmanager.deleteSecret(`${name}-app`);
1079
- } catch {
1080
- /* empty */
1081
- }
1765
+ secretDeletions.push(
1766
+ aws.secretsmanager.deleteSecret(`${name}-app`).catch(() => {}),
1767
+ );
1082
1768
  }
1769
+ if (deleteMail) {
1770
+ secretDeletions.push(
1771
+ aws.secretsmanager.deleteSecret(`${name}-mail`).catch(() => {}),
1772
+ );
1773
+ secretDeletions.push(
1774
+ (async () => {
1775
+ const {
1776
+ IAMClient,
1777
+ DeleteAccessKeyCommand,
1778
+ ListAccessKeysCommand,
1779
+ RemoveUserFromGroupCommand,
1780
+ DeleteUserCommand,
1781
+ } = require("@aws-sdk/client-iam");
1782
+ const { fromIni } = require("@aws-sdk/credential-providers");
1783
+
1784
+ const iamClient = new IAMClient({
1785
+ region: "us-east-1",
1786
+ credentials: fromIni({ profile: "fishawack" }),
1787
+ });
1788
+ const userName = `${name}-ses`;
1789
+
1790
+ try {
1791
+ const keys = await iamClient.send(
1792
+ new ListAccessKeysCommand({ UserName: userName }),
1793
+ );
1794
+ for (const key of keys.AccessKeyMetadata || []) {
1795
+ await iamClient.send(
1796
+ new DeleteAccessKeyCommand({
1797
+ UserName: userName,
1798
+ AccessKeyId: key.AccessKeyId,
1799
+ }),
1800
+ );
1801
+ }
1802
+ await iamClient.send(
1803
+ new RemoveUserFromGroupCommand({
1804
+ UserName: userName,
1805
+ GroupName: "AWSSESSendingGroupDoNotRename",
1806
+ }),
1807
+ );
1808
+ await iamClient.send(
1809
+ new DeleteUserCommand({ UserName: userName }),
1810
+ );
1811
+ } catch {
1812
+ /* user may not exist */
1813
+ }
1814
+ })(),
1815
+ );
1816
+ }
1817
+
1818
+ await Promise.all(secretDeletions);
1083
1819
  };