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

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.
Files changed (36) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/_Test/provision.js +1 -8
  3. package/_Test/prune.js +8 -5
  4. package/bitbucket-pipelines.yml +2 -2
  5. package/cli.js +1 -0
  6. package/commands/create/cmds/dekey.js +13 -9
  7. package/commands/create/cmds/deprovision.js +29 -0
  8. package/commands/create/cmds/key.js +60 -41
  9. package/commands/create/cmds/provision.js +425 -55
  10. package/commands/create/cmds/rekey.js +218 -0
  11. package/commands/create/libs/output-credentials.js +59 -0
  12. package/commands/create/libs/parallel-runner.js +204 -0
  13. package/commands/create/libs/resolve-operator.js +59 -0
  14. package/commands/create/libs/utilities.js +71 -8
  15. package/commands/create/libs/vars.js +70 -86
  16. package/commands/create/services/aws/acm.js +4 -2
  17. package/commands/create/services/aws/cloudfront.js +51 -45
  18. package/commands/create/services/aws/ec2.js +132 -53
  19. package/commands/create/services/aws/elasticache.js +159 -0
  20. package/commands/create/services/aws/elasticbeanstalk.js +141 -60
  21. package/commands/create/services/aws/iam.js +159 -93
  22. package/commands/create/services/aws/index.js +1209 -466
  23. package/commands/create/services/aws/opensearch.js +25 -17
  24. package/commands/create/services/aws/rds.js +22 -34
  25. package/commands/create/services/aws/s3.js +14 -27
  26. package/commands/create/services/aws/secretsmanager.js +8 -15
  27. package/commands/create/services/aws/ses.js +195 -0
  28. package/commands/create/templates/elasticbeanstalk/.ebextensions/laravel/cron.config +45 -0
  29. package/commands/create/templates/elasticbeanstalk/.ebextensions/laravel/queue-worker.config +29 -0
  30. package/commands/create/templates/elasticbeanstalk/.ebextensions/laravel/software.config +12 -0
  31. package/commands/create/templates/elasticbeanstalk/.ebextensions/misc/setvars.config +2 -16
  32. package/commands/create/templates/elasticbeanstalk/.platform/confighooks/postdeploy/rewrite-flush.sh +1 -1
  33. package/commands/create/templates/elasticbeanstalk/.platform/confighooks/postdeploy/setvars.sh +19 -0
  34. package/commands/create/templates/elasticbeanstalk/.platform/hooks/postdeploy/restart_queue.sh +4 -0
  35. package/commands/create/templates/elasticbeanstalk/.platform/hooks/prebuild/setvars.sh +19 -0
  36. package/package.json +5 -2
@@ -18,6 +18,11 @@ module.exports = [
18
18
  describe: "Branch to configure",
19
19
  type: "string",
20
20
  });
21
+ yargs.option("green-blue", {
22
+ alias: "g",
23
+ describe: "Clone an existing environment (pass source branch name)",
24
+ type: "string",
25
+ });
21
26
  },
