@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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  ## Changelog
2
2
 
3
+ ### 5.7.0-beta.2 (2026-06-11)
4
+
5
+ #### Features
6
+
7
+ * ability to add opensearch to laravel fullstack provisions ([6218c78](https://bitbucket.org/fishawackdigital/lab-env/commits/6218c7835124941fd63ef8056c462a8775f6d7ae))
8
+ * added ssm permission to ec2 role ([7b0195b](https://bitbucket.org/fishawackdigital/lab-env/commits/7b0195b98c59dd4fdcc8ca6848847dddda9ffb61))
9
+ * auto configuration of the acm cert and port 443 listener if cert found ([878ce17](https://bitbucket.org/fishawackdigital/lab-env/commits/878ce175b6031a7ee6cd9f84420c8ace4d858093))
10
+ * ec2 instances always in private and prompt for load balancer visibility + python now dynamic ([bd651cb](https://bitbucket.org/fishawackdigital/lab-env/commits/bd651cb78733d45bf1601e24dbd9c5a5ffc458e0))
11
+ * ensure private subnets now a standard part of the prov command ([22f9876](https://bitbucket.org/fishawackdigital/lab-env/commits/22f9876e390689f7e94939be3839d0a2312f5f10))
12
+ * rds now also creates in private subnet ([e7fb89d](https://bitbucket.org/fishawackdigital/lab-env/commits/e7fb89d9dfbb3fe62c34e8a07c6ba1c7fc732964))
13
+ * use instance role and bucket policy for access now and move restore to after preflight checks ([c4bc090](https://bitbucket.org/fishawackdigital/lab-env/commits/c4bc090927a8dd9d43a52b93f06926e7167003b2))
14
+
15
+ #### Bug Fixes
16
+
17
+ * fullstack provision now indempotent and search enforces search role ([ca374ca](https://bitbucket.org/fishawackdigital/lab-env/commits/ca374caf5c1efa0fa216f62db776591de228e8d5))
18
+
19
+ ### 5.7.0-beta.1 (2026-06-08)
20
+
21
+ #### Features
22
+
23
+ * added a sync secrets command ([c98c66d](https://bitbucket.org/fishawackdigital/lab-env/commits/c98c66dc0d92bdac9dd79ae544362e60bae4ac88))
24
+ * added the ability to restore from snapshot ([c0f3c6c](https://bitbucket.org/fishawackdigital/lab-env/commits/c0f3c6c5ee841121e78bc38047e40336c5880ef0))
25
+ * elasticbeanstlak now fully utilizes secrets manager and has options to persist after delete ([bbb252c](https://bitbucket.org/fishawackdigital/lab-env/commits/bbb252cf5e2f07551c63df6fcebd574c1a84d9ab))
26
+ * elb instance profile and managed update profile now include reading secretmanager permissions ([9059b01](https://bitbucket.org/fishawackdigital/lab-env/commits/9059b010ce781e07f3630aba5bb0b88556bed929))
27
+ * encryption in transit for db connections by default for laravel applications and enforcement ([cac6590](https://bitbucket.org/fishawackdigital/lab-env/commits/cac659083dfef46eb047b943451be854d4daf94f))
28
+ * rds and s3 no fully decoupled from fullstack and can be reattached to recreated elb instances ([72d3361](https://bitbucket.org/fishawackdigital/lab-env/commits/72d3361441e37d70f8b78dc98850828a92ea48a1))
29
+ * setvars now utilizes secret manager secrets instead of plain text ([462ef90](https://bitbucket.org/fishawackdigital/lab-env/commits/462ef9077dad31b420355038c3264abf471164b9))
30
+
31
+ #### Bug Fixes
32
+
33
+ * added secret manager permission to fullstack deploy permissions ([6432d2d](https://bitbucket.org/fishawackdigital/lab-env/commits/6432d2d756e19e96c62d8c29dd79828811ecf1ac))
34
+ * disable public access to RDS instances ([d4759e8](https://bitbucket.org/fishawackdigital/lab-env/commits/d4759e8ac11ecfbb22b0c3c80c96d5781ae8bda9))
35
+ * session driver now uses database by default ([d745615](https://bitbucket.org/fishawackdigital/lab-env/commits/d7456154920ab380e2c01424e065c9aca6edf750))
36
+ * use correct spinner class for logging ([592d7d5](https://bitbucket.org/fishawackdigital/lab-env/commits/592d7d56cf83ebfd62ce9d025450a8717222e8e0))
37
+
3
38
  ### 5.6.1 (2026-06-03)
4
39
 
5
40
  #### Bug Fixes
@@ -66,7 +66,9 @@ describe("provision", () => {
66
66
  });
67
67
 
68
68
  after(async () => {
69
- await aws.fullstackTerminate(repo, repo, branch);
69
+ await aws.fullstackTerminate(repo, repo, branch, {
70
+ deleteAppSecret: true,
71
+ });
70
72
 
71
73
  execSync(`rm -rf .elasticbeanstalk`, {
72
74
  encoding: "utf8",
package/cli.js CHANGED
@@ -171,6 +171,7 @@ const args = hideBin(process.argv);
171
171
  "key",
172
172
  "dekey",
173
173
  "prune",
174
+ "sync-secrets",
174
175
  ].forEach((d) =>
175
176
  cli.command(...require(`./commands/create/cmds/${d}.js`)),
176
177
  );
@@ -78,11 +78,42 @@ module.exports = [
78
78
  // Set AWS client defaults before any AWS operations
79
79
  setAWSClientDefaults(answers.client, answers.region);
80
80
 
81
- await aws[`${answers.stack}Terminate`](
82
- slug,
83
- _.repoSafe,
84
- branch,
85
- answers.id,
86
- );
81
+ if (answers.stack === "fullstack") {
82
+ const resourceAnswers = await inquirer.prompt([
83
+ {
84
+ type: "confirm",
85
+ name: "deleteStorage",
86
+ message: "Delete the S3 storage bucket?",
87
+ default: false,
88
+ },
89
+ {
90
+ type: "confirm",
91
+ name: "deleteDatabase",
92
+ message: "Delete the RDS database?",
93
+ default: false,
94
+ },
95
+ {
96
+ type: "confirm",
97
+ name: "deleteOpenSearch",
98
+ message: "Delete the OpenSearch domain?",
99
+ default: false,
100
+ },
101
+ {
102
+ type: "confirm",
103
+ name: "deleteAppSecret",
104
+ message: "Delete the app secret (APP_KEY, salts, etc.)?",
105
+ default: false,
106
+ },
107
+ ]);
108
+
109
+ await aws.fullstackTerminate(slug, _.repoSafe, branch, {
110
+ deleteStorage: resourceAnswers.deleteStorage,
111
+ deleteDatabase: resourceAnswers.deleteDatabase,
112
+ deleteOpenSearch: resourceAnswers.deleteOpenSearch,
113
+ deleteAppSecret: resourceAnswers.deleteAppSecret,
114
+ });
115
+ } else {
116
+ await aws.staticTerminate(slug, _.repoSafe, branch, answers.id);
117
+ }
87
118
  },
88
119
  ];
@@ -213,15 +213,35 @@ module.exports = [
213
213
  message: "What availability is required?",
214
214
  choices: ["low", "high"],
215
215
  },
216
+ region,
216
217
  {
217
218
  type: "confirm",
218
219
  name: "database",
219
220
  message: "Does this site need a database?",
220
221
  default: true,
221
222
  },
222
- region,
223
+ {
224
+ type: "confirm",
225
+ name: "storage",
226
+ message: "Does this site need S3 storage?",
227
+ default: true,
228
+ },
223
229
  ])),
224
230
  };
231
+
232
+ if (answers.framework === "laravel") {
233
+ answers = {
234
+ ...answers,
235
+ ...(await inquirer.prompt([
236
+ {
237
+ type: "confirm",
238
+ name: "opensearch",
239
+ message: "Does this site need OpenSearch?",
240
+ default: false,
241
+ },
242
+ ])),
243
+ };
244
+ }
225
245
  }
226
246
 
227
247
  answers = {
@@ -290,6 +310,225 @@ module.exports = [
290
310
  // Set AWS client defaults before any AWS operations
291
311
  setAWSClientDefaults(answers.client, answers.region);
292
312
 
313
+ // Pre-flight check for existing resources
314
+ let existingSecrets = {
315
+ app: null,
316
+ storage: null,
317
+ database: null,
318
+ opensearch: null,
319
+ };
320
+ let existingEnvironment = false;
321
+ let existingResources = {
322
+ storage: false,
323
+ database: false,
324
+ opensearch: false,
325
+ };
326
+ let snapshot = null;
327
+
328
+ if (answers.stack === "fullstack") {
329
+ const { existing } = await aws.fullstackPreflight(
330
+ slug,
331
+ _.repoSafe,
332
+ branch,
333
+ {
334
+ storage: answers.storage,
335
+ database: answers.database,
336
+ opensearch: answers.opensearch,
337
+ },
338
+ );
339
+
340
+ if (
341
+ existing.environment &&
342
+ existing.environment.Status !== "Ready"
343
+ ) {
344
+ console.log(
345
+ utilities.colorize(
346
+ `\n ✗ EB environment "${slug}" is currently ${existing.environment.Status}, cannot re-provision\n`,
347
+ "error",
348
+ ),
349
+ );
350
+ process.exit(1);
351
+ }
352
+
353
+ if (existing.environment) {
354
+ new utilities.Spinner(
355
+ "EB environment already exists, skipping creation",
356
+ true,
357
+ ).ora.info();
358
+ existingEnvironment = existing.environment;
359
+ }
360
+
361
+ if (existing.storage) {
362
+ new utilities.Spinner(
363
+ "S3 bucket already exists, skipping storage creation",
364
+ true,
365
+ ).ora.info();
366
+ existingResources.storage = true;
367
+ }
368
+
369
+ if (existing.database) {
370
+ new utilities.Spinner(
371
+ "RDS instance already exists, skipping database creation",
372
+ true,
373
+ ).ora.info();
374
+ existingResources.database = true;
375
+ }
376
+
377
+ if (existing.opensearch) {
378
+ new utilities.Spinner(
379
+ "OpenSearch domain already exists, skipping creation",
380
+ true,
381
+ ).ora.info();
382
+ existingResources.opensearch = true;
383
+ }
384
+
385
+ // Snapshot restore prompt — only if database is needed and doesn't already exist
386
+ if (answers.database && !existing.database) {
387
+ const { restore } = await inquirer.prompt([
388
+ {
389
+ type: "confirm",
390
+ name: "restore",
391
+ message: "Restore database from an existing snapshot?",
392
+ default: false,
393
+ },
394
+ ]);
395
+
396
+ if (restore) {
397
+ const snapshots = await aws.rds.listManualSnapshots();
398
+
399
+ if (snapshots.length) {
400
+ const { selected } = await inquirer.prompt([
401
+ {
402
+ type: "list",
403
+ name: "selected",
404
+ message: "Select a snapshot to restore from",
405
+ choices: snapshots.map((s) => ({
406
+ name: `${s.DBSnapshotIdentifier} (source: ${s.DBInstanceIdentifier}, created: ${new Date(s.SnapshotCreateTime).toLocaleDateString()})`,
407
+ value: s.DBSnapshotIdentifier,
408
+ })),
409
+ },
410
+ ]);
411
+
412
+ snapshot = selected;
413
+ } else {
414
+ new utilities.Spinner(
415
+ "No manual snapshots found, creating a fresh database",
416
+ true,
417
+ ).ora.info();
418
+ }
419
+ }
420
+ }
421
+
422
+ existingSecrets = existing.secrets;
423
+
424
+ if (existingSecrets.app) {
425
+ new utilities.Spinner(
426
+ "App secret already exists, reusing existing values",
427
+ true,
428
+ ).ora.info();
429
+ }
430
+
431
+ if (existingSecrets.storage) {
432
+ new utilities.Spinner(
433
+ "Storage secret already exists, reusing existing values",
434
+ true,
435
+ ).ora.info();
436
+ }
437
+
438
+ if (existingSecrets.database) {
439
+ new utilities.Spinner(
440
+ "Database secret already exists, reusing existing values",
441
+ true,
442
+ ).ora.info();
443
+ }
444
+
445
+ if (existingSecrets.opensearch) {
446
+ new utilities.Spinner(
447
+ "OpenSearch secret already exists, reusing existing values",
448
+ true,
449
+ ).ora.info();
450
+ }
451
+
452
+ // EB-specific prompts — only for new environments
453
+ if (!existingEnvironment && answers.availability === "high") {
454
+ answers = {
455
+ ...answers,
456
+ ...(await inquirer.prompt([
457
+ {
458
+ type: "list",
459
+ name: "elbScheme",
460
+ message: "Load balancer visibility?",
461
+ choices: ["public", "internal"],
462
+ },
463
+ ])),
464
+ };
465
+ }
466
+
467
+ // App name and domain prompts — only for new Laravel environments
468
+ if (!existingEnvironment && answers.framework === "laravel") {
469
+ answers = {
470
+ ...answers,
471
+ ...(await inquirer.prompt([
472
+ {
473
+ type: "input",
474
+ name: "appName",
475
+ message: "What is the app name?",
476
+ default: _.repoSafe,
477
+ validate: (v) =>
478
+ /^[a-zA-Z0-9 ]+$/.test(v) ||
479
+ "Alphanumeric characters and spaces only",
480
+ },
481
+ {
482
+ type: "input",
483
+ name: "appDomain",
484
+ message:
485
+ "What domain will this site be served at? (DNS must be configured manually)",
486
+ filter: (v) =>
487
+ v
488
+ .replace(/^https?:\/\//, "")
489
+ .replace(/\/+$/, ""),
490
+ validate: (v) =>
491
+ v.length > 0 || "Domain is required",
492
+ },
493
+ ])),
494
+ };
495
+
496
+ // ACM cert selection — only for high availability (ALB)
497
+ if (answers.availability === "high") {
498
+ const matchingCerts =
499
+ await aws.acm.findCertificatesForDomain(
500
+ answers.appDomain,
501
+ );
502
+
503
+ if (matchingCerts.length) {
504
+ answers = {
505
+ ...answers,
506
+ ...(await inquirer.prompt([
507
+ {
508
+ type: "list",
509
+ name: "certArn",
510
+ message:
511
+ "Which ACM certificate should be used for HTTPS?",
512
+ choices: [
513
+ ...matchingCerts.map((c) => ({
514
+ name: c.DomainName,
515
+ value: c.CertificateArn,
516
+ })),
517
+ { name: "None", value: null },
518
+ ],
519
+ },
520
+ ])),
521
+ };
522
+ } else {
523
+ new utilities.Spinner(
524
+ `No ACM certificates match ${answers.appDomain}, HTTPS listener must be configured manually`,
525
+ true,
526
+ ).ora.info();
527
+ }
528
+ }
529
+ }
530
+ }
531
+
293
532
  try {
294
533
  if (answers.stack === "static") {
295
534
  infastructure = await aws.static(
@@ -319,6 +558,16 @@ module.exports = [
319
558
  answers.framework,
320
559
  answers.availability,
321
560
  answers.database,
561
+ answers.storage,
562
+ answers.opensearch,
563
+ existingSecrets,
564
+ snapshot,
565
+ answers.elbScheme || "public",
566
+ existingEnvironment,
567
+ existingResources,
568
+ answers.appName,
569
+ answers.appDomain,
570
+ answers.certArn,
322
571
  );
323
572
  }
324
573
  } catch (e) {
@@ -0,0 +1,198 @@
1
+ const _ = require("../../../globals.js");
2
+ const inquirer = require("inquirer");
3
+ const aws = require("../services/aws/index.js");
4
+ const { setAWSClientDefaults } = require("../services/aws/misc.js");
5
+ const utilities = require("../libs/utilities");
6
+ const { client, region } = require("../libs/prompts.js");
7
+ const { secretGroups } = require("../libs/vars.js");
8
+
9
+ const SECRETS_NAMESPACE = "aws:elasticbeanstalk:application:environmentsecrets";
10
+
11
+ module.exports = [
12
+ ["sync-secrets", "syncs"],
13
+ _.config.preset === "devops"
14
+ ? "Sync Secrets Manager keys with EB environment secrets"
15
+ : false,
16
+ (yargs) => {
17
+ yargs.option("branch", {
18
+ alias: "b",
19
+ describe: "Branch to configure",
20
+ type: "string",
21
+ });
22
+ },
23
+ async (argv) => {
24
+ let branch = argv.branch || _.branch;
25
+
26
+ let answer = await inquirer.prompt([
27
+ {
28
+ type: "confirm",
29
+ name: "check",
30
+ message: `Syncing secrets for the ${utilities.colorize(branch, "success")} branch, is this correct?`,
31
+ default: "Y",
32
+ },
33
+ ]);
34
+
35
+ if (!answer.check) {
36
+ process.exit(1);
37
+ }
38
+
39
+ let answers = await inquirer.prompt([client, region]);
40
+
41
+ const slug = aws.slug(_.repoSafe, answers.client, branch, "eb");
42
+
43
+ setAWSClientDefaults(answers.client, answers.region);
44
+
45
+ // Verify EB environment exists
46
+ let appName;
47
+
48
+ try {
49
+ const { Environments } =
50
+ await aws.elasticbeanstalk.describeEnvironment(slug);
51
+ const active = Environments.filter(
52
+ (e) => e.Status !== "Terminated",
53
+ );
54
+ if (!active.length) {
55
+ console.log(
56
+ utilities.colorize(
57
+ `\n ✗ No active EB environment found for "${slug}"\n`,
58
+ "error",
59
+ ),
60
+ );
61
+ process.exit(1);
62
+ }
63
+ appName = active[0].ApplicationName;
64
+ } catch {
65
+ console.log(
66
+ utilities.colorize(
67
+ `\n ✗ No EB environment found for "${slug}"\n`,
68
+ "error",
69
+ ),
70
+ );
71
+ process.exit(1);
72
+ }
73
+
74
+ // Build expected environmentsecrets from Secrets Manager
75
+ const expectedSettings = [];
76
+ const groups = secretGroups;
77
+
78
+ for (const group of groups) {
79
+ const secretName = `${slug}-${group}`;
80
+
81
+ try {
82
+ const secret =
83
+ await aws.secretsmanager.describeSecret(secretName);
84
+ const secretValue =
85
+ await aws.secretsmanager.getSecretValue(secretName);
86
+ const keys = Object.keys(secretValue);
87
+
88
+ for (const key of keys) {
89
+ expectedSettings.push({
90
+ OptionName: key,
91
+ Value: `${secret.ARN}:${key}`,
92
+ Namespace: SECRETS_NAMESPACE,
93
+ });
94
+ }
95
+ } catch {
96
+ /* secret does not exist for this group */
97
+ }
98
+ }
99
+
100
+ // Get current EB configuration
101
+ const currentSettings =
102
+ await aws.elasticbeanstalk.describeConfigurationSettings(
103
+ appName,
104
+ slug,
105
+ );
106
+
107
+ const currentSecrets = currentSettings.filter(
108
+ (s) => s.Namespace === SECRETS_NAMESPACE,
109
+ );
110
+
111
+ // Diff: what to add and what to remove
112
+ const currentKeys = new Set(currentSecrets.map((s) => s.OptionName));
113
+ const expectedKeys = new Set(expectedSettings.map((s) => s.OptionName));
114
+
115
+ const toAdd = expectedSettings.filter(
116
+ (s) => !currentKeys.has(s.OptionName),
117
+ );
118
+ const toUpdate = expectedSettings.filter((s) => {
119
+ const current = currentSecrets.find(
120
+ (c) => c.OptionName === s.OptionName,
121
+ );
122
+ return current && current.Value !== s.Value;
123
+ });
124
+ const toRemove = currentSecrets
125
+ .filter((s) => !expectedKeys.has(s.OptionName))
126
+ .map((s) => ({
127
+ OptionName: s.OptionName,
128
+ Namespace: SECRETS_NAMESPACE,
129
+ }));
130
+
131
+ const optionSettings = [...toAdd, ...toUpdate];
132
+
133
+ if (!optionSettings.length && !toRemove.length) {
134
+ new utilities.Spinner(
135
+ "Environment secrets are in sync",
136
+ true,
137
+ ).ora.info();
138
+ return;
139
+ }
140
+
141
+ // Log summary
142
+ if (toAdd.length) {
143
+ console.log(
144
+ utilities.colorize(
145
+ `\n Adding ${toAdd.length} secret(s): ${toAdd.map((s) => s.OptionName).join(", ")}`,
146
+ "success",
147
+ ),
148
+ );
149
+ }
150
+
151
+ if (toUpdate.length) {
152
+ console.log(
153
+ utilities.colorize(
154
+ `\n Updating ${toUpdate.length} secret(s): ${toUpdate.map((s) => s.OptionName).join(", ")}`,
155
+ "info",
156
+ ),
157
+ );
158
+ }
159
+
160
+ if (toRemove.length) {
161
+ console.log(
162
+ utilities.colorize(
163
+ `\n Removing ${toRemove.length} secret(s): ${toRemove.map((s) => s.OptionName).join(", ")}`,
164
+ "warning",
165
+ ),
166
+ );
167
+ }
168
+
169
+ console.log("");
170
+
171
+ answer = await inquirer.prompt([
172
+ {
173
+ type: "confirm",
174
+ name: "proceed",
175
+ message: "Apply these changes?",
176
+ default: true,
177
+ },
178
+ ]);
179
+
180
+ if (!answer.proceed) {
181
+ process.exit(0);
182
+ }
183
+
184
+ await aws.elasticbeanstalk.updateEnvironmentSettings(
185
+ slug,
186
+ optionSettings,
187
+ toRemove,
188
+ );
189
+
190
+ console.log(
191
+ // eslint-disable-next-line prettier/prettier
192
+ utilities.colorize(
193
+ "\n ✔ Environment secrets synced\n",
194
+ "success",
195
+ ),
196
+ );
197
+ },
198
+ ];
@@ -122,6 +122,8 @@ module.exports.nameSafe = (name, service = "s3") => {
122
122
 
123
123
  if (service === "eb") {
124
124
  maxLength = 40;
125
+ } else if (service === "os") {
126
+ maxLength = 28;
125
127
  }
126
128
 
127
129
  safe = safe.substring(0, maxLength - suffix.length - prefix.length);