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

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.
@@ -147,6 +147,11 @@ module.exports.syncFWIAMPolicies = async (
147
147
  UserName,
148
148
  "arn:aws:iam::aws:policy/AmazonElastiCacheFullAccess",
149
149
  );
150
+
151
+ await module.exports.attachIAMPolicy(
152
+ UserName,
153
+ "arn:aws:iam::aws:policy/SecretsManagerReadWrite",
154
+ );
150
155
  }
151
156
 
152
157
  if (permissions.includes("manage-users")) {
@@ -393,6 +398,10 @@ module.exports.ensureEBInstanceProfileExists = async () => {
393
398
  role,
394
399
  "arn:aws:iam::aws:policy/AWSElasticBeanstalkMulticontainerDocker",
395
400
  );
401
+ await module.exports.attachRolePolicy(
402
+ role,
403
+ "arn:aws:iam::aws:policy/AWSSecretsManagerClientReadOnlyAccess",
404
+ );
396
405
  await module.exports.attachInlineRolePolicy(
397
406
  role,
398
407
  "lab-env-elasticbeanstalk-describe-env",
@@ -465,6 +474,11 @@ module.exports.ensureEBManagedUpdateProfileExists = async () => {
465
474
  "arn:aws:iam::aws:policy/AWSElasticBeanstalkManagedUpdatesCustomerRolePolicy",
466
475
  );
467
476
 
477
+ await module.exports.attachRolePolicy(
478
+ role,
479
+ "arn:aws:iam::aws:policy/AWSSecretsManagerClientReadOnlyAccess",
480
+ );
481
+
468
482
  await module.exports.attachInlineRolePolicy(
469
483
  role,
470
484
  "lab-env-elasticbeanstalk-custom-managed-updates-permissions",
@@ -1,7 +1,17 @@
1
1
  const md5 = require("apache-md5");
2
2
  const fs = require("fs-extra");
3
-
4
- const { nameSafe } = require("../../libs/utilities.js");
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
+
14
+ const { nameSafe, Spinner } = require("../../libs/utilities.js");
5
15
  const { eb } = require("../../libs/vars.js");
6
16
 
7
17
  module.exports.s3 = require("./s3.js");
@@ -9,10 +19,115 @@ module.exports.cloudfront = require("./cloudfront.js");
9
19
  module.exports.iam = require("./iam.js");
10
20
  module.exports.elasticbeanstalk = require("./elasticbeanstalk.js");
11
21
  module.exports.ec2 = require("./ec2.js");
22
+ module.exports.rds = require("./rds.js");
23
+ module.exports.secretsmanager = require("./secretsmanager.js");
12
24
 
13
25
  module.exports.slug = (repo, client, branch, service = "s3") =>
14
26
  nameSafe(`${branch}-${repo}-${client}`, service);
15
27
 
28
+ module.exports.fullstackPreflight = async (
29
+ name,
30
+ repo,
31
+ branch,
32
+ { storage = false, database = false } = {},
33
+ ) => {
34
+ const conflicts = [];
35
+ const existing = {
36
+ storage: false,
37
+ database: false,
38
+ secrets: { app: null, storage: null, database: null },
39
+ };
40
+
41
+ const spinner = new Spinner("Running pre-flight checks", true);
42
+
43
+ try {
44
+ // Check EB environment
45
+ spinner.ora.text = "Checking for existing EB environment";
46
+ try {
47
+ const { Environments } =
48
+ await module.exports.elasticbeanstalk.describeEnvironment(name);
49
+ const active = Environments.filter(
50
+ (e) => e.Status !== "Terminated",
51
+ );
52
+ if (active.length) {
53
+ conflicts.push(
54
+ `Elastic Beanstalk environment "${name}" already exists (status: ${active[0].Status})`,
55
+ );
56
+ }
57
+ } catch {
58
+ /* does not exist */
59
+ }
60
+
61
+ // Check S3
62
+ if (storage) {
63
+ const s3Slug = module.exports.slug(
64
+ repo,
65
+ process.env.AWS_PROFILE,
66
+ branch,
67
+ "s3",
68
+ );
69
+
70
+ spinner.ora.text = "Checking for existing S3 bucket";
71
+ try {
72
+ const { Buckets } = await new S3Client({}).send(
73
+ new ListBucketsCommand({}),
74
+ );
75
+ if (Buckets.some((b) => b.Name === s3Slug)) {
76
+ existing.storage = true;
77
+ }
78
+ } catch {
79
+ /* empty */
80
+ }
81
+ }
82
+
83
+ // Check RDS
84
+ if (database) {
85
+ const rdsSlug = module.exports.slug(
86
+ repo,
87
+ process.env.AWS_PROFILE,
88
+ branch,
89
+ "rds",
90
+ );
91
+
92
+ spinner.ora.text = "Checking for existing RDS instance";
93
+ try {
94
+ const res = await new RDSClient({}).send(
95
+ new DescribeDBInstancesCommand({
96
+ DBInstanceIdentifier: rdsSlug,
97
+ }),
98
+ );
99
+ const instance = res.DBInstances[0];
100
+ if (instance && instance.DBInstanceStatus !== "deleting") {
101
+ existing.database = true;
102
+ }
103
+ } catch {
104
+ /* does not exist */
105
+ }
106
+ }
107
+
108
+ // Check for existing secrets
109
+ const secretGroups = ["app", "storage", "database"];
110
+ for (const group of secretGroups) {
111
+ spinner.ora.text = `Checking for existing ${group} secret`;
112
+ try {
113
+ const secret = await new SecretsManagerClient({}).send(
114
+ new DescribeSecretCommand({ SecretId: `${name}-${group}` }),
115
+ );
116
+ existing.secrets[group] = secret.ARN;
117
+ } catch {
118
+ /* does not exist */
119
+ }
120
+ }
121
+
122
+ spinner.ora.succeed("Pre-flight checks complete");
123
+ } catch (e) {
124
+ spinner.ora.fail("Pre-flight checks failed");
125
+ throw e;
126
+ }
127
+
128
+ return { conflicts, existing };
129
+ };
130
+
16
131
  module.exports.clients = [
17
132
  "fishawack",
18
133
  "abbvie",
@@ -166,36 +281,97 @@ module.exports.fullstack = async (
166
281
  framework,
167
282
  availability,
168
283
  database = false,
284
+ storage = false,
285
+ existingSecrets = { app: null, storage: null, database: null },
286
+ snapshot = null,
169
287
  ) => {
170
288
  const { platform, language } = eb.configurations[framework];
171
289
 
172
- const s3Slug = module.exports.slug(
173
- repo,
174
- process.env.AWS_PROFILE,
175
- branch,
176
- "s3",
177
- );
290
+ let s3Slug;
291
+ let s3Data = {};
178
292
 
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
- );
293
+ if (storage) {
294
+ s3Slug = module.exports.slug(
295
+ repo,
296
+ process.env.AWS_PROFILE,
297
+ branch,
298
+ "s3",
299
+ );
300
+
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);
310
+
311
+ await module.exports.s3.createS3Bucket(
312
+ s3Slug,
313
+ tags,
314
+ false,
315
+ "BucketOwnerPreferred",
316
+ );
317
+
318
+ s3Data = { s3Slug, AccessKeyId, SecretAccessKey };
319
+ }
195
320
 
196
321
  await module.exports.iam.ensureEBInstanceProfileExists();
197
322
  await module.exports.iam.ensureEBManagedUpdateProfileExists();
198
323
 
324
+ // Create decoupled RDS instance if database is required
325
+ let rdsData = {};
326
+
327
+ if (database) {
328
+ const rdsSlug = module.exports.slug(
329
+ repo,
330
+ process.env.AWS_PROFILE,
331
+ branch,
332
+ "rds",
333
+ );
334
+
335
+ const dbPassword = generator.generate({
336
+ length: 10,
337
+ numbers: true,
338
+ });
339
+
340
+ await module.exports.rds.ensureSSLParameterGroup();
341
+
342
+ const rdsSecurityGroupId =
343
+ await module.exports.ec2.createRDSSecurityGroup(rdsSlug, tags);
344
+
345
+ if (snapshot) {
346
+ await module.exports.rds.restoreRDSInstanceFromSnapshot(
347
+ rdsSlug,
348
+ snapshot,
349
+ rdsSecurityGroupId,
350
+ tags,
351
+ );
352
+
353
+ await module.exports.rds.waitForRDSInstance(rdsSlug);
354
+
355
+ await module.exports.rds.modifyRDSPassword(rdsSlug, dbPassword);
356
+ } else {
357
+ await module.exports.rds.createRDSInstance(
358
+ rdsSlug,
359
+ tags,
360
+ dbPassword,
361
+ rdsSecurityGroupId,
362
+ );
363
+ }
364
+
365
+ const rdsInstance =
366
+ await module.exports.rds.waitForRDSInstance(rdsSlug);
367
+
368
+ rdsData = {
369
+ RDS_HOSTNAME: rdsInstance.Endpoint.Address,
370
+ RDS_PORT: String(rdsInstance.Endpoint.Port),
371
+ RDS_PASSWORD: dbPassword,
372
+ };
373
+ }
374
+
199
375
  try {
200
376
  await module.exports.elasticbeanstalk.createElasticBeanstalkApplication(
201
377
  repo,
@@ -217,12 +393,71 @@ module.exports.fullstack = async (
217
393
  const CNAMEPrefix = `fw-auto-${name.split("-")[name.split("-").length - 1]}`;
218
394
  process.env.DOMAIN_LINK = `${CNAMEPrefix}.${process.env.AWS_REGION}.elasticbeanstalk.com`;
219
395
 
396
+ // Build secret payloads and create/reuse secrets
397
+ const payloads = eb.buildSecretPayloads(
398
+ { framework, availability, platform, language, database, storage },
399
+ { ...s3Data, ...rdsData },
400
+ );
401
+
402
+ const secretArns = {};
403
+
404
+ for (const group of ["app", "storage", "database"]) {
405
+ if (existingSecrets[group]) {
406
+ secretArns[`${group}SecretArn`] = existingSecrets[group];
407
+ } else if (Object.keys(payloads[group]).length) {
408
+ const res = await module.exports.secretsmanager.createSecret(
409
+ `${name}-${group}`,
410
+ payloads[group],
411
+ tags,
412
+ );
413
+ secretArns[`${group}SecretArn`] = res.ARN;
414
+ }
415
+ }
416
+
417
+ // Auto-generate environmentsecrets entries from secret keys + ARNs
418
+ const secretSettings = [];
419
+
420
+ for (const group of ["app", "storage", "database"]) {
421
+ const arn = secretArns[`${group}SecretArn`];
422
+ if (!arn) continue;
423
+
424
+ let keys;
425
+
426
+ if (existingSecrets[group]) {
427
+ const secretValue =
428
+ await module.exports.secretsmanager.getSecretValue(
429
+ `${name}-${group}`,
430
+ );
431
+ keys = Object.keys(secretValue);
432
+ } else {
433
+ keys = Object.keys(payloads[group]);
434
+ }
435
+
436
+ for (const key of keys) {
437
+ secretSettings.push({
438
+ OptionName: key,
439
+ Value: `${arn}:${key}`,
440
+ Namespace:
441
+ "aws:elasticbeanstalk:application:environmentsecrets",
442
+ });
443
+ }
444
+ }
445
+
220
446
  const OptionSettings = eb.merge(
221
447
  "environments",
222
- { framework, availability, platform, language, database },
223
- { s3Slug, AccessKeyId, SecretAccessKey },
448
+ {
449
+ framework,
450
+ availability,
451
+ platform,
452
+ language,
453
+ database,
454
+ storage,
455
+ },
456
+ { ...s3Data, ...rdsData },
224
457
  );
225
458
 
459
+ OptionSettings.push(...secretSettings);
460
+
226
461
  if (hasKeyPair) {
227
462
  OptionSettings.push({
228
463
  OptionName: "EC2KeyName",
@@ -241,6 +476,38 @@ module.exports.fullstack = async (
241
476
  tags,
242
477
  );
243
478
 
479
+ // Add RDS security group ingress rule from EB security group
480
+ const rdsSlugForIngress = module.exports.slug(
481
+ repo,
482
+ process.env.AWS_PROFILE,
483
+ branch,
484
+ "rds",
485
+ );
486
+
487
+ try {
488
+ const rdsSg = await module.exports.ec2.findSecurityGroupByName(
489
+ `${rdsSlugForIngress}-rds`,
490
+ );
491
+ const ebSg = await module.exports.ec2.findSecurityGroupByTag([
492
+ {
493
+ Name: "tag:elasticbeanstalk:environment-name",
494
+ Values: [name],
495
+ },
496
+ {
497
+ Name: "tag:aws:cloudformation:logical-id",
498
+ Values: ["AWSEBSecurityGroup"],
499
+ },
500
+ ]);
501
+ if (rdsSg && ebSg) {
502
+ await module.exports.ec2.authorizeRDSIngress(
503
+ rdsSg.GroupId,
504
+ ebSg.GroupId,
505
+ );
506
+ }
507
+ } catch {
508
+ /* empty */
509
+ }
510
+
244
511
  const configurations = eb.merge("configurations", {
245
512
  framework,
246
513
  availability,
@@ -287,28 +554,71 @@ module.exports.fullstack = async (
287
554
  return config;
288
555
  };
289
556
 
290
- module.exports.fullstackTerminate = async (name, repo, branch) => {
291
- const s3Slug = module.exports.slug(
557
+ module.exports.fullstackTerminate = async (
558
+ name,
559
+ repo,
560
+ branch,
561
+ {
562
+ deleteStorage = false,
563
+ deleteDatabase = false,
564
+ deleteAppSecret = false,
565
+ } = {},
566
+ ) => {
567
+ if (deleteStorage) {
568
+ const s3Slug = module.exports.slug(
569
+ repo,
570
+ process.env.AWS_PROFILE,
571
+ branch,
572
+ "s3",
573
+ );
574
+
575
+ try {
576
+ await module.exports.iam.removeIAMUser(s3Slug);
577
+ } catch {
578
+ /* empty */
579
+ }
580
+
581
+ try {
582
+ await module.exports.s3.emptyS3Bucket(s3Slug);
583
+ } catch {
584
+ /* empty */
585
+ }
586
+
587
+ try {
588
+ await module.exports.s3.removeS3Bucket(s3Slug);
589
+ } catch {
590
+ /* empty */
591
+ }
592
+ }
593
+
594
+ // Remove RDS inbound rule referencing EB security group before terminating EB
595
+ const rdsSlug = module.exports.slug(
292
596
  repo,
293
597
  process.env.AWS_PROFILE,
294
598
  branch,
295
- "s3",
599
+ "rds",
296
600
  );
297
601
 
298
602
  try {
299
- await module.exports.iam.removeIAMUser(s3Slug);
300
- } catch {
301
- /* empty */
302
- }
303
-
304
- try {
305
- await module.exports.s3.emptyS3Bucket(s3Slug);
306
- } catch {
307
- /* empty */
308
- }
309
-
310
- try {
311
- await module.exports.s3.removeS3Bucket(s3Slug);
603
+ const rdsSg = await module.exports.ec2.findSecurityGroupByName(
604
+ `${rdsSlug}-rds`,
605
+ );
606
+ const ebSg = await module.exports.ec2.findSecurityGroupByTag([
607
+ {
608
+ Name: "tag:elasticbeanstalk:environment-name",
609
+ Values: [name],
610
+ },
611
+ {
612
+ Name: "tag:aws:cloudformation:logical-id",
613
+ Values: ["AWSEBSecurityGroup"],
614
+ },
615
+ ]);
616
+ if (rdsSg && ebSg) {
617
+ await module.exports.ec2.revokeRDSIngress(
618
+ rdsSg.GroupId,
619
+ ebSg.GroupId,
620
+ );
621
+ }
312
622
  } catch {
313
623
  /* empty */
314
624
  }
@@ -328,4 +638,51 @@ module.exports.fullstackTerminate = async (name, repo, branch) => {
328
638
  } catch {
329
639
  /* empty */
330
640
  }
641
+
642
+ if (deleteDatabase) {
643
+ try {
644
+ await module.exports.rds.deleteRDSInstance(rdsSlug);
645
+ await module.exports.rds.waitForRDSDeletion(rdsSlug);
646
+ } catch {
647
+ /* empty */
648
+ }
649
+
650
+ try {
651
+ const sg = await module.exports.ec2.findSecurityGroupByName(
652
+ `${rdsSlug}-rds`,
653
+ );
654
+ if (sg) {
655
+ await module.exports.ec2.deleteSecurityGroup(sg.GroupId);
656
+ }
657
+ } catch {
658
+ /* empty */
659
+ }
660
+ }
661
+
662
+ // Delete secrets
663
+ if (deleteStorage) {
664
+ try {
665
+ await module.exports.secretsmanager.deleteSecret(`${name}-storage`);
666
+ } catch {
667
+ /* empty */
668
+ }
669
+ }
670
+
671
+ if (deleteDatabase) {
672
+ try {
673
+ await module.exports.secretsmanager.deleteSecret(
674
+ `${name}-database`,
675
+ );
676
+ } catch {
677
+ /* empty */
678
+ }
679
+ }
680
+
681
+ if (deleteAppSecret) {
682
+ try {
683
+ await module.exports.secretsmanager.deleteSecret(`${name}-app`);
684
+ } catch {
685
+ /* empty */
686
+ }
687
+ }
331
688
  };