22
27
  async (argv) => {
23
28
  let branch = argv.branch || _.branch;
@@ -38,6 +43,190 @@ module.exports = [
38
43
  process.exit(1);
39
44
  }
40
45
 
46
+ // Green/Blue clone flow — entirely separate path
47
+ if (argv.greenBlue) {
48
+ let gbAnswers = await inquirer.prompt([client, region]);
49
+
50
+ setAWSClientDefaults(gbAnswers.client, gbAnswers.region);
51
+
52
+ // List existing EB applications
53
+ const applications = await aws.elasticbeanstalk.listApplications();
54
+
55
+ if (!applications.length) {
56
+ console.log(
57
+ utilities.colorize(
58
+ "\n ✗ No EB applications found in this account/region\n",
59
+ "error",
60
+ ),
61
+ );
62
+ process.exit(1);
63
+ }
64
+
65
+ // List all environments across applications
66
+ let allEnvironments = [];
67
+ for (const app of applications) {
68
+ const envs = await aws.elasticbeanstalk.listEnvironments(
69
+ app.ApplicationName,
70
+ );
71
+ allEnvironments = allEnvironments.concat(
72
+ envs.map((e) => ({
73
+ ...e,
74
+ ApplicationName: app.ApplicationName,
75
+ })),
76
+ );
77
+ }
78
+
79
+ if (!allEnvironments.length) {
80
+ console.log(
81
+ utilities.colorize(
82
+ "\n ✗ No active EB environments found\n",
83
+ "error",
84
+ ),
85
+ );
86
+ process.exit(1);
87
+ }
88
+
89
+ const { sourceEnv } = await inquirer.prompt([
90
+ {
91
+ type: "list",
92
+ name: "sourceEnv",
93
+ message: "Which environment should be cloned?",
94
+ choices: allEnvironments.map((e) => ({
95
+ name: `${e.EnvironmentName} (${e.Status})`,
96
+ value: e,
97
+ })),
98
+ },
99
+ ]);
100
+
101
+ // Validate branch doesn't conflict with source
102
+ const sourceSlug = sourceEnv.EnvironmentName;
103
+ const newSlug = aws.slug(
104
+ _.repoSafe,
105
+ gbAnswers.client,
106
+ branch,
107
+ "eb",
108
+ );
109
+
110
+ if (newSlug === sourceSlug) {
111
+ const { newBranch } = await inquirer.prompt([
112
+ {
113
+ type: "input",
114
+ name: "newBranch",
115
+ message:
116
+ "Branch conflicts with source. Enter a different branch name:",
117
+ default: `${branch}-green`,
118
+ validate: (input) => {
119
+ const slug = aws.slug(
120
+ _.repoSafe,
121
+ gbAnswers.client,
122
+ input,
123
+ "eb",
124
+ );
125
+ if (slug === sourceSlug) {
126
+ return "Branch still conflicts with source environment";
127
+ }
128
+ return true;
129
+ },
130
+ },
131
+ ]);
132
+ branch = newBranch;
133
+ }
134
+
135
+ const slug = aws.slug(_.repoSafe, gbAnswers.client, branch, "eb");
136
+
137
+ // Check the clone doesn't already exist
138
+ const { Environments } =
139
+ await aws.elasticbeanstalk.describeEnvironment(slug);
140
+ const active = (Environments || []).filter(
141
+ (e) => e.Status !== "Terminated",
142
+ );
143
+ if (active.length) {
144
+ console.log(
145
+ utilities.colorize(
146
+ `\n ✗ Environment "${slug}" already exists (${active[0].Status})\n`,
147
+ "error",
148
+ ),
149
+ );
150
+ process.exit(1);
151
+ }
152
+
153
+ const environmentChoices = ["production", "staging", "test"];
154
+ const defaultEnvironment =
155
+ environmentChoices.find((e) =>
156
+ branch.toLowerCase().includes(e),
157
+ ) || "test";
158
+
159
+ // Get environment tag from source
160
+ const sourceTags = await aws.elasticbeanstalk.getEnvironmentTags(
161
+ sourceEnv.EnvironmentArn,
162
+ );
163
+ const sourceEnvTag = sourceTags.find(
164
+ (t) => t.Key === "environment",
165
+ );
166
+ const envTagValue = sourceEnvTag
167
+ ? sourceEnvTag.Value
168
+ : defaultEnvironment;
169
+
170
+ const tags = [
171
+ { Key: "repository", Value: _.repo },
172
+ { Key: "environment", Value: envTagValue },
173
+ { Key: "branch", Value: branch },
174
+ { Key: "automated", Value: "true" },
175
+ { Key: "cloned-from", Value: sourceEnv.EnvironmentName },
176
+ ];
177
+
178
+ try {
179
+ const infastructure = await aws.fullstackClone(
180
+ slug,
181
+ tags,
182
+ _.repoSafe,
183
+ branch,
184
+ sourceEnv.ApplicationName,
185
+ sourceEnv.EnvironmentName,
186
+ sourceEnv.VersionLabel,
187
+ argv.greenBlue,
188
+ );
189
+
190
+ const config = {};
191
+ config[branch] = {
192
+ deploy: {
193
+ url: infastructure.url,
194
+ location: "/var/www/html",
195
+ "aws-eb": infastructure.environment,
196
+ environment: envTagValue,
197
+ paths: infastructure.paths,
198
+ "cloned-from": sourceEnv.EnvironmentName,
199
+ },
200
+ };
201
+
202
+ if (infastructure.copy) {
203
+ config[branch].copy = infastructure.copy;
204
+ }
205
+
206
+ let stringify = JSON.stringify(config, null, 4);
207
+ let output = stringify
208
+ .substring(1, stringify.length - 1)
209
+ .trim();
210
+ utilities.copyToClipboard(output);
211
+ console.log(
212
+ utilities.colorize(
213
+ `\n${output}\n\n(copied to clipboard)`,
214
+ "title",
215
+ ),
216
+ );
217
+ } catch (e) {
218
+ console.log(e.message);
219
+ process.exit(1);
220
+ }
221
+
222
+ return;
223
+ }
224
+
225
+ const environmentChoices = ["production", "staging", "test"];
226
+ const defaultEnvironment =
227
+ environmentChoices.find((e) => branch.toLowerCase().includes(e)) ||
228
+ "test";
229
+
41
230
  let answers = await inquirer.prompt([stack]);
42
231
 
43
232
  // Prompt for redirects if static stack and no existing redirects
@@ -207,12 +396,6 @@ module.exports = [
207
396
  value: "default",
208
397
  }),
