@fishawack/lab-env 5.7.0-beta.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.
@@ -10,9 +10,13 @@ const {
10
10
  SecretsManagerClient,
11
11
  DescribeSecretCommand,
12
12
  } = require("@aws-sdk/client-secrets-manager");
13
+ const {
14
+ OpenSearchClient,
15
+ DescribeDomainCommand,
16
+ } = require("@aws-sdk/client-opensearch");
13
17
 
14
18
  const { nameSafe, Spinner } = require("../../libs/utilities.js");
15
- const { eb } = require("../../libs/vars.js");
19
+ const { eb, secretGroups } = require("../../libs/vars.js");
16
20
 
17
21
  module.exports.s3 = require("./s3.js");
18
22
  module.exports.cloudfront = require("./cloudfront.js");
@@ -20,7 +24,9 @@ module.exports.iam = require("./iam.js");
20
24
  module.exports.elasticbeanstalk = require("./elasticbeanstalk.js");
21
25
  module.exports.ec2 = require("./ec2.js");
22
26
  module.exports.rds = require("./rds.js");
27
+ module.exports.opensearch = require("./opensearch.js");
23
28
  module.exports.secretsmanager = require("./secretsmanager.js");
29
+ module.exports.acm = require("./acm.js");
24
30
 
25
31
  module.exports.slug = (repo, client, branch, service = "s3") =>
26
32
  nameSafe(`${branch}-${repo}-${client}`, service);
@@ -29,13 +35,14 @@ module.exports.fullstackPreflight = async (
29
35
  name,
30
36
  repo,
31
37
  branch,
32
- { storage = false, database = false } = {},
38
+ { storage = false, database = false, opensearch = false } = {},
33
39
  ) => {
34
- const conflicts = [];
35
40
  const existing = {
41
+ environment: false,
36
42
  storage: false,
37
43
  database: false,
38
- secrets: { app: null, storage: null, database: null },
44
+ opensearch: false,
45
+ secrets: { app: null, storage: null, database: null, opensearch: null },
39
46
  };
40
47
 
41
48
  const spinner = new Spinner("Running pre-flight checks", true);
@@ -50,9 +57,7 @@ module.exports.fullstackPreflight = async (
50
57
  (e) => e.Status !== "Terminated",
51
58
  );
52
59
  if (active.length) {
53
- conflicts.push(
54
- `Elastic Beanstalk environment "${name}" already exists (status: ${active[0].Status})`,
55
- );
60
+ existing.environment = active[0];
56
61
  }
57
62
  } catch {
58
63
  /* does not exist */
@@ -105,8 +110,29 @@ module.exports.fullstackPreflight = async (
105
110
  }
106
111
  }
107
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
+
108
135
  // Check for existing secrets
109
- const secretGroups = ["app", "storage", "database"];
110
136
  for (const group of secretGroups) {
111
137
  spinner.ora.text = `Checking for existing ${group} secret`;
112
138
  try {
@@ -125,7 +151,7 @@ module.exports.fullstackPreflight = async (
125
151
  throw e;
126
152
  }
127
153
 
128
- return { conflicts, existing };
154
+ return { existing };
129
155
  };
130
156
 
131
157
  module.exports.clients = [
@@ -282,49 +308,104 @@ module.exports.fullstack = async (
282
308
  availability,
283
309
  database = false,
284
310
  storage = false,
285
- existingSecrets = { app: null, storage: null, database: null },
311
+ opensearch = false,
312
+ existingSecrets = {
313
+ app: null,
314
+ storage: null,
315
+ database: null,
316
+ opensearch: null,
317
+ },
286
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,
287
329
  ) => {
288
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;
289
345
 
290
- let s3Slug;
291
346
  let s3Data = {};
292
347
 
293
- if (storage) {
294
- s3Slug = module.exports.slug(
295
- repo,
296
- process.env.AWS_PROFILE,
297
- branch,
298
- "s3",
299
- );
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
+ });
300
356
 
301
- await module.exports.iam.createIAMUser(s3Slug, tags);
302
- await module.exports.iam.attachIAMPolicy(
303
- s3Slug,
304
- "arn:aws:iam::aws:policy/AmazonS3FullAccess",
305
- );
306
- const {
307
- AccessKey: { AccessKeyId },
308
- AccessKey: { SecretAccessKey },
309
- } = await module.exports.iam.createAccessKey(s3Slug);
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);
310
368
 
311
- await module.exports.s3.createS3Bucket(
312
- s3Slug,
313
- tags,
314
- false,
315
- "BucketOwnerPreferred",
316
- );
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
+ }
317
383
 
318
- s3Data = { s3Slug, AccessKeyId, SecretAccessKey };
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();
319
400
  }
320
401
 
321
- await module.exports.iam.ensureEBInstanceProfileExists();
322
402
  await module.exports.iam.ensureEBManagedUpdateProfileExists();
403
+ const vpcData = await module.exports.ec2.ensurePrivateSubnets();
323
404
 
324
405
  // Create decoupled RDS instance if database is required
325
406
  let rdsData = {};
326
407
 
