@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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
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
+
3
19
  ### 5.7.0-beta.1 (2026-06-08)
4
20
 
5
21
  #### Features
@@ -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",
@@ -92,6 +92,12 @@ module.exports = [
92
92
  message: "Delete the RDS database?",
93
93
  default: false,
94
94
  },
95
+ {
96
+ type: "confirm",
97
+ name: "deleteOpenSearch",
98
+ message: "Delete the OpenSearch domain?",
99
+ default: false,
100
+ },
95
101
  {
96
102
  type: "confirm",
97
103
  name: "deleteAppSecret",
@@ -103,6 +109,7 @@ module.exports = [
103
109
  await aws.fullstackTerminate(slug, _.repoSafe, branch, {
104
110
  deleteStorage: resourceAnswers.deleteStorage,
105
111
  deleteDatabase: resourceAnswers.deleteDatabase,
112
+ deleteOpenSearch: resourceAnswers.deleteOpenSearch,
106
113
  deleteAppSecret: resourceAnswers.deleteAppSecret,
107
114
  });
108
115
  } else {
@@ -228,6 +228,20 @@ module.exports = [
228
228
  },
229
229
  ])),
230
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
+ }
231
245
  }
232
246
 
233
247
  answers = {
@@ -296,78 +310,60 @@ module.exports = [
296
310
  // Set AWS client defaults before any AWS operations
297
311
  setAWSClientDefaults(answers.client, answers.region);
298
312
 
299
- // Snapshot restore prompt — only if database requested, before preflight
300
- let snapshot = null;
301
-
302
- if (answers.database) {
303
- const { restore } = await inquirer.prompt([
304
- {
305
- type: "confirm",
306
- name: "restore",
307
- message: "Restore database from an existing snapshot?",
308
- default: false,
309
- },
310
- ]);
311
-
312
- if (restore) {
313
- const snapshots = await aws.rds.listManualSnapshots();
314
-
315
- if (snapshots.length) {
316
- const { selected } = await inquirer.prompt([
317
- {
318
- type: "list",
319
- name: "selected",
320
- message: "Select a snapshot to restore from",
321
- choices: snapshots.map((s) => ({
322
- name: `${s.DBSnapshotIdentifier} (source: ${s.DBInstanceIdentifier}, created: ${new Date(s.SnapshotCreateTime).toLocaleDateString()})`,
323
- value: s.DBSnapshotIdentifier,
324
- })),
325
- },
326
- ]);
327
-
328
- snapshot = selected;
329
- } else {
330
- new utilities.Spinner(
331
- "No manual snapshots found, creating a fresh database",
332
- true,
333
- ).ora.info();
334
- }
335
- }
336
- }
337
-
338
313
  // Pre-flight check for existing resources
339
- let existingSecrets = { app: null, storage: null, database: null };
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;
340
327
 
341
328
  if (answers.stack === "fullstack") {
342
- const { conflicts, existing } = await aws.fullstackPreflight(
329
+ const { existing } = await aws.fullstackPreflight(
343
330
  slug,
344
331
  _.repoSafe,
345
332
  branch,
346
333
  {
347
334
  storage: answers.storage,
348
335
  database: answers.database,
336
+ opensearch: answers.opensearch,
349
337
  },
350
338
  );
351
339
 
352
- if (conflicts.length) {
353
- conflicts.forEach((c) =>
354
- console.log(utilities.colorize(`\n ✗ ${c}`, "error")),
355
- );
340
+ if (
341
+ existing.environment &&
342
+ existing.environment.Status !== "Ready"
343
+ ) {
356
344
  console.log(
357
345
  utilities.colorize(
358
- "\nProvisioning aborted due to existing resources.\n",
346
+ `\n ✗ EB environment "${slug}" is currently ${existing.environment.Status}, cannot re-provision\n`,
359
347
  "error",
360
348
  ),
361
349
  );
362
350
  process.exit(1);
363
351
  }
364
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
+
365
361
  if (existing.storage) {
366
362
  new utilities.Spinner(
367
363
  "S3 bucket already exists, skipping storage creation",
368
364
  true,
369
365
  ).ora.info();
370
- answers.storage = false;
366
+ existingResources.storage = true;
371
367
  }
372
368
 
373
369
  if (existing.database) {
@@ -375,7 +371,52 @@ module.exports = [
375
371
  "RDS instance already exists, skipping database creation",
376
372
  true,
377
373
  ).ora.info();
378
- answers.database = false;
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
+ }
379
420
  }
380
421
 
381
422
  existingSecrets = existing.secrets;
@@ -400,6 +441,92 @@ module.exports = [
400
441
  true,
401
442
  ).ora.info();
402
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
+ }
403
530
  }
404
531
 
405
532
  try {
@@ -432,8 +559,15 @@ module.exports = [
432
559
  answers.availability,
433
560
  answers.database,
434
561
  answers.storage,
562
+ answers.opensearch,
435
563
  existingSecrets,
436
564
  snapshot,
565
+ answers.elbScheme || "public",
566
+ existingEnvironment,
567
+ existingResources,
568
+ answers.appName,
569
+ answers.appDomain,
570
+ answers.certArn,
437
571
  );
438
572
  }
439
573
  } catch (e) {
@@ -4,6 +4,7 @@ const aws = require("../services/aws/index.js");
4
4
  const { setAWSClientDefaults } = require("../services/aws/misc.js");
5
5
  const utilities = require("../libs/utilities");
6
6
  const { client, region } = require("../libs/prompts.js");
7
+ const { secretGroups } = require("../libs/vars.js");
7
8
 
8
9
  const SECRETS_NAMESPACE = "aws:elasticbeanstalk:application:environmentsecrets";
9
10
 
@@ -72,7 +73,7 @@ module.exports = [
72
73
 
73
74
  // Build expected environmentsecrets from Secrets Manager
74
75
  const expectedSettings = [];
75
- const groups = ["app", "storage", "database"];
76
+ const groups = secretGroups;
76
77
 
77
78
  for (const group of groups) {
78
79
  const secretName = `${slug}-${group}`;
@@ -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);
@@ -178,6 +178,8 @@ module.exports.templates = [
178
178
  },
179
179
  ];
180
180
 
181
+ module.exports.secretGroups = ["app", "storage", "database", "opensearch"];
182
+
181
183
  module.exports.frameworks = [
182
184
  {
183
185
  name: "wordpress",
@@ -207,50 +209,7 @@ module.exports.eb = {
207
209
  python: {
208
210
  shared: [],
209
211
  low: [],
210
- high: [
211
- {
212
- OptionName: "ELBScheme",
213
- Value: "internal",
214
- Namespace: "aws:ec2:vpc",
215
- },
216
- () => {
217
- const regions = {
218
- "us-east-1": {
219
- VPCId: "vpc-d30bcca8",
220
- Subnets:
221
- "subnet-00bf5eda896fdd5c5,subnet-0aa7e40d4701683ae",
222
- },
223
- "eu-west-1": {
224
- VPCId: "vpc-cc0d9aa9",
225
- Subnets:
226
- "subnet-0e357f06e047e1c10,subnet-0956ffb043535455d",
227
- },
228
- };
229
-
230
- return [
231
- {
232
- OptionName: "VPCId",
233
- Value: regions[process.env.AWS_REGION].VPCId,
234
- Namespace: "aws:ec2:vpc",
235
- },
236
- {
237
- OptionName: "Subnets",
238
- Value: regions[process.env.AWS_REGION].Subnets,
239
- Namespace: "aws:ec2:vpc",
240
- },
241
- {
242
- OptionName: "ELBSubnets",
243
- Value: regions[process.env.AWS_REGION].Subnets,
244
- Namespace: "aws:ec2:vpc",
245
- },
246
- ];
247
- },
248
- {
249
- OptionName: "AssociatePublicIpAddress",
250
- Value: "false",
251
- Namespace: "aws:ec2:vpc",
252
- },
253
- ],
212
+ high: [],
254
213
  },
255
214
  php: {
256
215
  shared: [],
@@ -304,11 +263,11 @@ module.exports.eb = {
304
263
  Value: "lab-env-aws-elasticbeanstalk-service-role",
305
264
  Namespace: "aws:elasticbeanstalk:environment",
306
265
  },
307
- {
266
+ ({ instanceProfileName }) => ({
308
267
  OptionName: "IamInstanceProfile",
309
- Value: "aws-elasticbeanstalk-ec2-role",
268
+ Value: instanceProfileName || "aws-elasticbeanstalk-ec2-role",
310
269
  Namespace: "aws:autoscaling:launchconfiguration",
311
- },
270
+ }),
312
271
  ],
313
272
  low: [
314
273
  {
@@ -358,6 +317,43 @@ module.exports.eb = {
358
317
  Value: "application",
359
318
  Namespace: "aws:elasticbeanstalk:environment",
360
319
  },
320
+ ({ vpcId, privateSubnetIds, publicSubnetIds, elbScheme }) => {
321
+ const settings = [
322
+ {
323
+ OptionName: "VPCId",
324
+ Value: vpcId,
325
+ Namespace: "aws:ec2:vpc",
326
+ },
327
+ {
328
+ OptionName: "Subnets",
329
+ Value: privateSubnetIds,
330
+ Namespace: "aws:ec2:vpc",
331
+ },
332
+ {
333
+ OptionName: "ELBSubnets",
334
+ Value:
335
+ elbScheme === "internal"
336
+ ? privateSubnetIds
337
+ : publicSubnetIds,
338
+ Namespace: "aws:ec2:vpc",
339
+ },
340
+ {
341
+ OptionName: "AssociatePublicIpAddress",
342
+ Value: "false",
343
+ Namespace: "aws:ec2:vpc",
344
+ },
345
+ ];
346
+
347
+ if (elbScheme === "internal") {
348
+ settings.push({
349
+ OptionName: "ELBScheme",
350
+ Value: "internal",
351
+ Namespace: "aws:ec2:vpc",
352
+ });
353
+ }
354
+
355
+ return settings;
356
+ },
361
357
  ],
362
358
  },
363
359
  secretPayloads: {
@@ -435,20 +431,24 @@ module.exports.eb = {
435
431
  laravel: {
436
432
  shared: {
437
433
  app: {
434
+ APP_NAME: "<%= APP_NAME %>",
438
435
  APP_ENV: "production",
439
436
  APP_KEY: `base64:${Buffer.from(generator.generate({ length: 32, numbers: true, symbols: true })).toString("base64")}`,
440
437
  APP_DEBUG: "false",
441
- APP_URL: "https://<%= DOMAIN_LINK %>",
438
+ APP_URL: "https://<%= APP_DOMAIN %>",
442
439
  },
443
440
  storage: ({ storage }) =>
444
441
  storage && {
445
442
  AWS_BUCKET: "<%= s3Slug %>",
446
443
  AWS_DEFAULT_REGION: "<%= AWS_REGION %>",
447
- AWS_ACCESS_KEY_ID: "<%= AccessKeyId %>",
448
- AWS_SECRET_ACCESS_KEY: "<%= SecretAccessKey %>",
444
+ AWS_ACL_VISIBILITY: "private",
449
445
  FILESYSTEM_DISK: "s3",
450
446
  FILESYSTEM_DISK_PUBLIC: "s3-public",
451
447
  },
448
+ opensearch: ({ opensearch }) =>
449
+ opensearch && {
450
+ SEARCH_HOST: "<%= SEARCH_HOST %>",
451
+ },
452
452
  },
453
453
  high: {
454
454
  app: {
@@ -488,7 +488,7 @@ module.exports.eb = {
488
488
  },
489
489
  buildSecretPayloads(config, data = {}) {
490
490
  const { framework, availability = "low", platform, language } = config;
491
- const result = { app: {}, storage: {}, database: {} };
491
+ const result = { app: {}, storage: {}, database: {}, opensearch: {} };
492
492
 
493
493
  const sources = [
494
494
  this.secretPayloads.shared,
@@ -503,7 +503,7 @@ module.exports.eb = {
503
503
 
504
504
  for (const source of sources) {
505
505
  if (!source) continue;
506
- for (const group of ["app", "storage", "database"]) {
506
+ for (const group of module.exports.secretGroups) {
507
507
  let values = source[group];
508
508
  if (typeof values === "function") {
509
509
  values = values(config, data);
@@ -516,7 +516,7 @@ module.exports.eb = {
516
516
 
517
517
  // Apply lodash template interpolation
518
518
  const templateData = { ...process.env, ...data };
519
- for (const group of ["app", "storage", "database"]) {
519
+ for (const group of module.exports.secretGroups) {
520
520
  if (Object.keys(result[group]).length) {
521
521
  result[group] = JSON.parse(
522
522
  template(JSON.stringify(result[group]))(templateData),
@@ -0,0 +1,59 @@
1
+ const { ACMClient, ListCertificatesCommand } = require("@aws-sdk/client-acm");
2
+ const { Spinner } = require("../../libs/utilities");
3
+
4
+ module.exports.listCertificates = async () => {
5
+ const client = new ACMClient({});
6
+ const certs = [];
7
+ let nextToken;
8
+
9
+ do {
10
+ const res = await client.send(
11
+ new ListCertificatesCommand({
12
+ CertificateStatuses: ["ISSUED"],
13
+ NextToken: nextToken,
14
+ }),
15
+ );
16
+ certs.push(...(res.CertificateSummaryList || []));
17
+ nextToken = res.NextToken;
18
+ } while (nextToken);
19
+
20
+ return certs;
21
+ };
22
+
23
+ module.exports.findCertificatesForDomain = async (domain) => {
24
+ const certs = await Spinner.prototype.simple(
25
+ `Looking up ACM certificates for ${domain}`,
26
+ () => module.exports.listCertificates(),
27
+ );
28
+
29
+ if (!certs.length) return [];
30
+
31
+ // Extract apex domain (e.g. "test.avh-det.com" → "avh-det.com")
32
+ const parts = domain.split(".");
33
+ const apex = parts.length > 2 ? parts.slice(-2).join(".") : domain;
34
+ const wildcard = `*.${apex}`;
35
+
36
+ // Check all cert domains (DomainName + SANs)
37
+ const getCertDomains = (cert) => {
38
+ const domains = [cert.DomainName];
39
+ if (cert.SubjectAlternativeNameSummaries) {
40
+ domains.push(...cert.SubjectAlternativeNameSummaries);
41
+ }
42
+ return domains;
43
+ };
44
+
45
+ const candidates = [domain, wildcard];
46
+ if (apex !== domain) candidates.push(apex);
47
+
48
+ const matches = certs.filter((c) =>
49
+ getCertDomains(c).some((d) => candidates.includes(d)),
50
+ );
51
+
52
+ // Deduplicate by ARN
53
+ const seen = new Set();
54
+ return matches.filter((c) => {
55
+ if (seen.has(c.CertificateArn)) return false;
56
+ seen.add(c.CertificateArn);
57
+ return true;
58
+ });
59
+ };