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

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.
@@ -1,18 +1,159 @@
1
1
  const md5 = require("apache-md5");
2
2
  const fs = require("fs-extra");
3
+ const generator = require("generate-password");
4
+ const { S3Client, ListBucketsCommand } = require("@aws-sdk/client-s3");
5
+ const {
6
+ RDSClient,
7
+ DescribeDBInstancesCommand,
8
+ } = require("@aws-sdk/client-rds");
9
+ const {
10
+ SecretsManagerClient,
11
+ DescribeSecretCommand,
12
+ } = require("@aws-sdk/client-secrets-manager");
13
+ const {
14
+ OpenSearchClient,
15
+ DescribeDomainCommand,
16
+ } = require("@aws-sdk/client-opensearch");
3
17
 
4
- const { nameSafe } = require("../../libs/utilities.js");
5
- const { eb } = require("../../libs/vars.js");
18
+ const { nameSafe, Spinner } = require("../../libs/utilities.js");
19
+ const { eb, secretGroups } = require("../../libs/vars.js");
6
20
 
7
21
  module.exports.s3 = require("./s3.js");
8
22
  module.exports.cloudfront = require("./cloudfront.js");
9
23
  module.exports.iam = require("./iam.js");
10
24
  module.exports.elasticbeanstalk = require("./elasticbeanstalk.js");
11
25
  module.exports.ec2 = require("./ec2.js");
26
+ module.exports.rds = require("./rds.js");
27
+ module.exports.opensearch = require("./opensearch.js");
28
+ module.exports.secretsmanager = require("./secretsmanager.js");
29
+ module.exports.acm = require("./acm.js");
12
30
 
13
31
  module.exports.slug = (repo, client, branch, service = "s3") =>
14
32
  nameSafe(`${branch}-${repo}-${client}`, service);
15
33
 