209
398
  },
210
- {
211
- type: "list",
212
- name: "availability",
213
- message: "What availability is required?",
214
- choices: ["low", "high"],
215
- },
216
399
  region,
217
400
  {
218
401
  type: "confirm",
@@ -239,6 +422,18 @@ module.exports = [
239
422
  message: "Does this site need OpenSearch?",
240
423
  default: false,
241
424
  },
425
+ {
426
+ type: "confirm",
427
+ name: "cache",
428
+ message: "Does this site need cache (Valkey)?",
429
+ default: false,
430
+ },
431
+ {
432
+ type: "confirm",
433
+ name: "mail",
434
+ message: "Does this site need email (SES)?",
435
+ default: false,
436
+ },
242
437
  ])),
243
438
  };
244
439
  }
@@ -246,15 +441,19 @@ module.exports = [
246
441
 
247
442
  answers = {
248
443
  ...answers,
249
- ...(await inquirer.prompt([
250
- client,
251
- {
252
- type: "confirm",
253
- name: "protected",
254
- message: "Should the site be password protected?",
255
- default: true,
256
- },
257
- ])),
444
+ ...(await inquirer.prompt(
445
+ answers.stack === "static"
446
+ ? [
447
+ client,
448
+ {
449
+ type: "confirm",
450
+ name: "protected",
451
+ message: "Should the site be password protected?",
452
+ default: true,
453
+ },
454
+ ]
455
+ : [client],
456
+ )),
258
457
  };
259
458
 
260
459
  let credentials = [];
@@ -316,12 +515,15 @@ module.exports = [
316
515
  storage: null,
317
516
  database: null,
318
517
  opensearch: null,
518
+ cache: null,
519
+ mail: null,
319
520
  };
320
521
  let existingEnvironment = false;
321
522
  let existingResources = {
322
523
  storage: false,
323
524
  database: false,
324
525
  opensearch: false,
526
+ cache: false,
325
527
  };
326
528
  let snapshot = null;
327
529
 
@@ -334,6 +536,7 @@ module.exports = [
334
536
  storage: answers.storage,
335
537
  database: answers.database,
336
538
  opensearch: answers.opensearch,
539
+ cache: answers.cache,
337
540
  },
338
541
  );
339
542
 
@@ -382,6 +585,14 @@ module.exports = [
382
585
  existingResources.opensearch = true;
383
586
  }
384
587
 
588
+ if (existing.cache) {
589
+ new utilities.Spinner(
590
+ "ElastiCache cluster already exists, skipping creation",
591
+ true,
592
+ ).ora.info();
593
+ existingResources.cache = true;
594
+ }
595
+
385
596
  // Snapshot restore prompt — only if database is needed and doesn't already exist