327
- if (database) {
408
+ if (database && !existingResources.database) {
328
409
  const rdsSlug = module.exports.slug(
329
410
  repo,
330
411
  process.env.AWS_PROFILE,
@@ -342,12 +423,17 @@ module.exports.fullstack = async (
342
423
  const rdsSecurityGroupId =
343
424
  await module.exports.ec2.createRDSSecurityGroup(rdsSlug, tags);
344
425
 
426
+ const dbSubnetGroupName = await module.exports.rds.ensureDBSubnetGroup(
427
+ vpcData.privateSubnetIds,
428
+ );
429
+
345
430
  if (snapshot) {
346
431
  await module.exports.rds.restoreRDSInstanceFromSnapshot(
347
432
  rdsSlug,
348
433
  snapshot,
349
434
  rdsSecurityGroupId,
350
435
  tags,
436
+ dbSubnetGroupName,
351
437
  );
352
438
 
353
439
  await module.exports.rds.waitForRDSInstance(rdsSlug);
@@ -359,6 +445,7 @@ module.exports.fullstack = async (
359
445
  tags,
360
446
  dbPassword,
361
447
  rdsSecurityGroupId,
448
+ dbSubnetGroupName,
362
449
  );
363
450
  }
364
451
 
@@ -370,6 +457,92 @@ module.exports.fullstack = async (
370
457
  RDS_PORT: String(rdsInstance.Endpoint.Port),
371
458
  RDS_PASSWORD: dbPassword,
372
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
+ };
373
546
  }
374
547
 
375
548
  try {
@@ -395,15 +568,51 @@ module.exports.fullstack = async (
395
568
 
396
569
  // Build secret payloads and create/reuse secrets
397
570
  const payloads = eb.buildSecretPayloads(
398
- { framework, availability, platform, language, database, storage },
399
- { ...s3Data, ...rdsData },
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
+ },
400
587
  );
401
588
 
402
589
  const secretArns = {};
403
590
 
404
- for (const group of ["app", "storage", "database"]) {
591
+ for (const group of secretGroups) {
405
592
  if (existingSecrets[group]) {
406
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
+ }
407
616
  } else if (Object.keys(payloads[group]).length) {
408
617
  const res = await module.exports.secretsmanager.createSecret(
409
618
  `${name}-${group}`,
@@ -417,7 +626,7 @@ module.exports.fullstack = async (
417
626
  // Auto-generate environmentsecrets entries from secret keys + ARNs
418
627
  const secretSettings = [];
419
628
 
420
- for (const group of ["app", "storage", "database"]) {
629
+ for (const group of secretGroups) {
421
630
  const arn = secretArns[`${group}SecretArn`];
422
631
  if (!arn) continue;
423
632
 
@@ -443,39 +652,129 @@ module.exports.fullstack = async (
443
652
  }
444
653
  }
445
654
 
446
- const OptionSettings = eb.merge(
447
- "environments",
448
- {
449
- framework,
450
- availability,
451
- platform,
452
- language,
453
- database,
454
- storage,
455
- },
456
- { ...s3Data, ...rdsData },
457
- );
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
+ );
458
669
 
459
- OptionSettings.push(...secretSettings);
670
+ const currentKeys = new Set(currentSecrets.map((s) => s.OptionName));
671
+ const expectedKeys = new Set(secretSettings.map((s) => s.OptionName));
460
672
 
461
- if (hasKeyPair) {
462
- OptionSettings.push({
463
- OptionName: "EC2KeyName",
464
- Value: "id_rsa_prev",
465
- Namespace: "aws:autoscaling:launchconfiguration",
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;
466
681
  });
467
- }
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
+ }
468
698
 
469
- const environment =
470
- await module.exports.elasticbeanstalk.createElasticBeanstalkEnvironment(
471
- name,
472
- { framework, availability, platform, language },
473
- repo,
474
- OptionSettings,
475
- CNAMEPrefix,
476
- tags,
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
+ },
477
724
  );
478
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
+ );
776
+ }
777
+
479
778
  // Add RDS security group ingress rule from EB security group
480
779
  const rdsSlugForIngress = module.exports.slug(
481
780
  repo,
@@ -508,6 +807,33 @@ module.exports.fullstack = async (
508
807
  /* empty */
509
808
  }
510
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
+ }
836
+
511
837
  const configurations = eb.merge("configurations", {
512
838
  framework,
513
839
  availability,
@@ -561,6 +887,7 @@ module.exports.fullstackTerminate = async (
561
887
  {
562
888
  deleteStorage = false,
563
889
  deleteDatabase = false,
890
+ deleteOpenSearch = false,
564
891
  deleteAppSecret = false,
565
892
  } = {},
566
893
  ) => {
@@ -623,6 +950,38 @@ module.exports.fullstackTerminate = async (
623
950
  /* empty */
624
951
  }
625
952
 
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
+ );
960
+
961
+ try {
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
+ }
981
+ } catch {
982
+ /* empty */
983
+ }
984
+
626
985
  try {
627
986
  await module.exports.elasticbeanstalk.removeElasticBeanstalkEnvironment(
628
987
  name,
@@ -639,6 +998,12 @@ module.exports.fullstackTerminate = async (
639
998
  /* empty */
640
999
  }
641
1000
 
1001
+ try {
1002
+ await module.exports.iam.removeEnvironmentInstanceProfile(name);
1003
+ } catch {
1004
+ /* empty */
1005
+ }
1006
+
642
1007
  if (deleteDatabase) {
643
1008
  try {
644
1009
  await module.exports.rds.deleteRDSInstance(rdsSlug);
@@ -659,6 +1024,26 @@ module.exports.fullstackTerminate = async (
659
1024
  }
660
1025
  }
661
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
+
662
1047
  // Delete secrets
663
1048
  if (deleteStorage) {
664
1049
  try {
@@ -678,6 +1063,16 @@ module.exports.fullstackTerminate = async (
678
1063
  }
679
1064
  }
680
1065
 
1066
+ if (deleteOpenSearch) {
1067
+ try {
1068
+ await module.exports.secretsmanager.deleteSecret(
1069
+ `${name}-opensearch`,
1070
+ );
1071
+ } catch {
1072
+ /* empty */
1073
+ }
1074
+ }
1075
+
681
1076
  if (deleteAppSecret) {
682
1077
  try {
683
1078
  await module.exports.secretsmanager.deleteSecret(`${name}-app`);