34
+ module.exports.fullstackPreflight = async (
35
+ name,
36
+ repo,
37
+ branch,
38
+ { storage = false, database = false, opensearch = false } = {},
39
+ ) => {
40
+ const existing = {
41
+ environment: false,
42
+ storage: false,
43
+ database: false,
44
+ opensearch: false,
45
+ secrets: { app: null, storage: null, database: null, opensearch: null },
46
+ };
47
+
48
+ const spinner = new Spinner("Running pre-flight checks", true);
49
+
50
+ try {
51
+ // Check EB environment
52
+ spinner.ora.text = "Checking for existing EB environment";
53
+ try {
54
+ const { Environments } =
55
+ await module.exports.elasticbeanstalk.describeEnvironment(name);
56
+ const active = Environments.filter(
57
+ (e) => e.Status !== "Terminated",
58
+ );
59
+ if (active.length) {
60
+ existing.environment = active[0];
61
+ }
62
+ } catch {
63
+ /* does not exist */
64
+ }
65
+
66
+ // Check S3
67
+ if (storage) {
68
+ const s3Slug = module.exports.slug(
69
+ repo,
70
+ process.env.AWS_PROFILE,
71
+ branch,
72
+ "s3",
73
+ );
74
+
75
+ spinner.ora.text = "Checking for existing S3 bucket";
76
+ try {
77
+ const { Buckets } = await new S3Client({}).send(
78
+ new ListBucketsCommand({}),
79
+ );
80
+ if (Buckets.some((b) => b.Name === s3Slug)) {
81
+ existing.storage = true;
82
+ }
83
+ } catch {
84
+ /* empty */
85
+ }
86
+ }
87
+
88
+ // Check RDS
89
+ if (database) {
90
+ const rdsSlug = module.exports.slug(
91
+ repo,
92
+ process.env.AWS_PROFILE,
93
+ branch,
94
+ "rds",
95
+ );
96
+
97
+ spinner.ora.text = "Checking for existing RDS instance";
98
+ try {
99
+ const res = await new RDSClient({}).send(
100
+ new DescribeDBInstancesCommand({
101
+ DBInstanceIdentifier: rdsSlug,
102
+ }),
103
+ );
104
+ const instance = res.DBInstances[0];
105
+ if (instance && instance.DBInstanceStatus !== "deleting") {
106
+ existing.database = true;
107
+ }
108
+ } catch {
109
+ /* does not exist */
110
+ }
111
+ }
112
+
113
+ // Check OpenSearch
114
+ if (opensearch) {
115
+ const osSlug = module.exports.slug(
116
+ repo,
117
+ process.env.AWS_PROFILE,
118
+ branch,
119
+ "os",
120
+ );
121
+
122
+ spinner.ora.text = "Checking for existing OpenSearch domain";
123
+ try {
124
+ const { DomainStatus } = await new OpenSearchClient({}).send(
125
+ new DescribeDomainCommand({ DomainName: osSlug }),
126
+ );
127
+ if (DomainStatus && !DomainStatus.Deleted) {
128
+ existing.opensearch = true;
129
+ }
130
+ } catch {
131
+ /* does not exist */
132
+ }
133
+ }
134
+
135
+ // Check for existing secrets
136
+ for (const group of secretGroups) {
137
+ spinner.ora.text = `Checking for existing ${group} secret`;
138
+ try {
139
+ const secret = await new SecretsManagerClient({}).send(
140
+ new DescribeSecretCommand({ SecretId: `${name}-${group}` }),
141
+ );
142
+ existing.secrets[group] = secret.ARN;
143
+ } catch {
144
+ /* does not exist */
145
+ }
146
+ }
147
+
148
+ spinner.ora.succeed("Pre-flight checks complete");
149
+ } catch (e) {
150
+ spinner.ora.fail("Pre-flight checks failed");
151
+ throw e;
152
+ }
153
+
154
+ return { existing };
155
+ };
156
+
16
157
  module.exports.clients = [
17
158
  "fishawack",
18
159
  "abbvie",
@@ -166,35 +307,243 @@ module.exports.fullstack = async (
166
307
  framework,
167
308
  availability,
168
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,
169
329
  ) => {
170
330
  const { platform, language } = eb.configurations[framework];
331
+ const isLaravel = framework === "laravel";
171
332
 
172
- const s3Slug = module.exports.slug(
173
- repo,
174
- process.env.AWS_PROFILE,
175
- branch,
176
- "s3",
177
- );
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;
178
339
 
179
- await module.exports.iam.createIAMUser(s3Slug, tags);
180
- await module.exports.iam.attachIAMPolicy(
181
- s3Slug,
182
- "arn:aws:iam::aws:policy/AmazonS3FullAccess",
183
- );
184
- const {
185
- AccessKey: { AccessKeyId },
186
- AccessKey: { SecretAccessKey },
187
- } = await module.exports.iam.createAccessKey(s3Slug);
188
-
189
- await module.exports.s3.createS3Bucket(
190
- s3Slug,
191
- tags,
192
- false,
193
- "BucketOwnerPreferred",
194
- );
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
+
346
+ let s3Data = {};
347
+
348
+ if (storage && !existingResources.storage) {
349
+ if (isLaravel) {
350
+ await module.exports.s3.createS3Bucket(s3Slug, tags, {
351
+ BlockPublicAcls: true,
352
+ IgnorePublicAcls: true,
353
+ BlockPublicPolicy: false,
354
+ RestrictPublicBuckets: false,
355
+ });
356
+
357
+ await module.exports.s3.setPublicReadPolicy(s3Slug, "public/*");
358
+ } else {
359
+ await module.exports.iam.createIAMUser(s3Slug, tags);
360
+ await module.exports.iam.attachIAMPolicy(
361
+ s3Slug,
362
+ "arn:aws:iam::aws:policy/AmazonS3FullAccess",
363
+ );
364
+ const {
365
+ AccessKey: { AccessKeyId },
366
+ AccessKey: { SecretAccessKey },
367
+ } = await module.exports.iam.createAccessKey(s3Slug);
368
+
369
+ await module.exports.s3.createS3Bucket(
370
+ s3Slug,
371
+ tags,
372
+ false,
373
+ "BucketOwnerPreferred",
374
+ );
375
+
376
+ s3Data = { s3Slug, AccessKeyId, SecretAccessKey };
377
+ }
378
+ }
379
+
380
+ if (storage && !s3Data.s3Slug) {
381
+ s3Data = { s3Slug };
382
+ }
383
+
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
+ }
195
401
 
196
- await module.exports.iam.ensureEBInstanceProfileExists();
197
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 = {};
407
+
408
+ if (database && !existingResources.database) {
409
+ const rdsSlug = module.exports.slug(
410
+ repo,
411
+ process.env.AWS_PROFILE,
412
+ branch,
413
+ "rds",
414
+ );
415
+
416
+ const dbPassword = generator.generate({
417
+ length: 10,
418
+ numbers: true,
419
+ });
420
+
421
+ await module.exports.rds.ensureSSLParameterGroup();
422
+
423
+ const rdsSecurityGroupId =
424
+ await module.exports.ec2.createRDSSecurityGroup(rdsSlug, tags);
425
+
426
+ const dbSubnetGroupName = await module.exports.rds.ensureDBSubnetGroup(
427
+ vpcData.privateSubnetIds,
428
+ );
429
+
430
+ if (snapshot) {
431
+ await module.exports.rds.restoreRDSInstanceFromSnapshot(
432
+ rdsSlug,
433
+ snapshot,
434
+ rdsSecurityGroupId,
435
+ tags,
436
+ dbSubnetGroupName,
437
+ );
438
+
439
+ await module.exports.rds.waitForRDSInstance(rdsSlug);
440
+
441
+ await module.exports.rds.modifyRDSPassword(rdsSlug, dbPassword);
442
+ } else {
443
+ await module.exports.rds.createRDSInstance(
444
+ rdsSlug,
445
+ tags,
446
+ dbPassword,
447
+ rdsSecurityGroupId,
448
+ dbSubnetGroupName,
449
+ );
450
+ }
451
+
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,
459
+ };
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
+
473
+ const dbPassword = generator.generate({
474
+ length: 10,
475
+ numbers: true,
476
+ });
477
+
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,
486
+ };
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(
494
+ `${name}-database`,
495
+ );
496
+ rdsData = {
497
+ RDS_HOSTNAME: secretValue.RDS_HOSTNAME,
498
+ RDS_PORT: secretValue.RDS_PORT,
499
+ RDS_PASSWORD: secretValue.RDS_PASSWORD,
500
+ };
501
+ }
502
+
503
+ // Create decoupled OpenSearch domain if requested
504
+ let osData = {};
505
+
506
+ if (opensearch && !existingResources.opensearch) {
507
+ await module.exports.iam.ensureOpenSearchServiceRole();
508
+
509
+ const roleArn = instanceProfileName
510
+ ? (await module.exports.iam.getRole(instanceProfileName)).Role.Arn
511
+ : undefined;
512
+
513
+ const osSecurityGroupId =
514
+ await module.exports.ec2.createOpenSearchSecurityGroup(
515
+ osSlug,
516
+ tags,
517
+ );
518
+
519
+ await module.exports.opensearch.createOpenSearchDomain(
520
+ osSlug,
521
+ osSecurityGroupId,
522
+ vpcData.privateSubnetIds,
523
+ roleArn,
524
+ );
525
+
526
+ const osDomain =
527
+ await module.exports.opensearch.waitForOpenSearchDomain(osSlug);
528
+
529
+ const osEndpoint =
530
+ osDomain.Endpoint || (osDomain.Endpoints && osDomain.Endpoints.vpc);
531
+
532
+ osData = {
533
+ SEARCH_HOST: `https://${osEndpoint}`,
534
+ };
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);
539
+
540
+ const osEndpoint =
541
+ osDomain.Endpoint || (osDomain.Endpoints && osDomain.Endpoints.vpc);
542
+
543
+ osData = {
544
+ SEARCH_HOST: `https://${osEndpoint}`,
545
+ };
546
+ }
198
547
 