386
597
  if (answers.database && !existing.database) {
387
598
  const { restore } = await inquirer.prompt([
@@ -449,8 +660,144 @@ module.exports = [
449
660
  ).ora.info();
450
661
  }
451
662
 
663
+ if (existingSecrets.cache) {
664
+ new utilities.Spinner(
665
+ "Cache secret already exists, reusing existing values",
666
+ true,
667
+ ).ora.info();
668
+ }
669
+
670
+ if (existingSecrets.mail) {
671
+ new utilities.Spinner(
672
+ "Mail secret already exists, reusing existing values",
673
+ true,
674
+ ).ora.info();
675
+ }
676
+
677
+ // SES detail prompts — only if mail requested and not already configured
678
+ if (answers.mail && !existingSecrets.mail) {
679
+ const identities = await aws.ses.listVerifiedIdentities();
680
+
681
+ if (!identities.length) {
682
+ new utilities.Spinner(
683
+ "No verified SES identities found, skipping email setup",
684
+ true,
685
+ ).ora.warn();
686
+ answers.mail = false;
687
+ } else {
688
+ const defaultIdentity = answers.appDomain
689
+ ? identities.find(
690
+ (id) =>
691
+ answers.appDomain.endsWith(id) ||
692
+ id === answers.appDomain,
693
+ ) || identities[0]
694
+ : identities[0];
695
+
696
+ const { sesIdentity } = await inquirer.prompt([
697
+ {
698
+ type: "list",
699
+ name: "sesIdentity",
700
+ message:
701
+ "Which verified SES domain should be used?",
702
+ choices: identities,
703
+ default: defaultIdentity,
704
+ },
705
+ ]);
706
+
707
+ const { mailFromAddress } = await inquirer.prompt([
708
+ {
709
+ type: "input",
710
+ name: "mailFromAddress",
711
+ message: "Mail from address:",
712
+ default: `noreply@${sesIdentity}`,
713
+ validate: (input) => {
714
+ if (!input.includes("@")) {
715
+ return "Must be a valid email address";
716
+ }
717
+ const domain = input.split("@")[1];
718
+ if (
719
+ domain === sesIdentity ||
720
+ domain.endsWith(`.${sesIdentity}`)
721
+ ) {
722
+ return true;
723
+ }
724
+ return `Address must be under the verified identity ${sesIdentity}`;
725
+ },
726
+ },
727
+ ]);
728
+
729
+ const { mailFromName } = await inquirer.prompt([
730
+ {
731
+ type: "input",
732
+ name: "mailFromName",
733
+ message: "Mail from name:",
734
+ default:
735
+ answers.appName ||
736
+ _.repoSafe
737
+ .replace(/-/g, " ")
738
+ .replace(/^./, (c) => c.toUpperCase()),
739
+ validate: (input) =>
740
+ !!input.length || "From name is required",
741
+ },
742
+ ]);
743
+
744
+ answers.mailData = {
745
+ fromAddress: mailFromAddress,
746
+ fromName: mailFromName,
747
+ };
748
+ }
749
+ }
750
+
751
+ // Resolve environment tag
752
+ if (existingEnvironment) {
753
+ const envTags = await aws.elasticbeanstalk.getEnvironmentTags(
754
+ existingEnvironment.EnvironmentArn,
755
+ );
756
+ const envTag = envTags.find((t) => t.Key === "environment");
757
+ answers.environment = envTag
758
+ ? envTag.Value
759
+ : defaultEnvironment;
760
+ new utilities.Spinner(
761
+ `Using existing environment tag: ${answers.environment}`,
762
+ true,
763
+ ).ora.info();
764
+ } else {
765
+ answers = {
766
+ ...answers,
767
+ ...(await inquirer.prompt([
768
+ {
769
+ type: "list",
770
+ name: "environment",
771
+ message: "What environment is this?",
772
+ choices: environmentChoices,
773
+ default: defaultEnvironment,
774
+ },
775
+ ])),
776
+ };
777
+ }
778
+
452
779
  // EB-specific prompts — only for new environments
453
- if (!existingEnvironment && answers.availability === "high") {
780
+ if (!existingEnvironment) {
781
+ answers = {
782
+ ...answers,
783
+ ...(await inquirer.prompt([
784
+ {
785
+ type: "list",
786
+ name: "instanceType",
787
+ message: "What instance size is required?",
788
+ choices: [
789
+ { name: "Small", value: "t3.small" },
790
+ { name: "Medium", value: "t3.medium" },
791
+ { name: "Large", value: "t3.large" },
792
+ { name: "XLarge", value: "t3.xlarge" },
793
+ ],
794
+ default: "t3.small",
795
+ },
796
+ ])),
797
+ };
798
+ }
799
+
800
+ if (!existingEnvironment) {
454
801
  answers = {
455
802
  ...answers,
456
803
  ...(await inquirer.prompt([
@@ -473,7 +820,9 @@ module.exports = [
473
820
  type: "input",
474
821
  name: "appName",
475
822
  message: "What is the app name?",
476
- default: _.repoSafe,
823
+ default: _.repoSafe
824
+ .replace(/-/g, " ")
825
+ .replace(/^./, (c) => c.toUpperCase()),
477
826
  validate: (v) =>
478
827
  /^[a-zA-Z0-9 ]+$/.test(v) ||
479
828
  "Alphanumeric characters and spaces only",
@@ -493,50 +842,62 @@ module.exports = [
493
842
  ])),
494
843
  };
495
844
 
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
- }
845
+ const matchingCerts = await aws.acm.findCertificatesForDomain(
846
+ answers.appDomain,
847
+ );
848
+
849
+ if (matchingCerts.length) {
850
+ answers = {
851
+ ...answers,
852
+ ...(await inquirer.prompt([
853
+ {
854
+ type: "list",
855
+ name: "certArn",
856
+ message:
857
+ "Which ACM certificate should be used for HTTPS?",
858
+ choices: [
859
+ ...matchingCerts.map((c) => ({
860
+ name: c.DomainName,
861
+ value: c.CertificateArn,
862
+ })),
863
+ { name: "None", value: null },
864
+ ],
865
+ },
866
+ ])),
867
+ };
868
+ } else {
869
+ new utilities.Spinner(
870
+ `No ACM certificates match ${answers.appDomain}, HTTPS listener must be configured manually`,
871
+ true,
872
+ ).ora.info();
528
873
  }
529
874
  }
530
875
  }
531
876
 
877
+ if (answers.stack === "static") {
878
+ answers = {
879
+ ...answers,
880
+ ...(await inquirer.prompt([
881
+ {
882
+ type: "list",
883
+ name: "environment",
884
+ message: "What environment is this?",
885
+ choices: environmentChoices,
886
+ default: defaultEnvironment,
887
+ },
888
+ ])),
889
+ };
890
+ }
891
+
532
892
  try {
533
893
  if (answers.stack === "static") {
534
894
  infastructure = await aws.static(
535
895
  slug,
536
896
  [
537
897
  { Key: "repository", Value: _.repo },
538
- { Key: "environment", Value: branch },
539
- { Key: "automated", Value: true },
898
+ { Key: "environment", Value: answers.environment },
899
+ { Key: "branch", Value: branch },
900
+ { Key: "automated", Value: "true" },
540
901
  ],
541
902
  credentials,
542
903
  _.repoSafe,
@@ -549,17 +910,18 @@ module.exports = [
549
910
  slug,
550
911
  [
551
912
  { Key: "repository", Value: _.repo },
552
- { Key: "environment", Value: branch },
553
- { Key: "automated", Value: true },
913
+ { Key: "environment", Value: answers.environment },
914
+ { Key: "branch", Value: branch },
915
+ { Key: "automated", Value: "true" },
554
916
  ],
555
- credentials,
556
917
  _.repoSafe,
557
918
  branch,
558
919
  answers.framework,
559
- answers.availability,
560
920
  answers.database,
561
921
  answers.storage,
562
922
  answers.opensearch,
923
+ answers.cache,
924
+ answers.mail,
563
925
  existingSecrets,
564
926
  snapshot,
565
927
  answers.elbScheme || "public",
@@ -568,6 +930,8 @@ module.exports = [
568
930
  answers.appName,
569
931
  answers.appDomain,
570
932
  answers.certArn,
933
+ answers.instanceType,
934
+ answers.mailData,
571
935
  );
572
936
  }
573
937
  } catch (e) {
@@ -582,16 +946,22 @@ module.exports = [
582
946
  location: infastructure.bucket.slice(1), // Remove / from start
583
947
  "aws-s3": answers.client,
584
948
  "aws-cloudfront": infastructure.cloudfront,
949
+ environment: answers.environment,
585
950
  }
586
951
  : {
587
952
  url: infastructure.url,
588
953
  location: "/var/www/html",
589
954
  "aws-eb": infastructure.environment,
955
+ environment: answers.environment,
590
956
  paths: infastructure.paths,
591
957
  };
592
958
 
593
959
  config[branch] = { deploy };
594
960
 
961
+ if (infastructure.copy) {
962
+ config[branch].copy = infastructure.copy;
963
+ }
964
+
595
965
  if (credentials.length) {
596
966
  config[branch].deploy.users = credentials;
597
967
  }