199
548
  try {
200
549
  await module.exports.elasticbeanstalk.createElasticBeanstalkApplication(
@@ -217,29 +566,273 @@ module.exports.fullstack = async (
217
566
  const CNAMEPrefix = `fw-auto-${name.split("-")[name.split("-").length - 1]}`;
218
567
  process.env.DOMAIN_LINK = `${CNAMEPrefix}.${process.env.AWS_REGION}.elasticbeanstalk.com`;
219
568
 
220
- const OptionSettings = eb.merge(
221
- "environments",
222
- { framework, availability, platform, language, database },
223
- { s3Slug, AccessKeyId, SecretAccessKey },
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,
586
+ },
224
587
  );
225
588
 
226
- if (hasKeyPair) {
227
- OptionSettings.push({
228
- OptionName: "EC2KeyName",
229
- Value: "id_rsa_prev",
230
- Namespace: "aws:autoscaling:launchconfiguration",
589
+ const secretArns = {};
590
+
591
+ for (const group of secretGroups) {
592
+ if (existingSecrets[group]) {
593
+ secretArns[`${group}SecretArn`] = existingSecrets[group];
594
+
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
+ );
604
+
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;
623
+ }
624
+ }
625
+
626
+ // Auto-generate environmentsecrets entries from secret keys + ARNs
627
+ const secretSettings = [];
628
+
629
+ for (const group of secretGroups) {
630
+ const arn = secretArns[`${group}SecretArn`];
631
+ if (!arn) continue;
632
+
633
+ let keys;
634
+
635
+ if (existingSecrets[group]) {
636
+ const secretValue =
637
+ await module.exports.secretsmanager.getSecretValue(
638
+ `${name}-${group}`,
639
+ );
640
+ keys = Object.keys(secretValue);
641
+ } else {
642
+ keys = Object.keys(payloads[group]);
643
+ }
644
+
645
+ for (const key of keys) {
646
+ secretSettings.push({
647
+ OptionName: key,
648
+ Value: `${arn}:${key}`,
649
+ Namespace:
650
+ "aws:elasticbeanstalk:application:environmentsecrets",
651
+ });
652
+ }
653
+ }
654
+
655
+ let environment;
656
+
657
+ if (existingEnvironment) {
658
+ // Re-provision: sync secrets to existing EB environment
659
+ const SECRETS_NAMESPACE =
660
+ "aws:elasticbeanstalk:application:environmentsecrets";
661
+ const currentSettings =
662
+ await module.exports.elasticbeanstalk.describeConfigurationSettings(
663
+ repo,
664
+ name,
665
+ );
666
+ const currentSecrets = currentSettings.filter(
667
+ (s) => s.Namespace === SECRETS_NAMESPACE,
668
+ );
669
+
670
+ const currentKeys = new Set(currentSecrets.map((s) => s.OptionName));
671
+ const expectedKeys = new Set(secretSettings.map((s) => s.OptionName));
672
+
673
+ const toAdd = secretSettings.filter(
674
+ (s) => !currentKeys.has(s.OptionName),
675
+ );
676
+ const toUpdate = secretSettings.filter((s) => {
677
+ const current = currentSecrets.find(
678
+ (c) => c.OptionName === s.OptionName,
679
+ );
680
+ return current && current.Value !== s.Value;
231
681
  });
682
+ const toRemove = currentSecrets
683
+ .filter((s) => !expectedKeys.has(s.OptionName))
684
+ .map((s) => ({
685
+ OptionName: s.OptionName,
686
+ Namespace: SECRETS_NAMESPACE,
687
+ }));
688
+
689
+ const optionSettings = [...toAdd, ...toUpdate];
690
+
691
+ if (optionSettings.length || toRemove.length) {
692
+ await module.exports.elasticbeanstalk.updateEnvironmentSettings(
693
+ name,
694
+ optionSettings,
695
+ toRemove,
696
+ );
697
+ }
698
+
699
+ environment = existingEnvironment;
700
+ } else {
701
+ const OptionSettings = eb.merge(
702
+ "environments",
703
+ {
704
+ framework,
705
+ availability,
706
+ platform,
707
+ language,
708
+ database,
709
+ storage,
710
+ opensearch,
711
+ vpcId: vpcData.vpcId,
712
+ privateSubnetIds: vpcData.privateSubnetIds.join(","),
713
+ publicSubnetIds: vpcData.publicSubnetIds.join(","),
714
+ elbScheme,
715
+ instanceProfileName,
716
+ },
717
+ {
718
+ ...s3Data,
719
+ ...rdsData,
720
+ ...osData,
721
+ APP_NAME: appName,
722
+ APP_DOMAIN: appDomain,
723
+ },
724
+ );
725
+
726
+ // HTTPS listener via ACM cert
727
+ if (certArn) {
728
+ OptionSettings.push(
729
+ {
730
+ OptionName: "ListenerEnabled",
731
+ Value: "true",
732
+ Namespace: "aws:elbv2:listener:443",
733
+ },
734
+ {
735
+ OptionName: "Protocol",
736
+ Value: "HTTPS",
737
+ Namespace: "aws:elbv2:listener:443",
738
+ },
739
+ {
740
+ OptionName: "SSLCertificateArns",
741
+ Value: certArn,
742
+ Namespace: "aws:elbv2:listener:443",
743
+ },
744
+ {
745
+ OptionName: "SSLPolicy",
746
+ Value: "ELBSecurityPolicy-FS-1-2-Res-2020-10",
747
+ Namespace: "aws:elbv2:listener:443",
748
+ },
749
+ {
750
+ OptionName: "DefaultProcess",
751
+ Value: "default",
752
+ Namespace: "aws:elbv2:listener:443",
753
+ },
754
+ );
755
+ }
756
+
757
+ OptionSettings.push(...secretSettings);
758
+
759
+ if (hasKeyPair) {
760
+ OptionSettings.push({
761
+ OptionName: "EC2KeyName",
762
+ Value: "id_rsa_prev",
763
+ Namespace: "aws:autoscaling:launchconfiguration",
764
+ });
765
+ }
766
+
767
+ environment =
768
+ await module.exports.elasticbeanstalk.createElasticBeanstalkEnvironment(
769
+ name,
770
+ { framework, availability, platform, language },
771
+ repo,
772
+ OptionSettings,
773
+ CNAMEPrefix,
774
+ tags,
775
+ );
232
776
  }
233
777
 
234
- const environment =
235
- await module.exports.elasticbeanstalk.createElasticBeanstalkEnvironment(
236
- name,
237
- { framework, availability, platform, language },
238
- repo,
239
- OptionSettings,
240
- CNAMEPrefix,
241
- tags,
778
+ // Add RDS security group ingress rule from EB security group
779
+ const rdsSlugForIngress = module.exports.slug(
780
+ repo,
781
+ process.env.AWS_PROFILE,
782
+ branch,
783
+ "rds",
784
+ );
785
+
786
+ try {
787
+ const rdsSg = await module.exports.ec2.findSecurityGroupByName(
788
+ `${rdsSlugForIngress}-rds`,
242
789
  );
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
+ }
809
+
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
+ }
835
+ }
243
836
 
244
837
  const configurations = eb.merge("configurations", {
245
838
  framework,
@@ -287,28 +880,104 @@ module.exports.fullstack = async (
287
880
  return config;
288
881
  };
289
882
 
290
- module.exports.fullstackTerminate = async (name, repo, branch) => {
291
- const s3Slug = module.exports.slug(
883
+ module.exports.fullstackTerminate = async (
884
+ name,
885
+ repo,
886
+ branch,
887
+ {
888
+ deleteStorage = false,
889
+ deleteDatabase = false,
890
+ deleteOpenSearch = false,
891
+ deleteAppSecret = false,
892
+ } = {},
893
+ ) => {
894
+ if (deleteStorage) {
895
+ const s3Slug = module.exports.slug(
896
+ repo,
897
+ process.env.AWS_PROFILE,
898
+ branch,
899
+ "s3",
900
+ );
901
+
902
+ try {
903
+ await module.exports.iam.removeIAMUser(s3Slug);
904
+ } catch {
905
+ /* empty */
906
+ }
907
+
908
+ try {
909
+ await module.exports.s3.emptyS3Bucket(s3Slug);
910
+ } catch {
911
+ /* empty */
912
+ }
913
+
914
+ try {
915
+ await module.exports.s3.removeS3Bucket(s3Slug);
916
+ } catch {
917
+ /* empty */
918
+ }
919
+ }
920
+
921
+ // Remove RDS inbound rule referencing EB security group before terminating EB
922
+ const rdsSlug = module.exports.slug(
292
923
  repo,
293
924
  process.env.AWS_PROFILE,
294
925
  branch,
295
- "s3",
926
+ "rds",
296
927
  );
297
928
 
298
929
  try {
299
- await module.exports.iam.removeIAMUser(s3Slug);
930
+ const rdsSg = await module.exports.ec2.findSecurityGroupByName(
931
+ `${rdsSlug}-rds`,
932
+ );
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
+ }
300
949
  } catch {
301
950
  /* empty */
302
951
  }
303
952
 
304
- try {
305
- await module.exports.s3.emptyS3Bucket(s3Slug);
306
- } catch {
307
- /* empty */
308
- }
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,
957
+ branch,
958
+ "os",
959
+ );
309
960
 
310
961
  try {
311
- await module.exports.s3.removeS3Bucket(s3Slug);
962
+ const osSg = await module.exports.ec2.findSecurityGroupByName(
963
+ `${osSlug}-os`,
964
+ );
965
+ const ebSgForOs = await module.exports.ec2.findSecurityGroupByTag([
966
+ {
967
+ Name: "tag:elasticbeanstalk:environment-name",
968
+ Values: [name],
969
+ },
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
+ }
312
981
  } catch {
313
982
  /* empty */
314
983
  }
@@ -328,4 +997,87 @@ module.exports.fullstackTerminate = async (name, repo, branch) => {
328
997
  } catch {
329
998
  /* empty */
330
999
  }
1000
+
1001
+ try {
1002
+ await module.exports.iam.removeEnvironmentInstanceProfile(name);
1003
+ } catch {
1004
+ /* empty */
1005
+ }
1006
+
1007
+ if (deleteDatabase) {
1008
+ try {
1009
+ await module.exports.rds.deleteRDSInstance(rdsSlug);
1010
+ await module.exports.rds.waitForRDSDeletion(rdsSlug);
1011
+ } catch {
1012
+ /* empty */
1013
+ }
1014
+
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
+ }
1025
+ }
1026
+
1027
+ if (deleteOpenSearch) {
1028
+ try {
1029
+ await module.exports.opensearch.deleteOpenSearchDomain(osSlug);
1030
+ await module.exports.opensearch.waitForOpenSearchDeletion(osSlug);
1031
+ } catch {
1032
+ /* empty */
1033
+ }
1034
+
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
+ }
1045
+ }
1046
+
1047
+ // Delete secrets
1048
+ if (deleteStorage) {
1049
+ try {
1050
+ await module.exports.secretsmanager.deleteSecret(`${name}-storage`);
1051
+ } catch {
1052
+ /* empty */
1053
+ }
1054
+ }
1055
+
1056
+ if (deleteDatabase) {
1057
+ try {
1058
+ await module.exports.secretsmanager.deleteSecret(
1059
+ `${name}-database`,
1060
+ );
1061
+ } catch {
1062
+ /* empty */
1063
+ }
1064
+ }
1065
+
1066
+ if (deleteOpenSearch) {
1067
+ try {
1068
+ await module.exports.secretsmanager.deleteSecret(
1069
+ `${name}-opensearch`,
1070
+ );
1071
+ } catch {
1072
+ /* empty */
1073
+ }
1074
+ }
1075
+
1076
+ if (deleteAppSecret) {
1077
+ try {
1078
+ await module.exports.secretsmanager.deleteSecret(`${name}-app`);
1079
+ } catch {
1080
+ /* empty */
1081
+ }
1082
+ }
331
1083
  };