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

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 (37) hide show
  1. package/CHANGELOG.md +51 -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 +178 -98
  22. package/commands/create/services/aws/index.js +1202 -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/commands/scan.js +1 -1
  37. package/package.json +5 -2
@@ -0,0 +1,218 @@
1
+ const _ = require("../../../globals.js");
2
+ const inquirer = require("inquirer");
3
+ const aws = require("../services/aws/index.js");
4
+ const outputCredentials = require("../libs/output-credentials.js");
5
+ const { misc } = require("../libs/vars.js");
6
+ const {
7
+ setAWSClientDefaults,
8
+ } = require("../../../commands/create/services/aws/misc.js");
9
+ const parallelRunner = require("../libs/parallel-runner.js");
10
+ const resolveOperator = require("../libs/resolve-operator.js");
11
+
12
+ module.exports = [
13
+ "rekey",
14
+ _.config.preset === "devops"
15
+ ? "Rotates AWS access keys and credentials"
16
+ : false,
17
+ () => {},
18
+ async () => {
19
+ let operator = await resolveOperator();
20
+
21
+ let users = [];
22
+ let clients = [];
23
+ let sendEmail = false;
24
+
25
+ let answer = await inquirer.prompt([
26
+ {
27
+ type: "confirm",
28
+ name: "check",
29
+ message: `Rotate keys for all users`,
30
+ default: "Y",
31
+ },
32
+ ]);
33
+
34
+ if (answer.check) {
35
+ users = _.config.users.map((d) => d.username);
36
+ } else {
37
+ answer = await inquirer.prompt([
38
+ {
39
+ type: "checkbox",
40
+ name: "users",
41
+ message: "Select users",
42
+ choices: _.config.users.map((d) => d.username),
43
+ },
44
+ ]);
45
+
46
+ users = answer.users;
47
+ }
48
+
49
+ answer = await inquirer.prompt([
50
+ {
51
+ type: "confirm",
52
+ name: "check",
53
+ message: `Rotate keys for all clients`,
54
+ default: "Y",
55
+ },
56
+ ]);
57
+
58
+ if (answer.check) {
59
+ clients = aws.clients;
60
+ } else {
61
+ answer = await inquirer.prompt([
62
+ {
63
+ type: "checkbox",
64
+ name: "clients",
65
+ message: "Select clients",
66
+ choices: aws.clients,
67
+ },
68
+ ]);
69
+
70
+ clients = answer.clients;
71
+ }
72
+
73
+ answer = await inquirer.prompt([
74
+ {
75
+ type: "confirm",
76
+ name: "check",
77
+ message: `Would you like to email out the credentials after creation (requires office365 credentials in ~/targets/misc.json)?`,
78
+ default: "Y",
79
+ },
80
+ ]);
81
+
82
+ sendEmail = answer.check;
83
+
84
+ // Check for SES email credentials if user wants to send emails
85
+ if (sendEmail && !misc.nodemailer?.AWSSES?.username) {
86
+ answer = await inquirer.prompt([
87
+ {
88
+ type: "confirm",
89
+ name: "check",
90
+ message:
91
+ "No email credentials found, create them via AWS SES?",
92
+ default: "Y",
93
+ },
94
+ ]);
95
+
96
+ if (answer.check) {
97
+ try {
98
+ const creds = await aws.ses.ensureOperatorSESCredentials(
99
+ operator.username,
100
+ );
101
+
102
+ if (creds) {
103
+ aws.ses.updateMiscNodemailer(creds);
104
+ console.log(
105
+ "\n \u2713 SES email credentials created and saved to misc.json\n",
106
+ );
107
+ } else {
108
+ console.log(
109
+ "\n \u26a0 SES IAM user exists but keys were already created. Rotate with `fw rekey` to generate new credentials.\n",
110
+ );
111
+ }
112
+ } catch (err) {
113
+ console.log(
114
+ `\n \u26a0 Failed to create SES credentials: ${err.message}\n`,
115
+ );
116
+ }
117
+ }
118
+ }
119
+
120
+ // Separate non-operator users and operator
121
+ // Operator MUST be processed last per account — rotating the
122
+ // operator's key invalidates the credentials used for all API calls
123
+ let nonOperatorUsers = users.filter((u) => u !== operator.username);
124
+ let includeOperator = users.includes(operator.username);
125
+
126
+ let credentials = {};
127
+
128
+ // Process non-operator users in parallel
129
+ if (nonOperatorUsers.length) {
130
+ const batches = clients.map((client) => ({
131
+ users: nonOperatorUsers,
132
+ client,
133
+ fn: async (user) => {
134
+ setAWSClientDefaults(client);
135
+
136
+ await aws.iam.removeIAMUser(`fw-automation-${user}`);
137
+
138
+ return aws.iam.createFWIAMUser(
139
+ `fw-automation-${user}`,
140
+ _.config.users.find((d) => d.username === user)
141
+ .permissions,
142
+ );
143
+ },
144
+ }));
145
+
146
+ const resultsMap = await parallelRunner(batches, {
147
+ concurrency: 10,
148
+ verb: "Rotate credentials",
149
+ });
150
+
151
+ for (const [user, entries] of resultsMap) {
152
+ credentials[user] = {};
153
+
154
+ for (const { client, res } of entries) {
155
+ credentials[user][client] = {
156
+ key:
157
+ (res.AccessKey && res.AccessKey.AccessKeyId) ||
158
+ res.AccessKeyMetadata[0].AccessKeyId,
159
+ secret:
160
+ (res.AccessKey && res.AccessKey.SecretAccessKey) ||
161
+ "** secret **",
162
+ };
163
+ }
164
+ }
165
+ }
166
+
167
+ // Process operator sequentially — must be last per account
168
+ if (includeOperator) {
169
+ const operatorBatches = clients.map((client) => ({
170
+ users: [operator.username],
171
+ client,
172
+ fn: async (user) => {
173
+ setAWSClientDefaults(client);
174
+
175
+ return aws.iam.rotateFWIAMUser(
176
+ `fw-automation-${user}`,
177
+ operator.permissions,
178
+ );
179
+ },
180
+ }));
181
+
182
+ const operatorResults = await parallelRunner(operatorBatches, {
183
+ concurrency: 1,
184
+ verb: "Rotate operator credentials",
185
+ });
186
+
187
+ for (const [user, entries] of operatorResults) {
188
+ credentials[user] = {};
189
+
190
+ for (const { client, res } of entries) {
191
+ credentials[user][client] = {
192
+ key: res.AccessKey.AccessKeyId,
193
+ secret: res.AccessKey.SecretAccessKey,
194
+ };
195
+ }
196
+ }
197
+ }
198
+
199
+ // Rotate SES email credentials (after operator keys so fishawack creds are still valid)
200
+ if (misc.nodemailer?.AWSSES?.username) {
201
+ try {
202
+ const creds = await aws.ses.rotateOperatorSESCredentials(
203
+ operator.username,
204
+ );
205
+ aws.ses.updateMiscNodemailer(creds);
206
+ console.log(
207
+ "\n \u2713 SES email credentials rotated and saved to misc.json\n",
208
+ );
209
+ } catch (err) {
210
+ console.log(
211
+ `\n \u26a0 Failed to rotate SES credentials: ${err.message}\n`,
212
+ );
213
+ }
214
+ }
215
+
216
+ await outputCredentials(credentials, sendEmail);
217
+ },
218
+ ];
@@ -0,0 +1,59 @@
1
+ const _ = require("../../../globals.js");
2
+ const utilities = require("../libs/utilities");
3
+ const email = require("../services/email.js");
4
+ const { readFileSync } = require("fs");
5
+ const htmlEmail = readFileSync(`${__dirname}/../templates/credentials.html`, {
6
+ encoding: "utf8",
7
+ });
8
+
9
+ module.exports = async function outputCredentials(credentials, sendEmail) {
10
+ let output = "";
11
+
12
+ for (var user in credentials) {
13
+ output += utilities.colorize(`\n${user}\n`, "title");
14
+
15
+ let outputUser = "";
16
+ for (var client in credentials[user]) {
17
+ outputUser += `\n[${client}]\n`;
18
+ outputUser += `aws_access_key_id = ${credentials[user][client].key}\n`;
19
+ outputUser += `aws_secret_access_key = ${credentials[user][client].secret}\n`;
20
+ }
21
+ output += outputUser;
22
+
23
+ if (sendEmail) {
24
+ await email.send(
25
+ _.config.users.find((d) => d.username === user).email,
26
+ "New AWS Keys",
27
+ `${htmlEmail}<pre><code>${outputUser.replace(/\n/g, "<br>")}</code></pre>`,
28
+ );
29
+ }
30
+ }
31
+
32
+ console.log(output);
33
+
34
+ // Output base64-encoded credentials for the CI runner user
35
+ let runner =
36
+ _.config.users.find((d) => d.runner) ||
37
+ _.config.users.find((d) => d.username === "aws-runner");
38
+
39
+ if (runner && credentials[runner.username]) {
40
+ let runnerCreds = "";
41
+
42
+ for (var c in credentials[runner.username]) {
43
+ runnerCreds += `[${c}]\n`;
44
+ runnerCreds += `aws_access_key_id = ${credentials[runner.username][c].key}\n`;
45
+ runnerCreds += `aws_secret_access_key = ${credentials[runner.username][c].secret}\n\n`;
46
+ }
47
+
48
+ let base64 = Buffer.from(runnerCreds.trim()).toString("base64");
49
+
50
+ console.log(
51
+ utilities.colorize("\n CI Runner Base64 Credentials\n", "title"),
52
+ );
53
+ console.log(
54
+ " Use this base64 string as the CI variable for pipeline AWS authentication.\n",
55
+ );
56
+ console.log(base64);
57
+ console.log("");
58
+ }
59
+ };
@@ -0,0 +1,204 @@
1
+ const logUpdate = require("log-update");
2
+ const chalk = require("chalk");
3
+ const { Spinner } = require("./utilities");
4
+
5
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
6
+
7
+ function formatElapsed(ms) {
8
+ const secs = Math.floor(ms / 1000);
9
+ if (secs < 60) return `${secs}s`;
10
+ const mins = Math.floor(secs / 60);
11
+ return `${mins}m${secs % 60}s`;
12
+ }
13
+
14
+ /**
15
+ * Runs batches of tasks sequentially (one batch per client/account) with
16
+ * parallel execution within each batch. Users are processed in groups of
17
+ * `concurrency` — each group completes all batches before the next starts.
18
+ *
19
+ * @param {Array<{ users: string[], client: string, fn: (user: string) => Promise<any> }>} batches
20
+ * @param {{ concurrency?: number, verb?: string }} options
21
+ * @returns {Promise<Map<string, Array<{ client: string, res: any }>>>} results keyed by user
22
+ */
23
+ module.exports = async function parallelRunner(
24
+ batches,
25
+ { concurrency = 5, verb = "Processing" } = {},
26
+ ) {
27
+ const allUsers = [...new Set(batches.flatMap((b) => b.users))];
28
+ const totalBatches = batches.length;
29
+ const results = new Map(allUsers.map((u) => [u, []]));
30
+ const startTime = Date.now();
31
+ let frame = 0;
32
+ let throttleCount = 0;
33
+
34
+ // Silence Spinner.prototype.simple — track throttling via slow calls
35
+ const originalSimple = Spinner.prototype.simple;
36
+ Spinner.prototype.simple = async (_message, action) => {
37
+ const t0 = Date.now();
38
+ const res = await action();
39
+ // If a single API call took > 3s it likely hit a retry/backoff
40
+ if (Date.now() - t0 > 3000) {
41
+ throttleCount++;
42
+ }
43
+ return res;
44
+ };
45
+
46
+ // Chunk users into groups of `concurrency`
47
+ const groups = [];
48
+ for (let i = 0; i < allUsers.length; i += concurrency) {
49
+ groups.push(allUsers.slice(i, i + concurrency));
50
+ }
51
+
52
+ // Track state for users (populated as groups start)
53
+ const groupStates = new Map();
54
+ const errors = [];
55
+
56
+ // Render loop — header + one line per user
57
+ const render = setInterval(() => {
58
+ frame = (frame + 1) % SPINNER_FRAMES.length;
59
+ const elapsed = formatElapsed(Date.now() - startTime);
60
+
61
+ // Header line with timing and throttle info
62
+ let header = chalk.dim(` ${elapsed} elapsed`);
63
+ if (throttleCount > 0) {
64
+ header += chalk.yellow(
65
+ ` │ ⚠ ${throttleCount} throttled call${throttleCount > 1 ? "s" : ""} (retrying automatically)`,
66
+ );
67
+ }
68
+
69
+ const lines = [header];
70
+
71
+ for (const user of allUsers) {
72
+ const state = groupStates.get(user);
73
+
74
+ if (!state) {
75
+ lines.push(
76
+ chalk.dim(` - ${verb}: ${user} (0/${totalBatches})`),
77
+ );
78
+ continue;
79
+ }
80
+
81
+ if (state.status === "active") {
82
+ const taskElapsed = Date.now() - state.taskStart;
83
+ let suffix = "";
84
+ if (taskElapsed > 5000) {
85
+ suffix = chalk.yellow(` ${formatElapsed(taskElapsed)}`);
86
+ }
87
+ lines.push(
88
+ chalk.cyan(
89
+ ` ${SPINNER_FRAMES[frame]} ${verb}: ${user} - ${state.client} (${state.batchesDone + 1}/${totalBatches})`,
90
+ ) + suffix,
91
+ );
92
+ } else if (state.status === "error") {
93
+ lines.push(
94
+ chalk.red(
95
+ ` ✖ ${verb}: ${user} - ${state.client} (${state.batchesDone}/${totalBatches}) ${state.errorMsg}`,
96
+ ),
97
+ );
98
+ } else if (state.batchesDone === totalBatches) {
99
+ lines.push(
100
+ chalk.green(
101
+ ` ✓ ${verb}: ${user} (${totalBatches}/${totalBatches})`,
102
+ ),
103
+ );
104
+ } else {
105
+ lines.push(
106
+ chalk.dim(
107
+ ` - ${verb}: ${user} (${state.batchesDone}/${totalBatches})`,
108
+ ),
109
+ );
110
+ }
111
+ }
112
+
113
+ logUpdate(lines.join("\n"));
114
+ }, 80);
115
+
116
+ // Process each group to full completion before starting the next
117
+ for (const group of groups) {
118
+ for (const u of group) {
119
+ groupStates.set(u, {
120
+ client: "",
121
+ status: "pending",
122
+ batchesDone: 0,
123
+ taskStart: 0,
124
+ hasError: false,
125
+ errorMsg: "",
126
+ });
127
+ }
128
+
129
+ for (const batch of batches) {
130
+ const groupUsers = group.filter((u) => batch.users.includes(u));
131
+
132
+ await Promise.all(
133
+ groupUsers.map(async (user) => {
134
+ const state = groupStates.get(user);
135
+ state.client = batch.client;
136
+ state.status = "active";
137
+ state.taskStart = Date.now();
138
+
139
+ try {
140
+ const res = await batch.fn(user);
141
+ results.get(user).push({ client: batch.client, res });
142
+ state.status = "done";
143
+ } catch (err) {
144
+ state.status = "error";
145
+ state.hasError = true;
146
+ state.errorMsg = err.message || String(err);
147
+ errors.push({ user, client: batch.client, error: err });
148
+ } finally {
149
+ state.batchesDone++;
150
+ }
151
+ }),
152
+ );
153
+ }
154
+ }
155
+
156
+ clearInterval(render);
157
+
158
+ // Final render
159
+ const elapsed = formatElapsed(Date.now() - startTime);
160
+ let header = chalk.dim(` ${elapsed} elapsed`);
161
+ if (throttleCount > 0) {
162
+ header += chalk.yellow(
163
+ ` │ ⚠ ${throttleCount} throttled call${throttleCount > 1 ? "s" : ""}`,
164
+ );
165
+ }
166
+
167
+ const lines = [header];
168
+ for (const user of allUsers) {
169
+ const state = groupStates.get(user);
170
+ if (state.hasError) {
171
+ lines.push(
172
+ chalk.red(
173
+ ` ✖ ${verb}: ${user} (${state.batchesDone}/${totalBatches}) ${state.errorMsg}`,
174
+ ),
175
+ );
176
+ } else {
177
+ lines.push(
178
+ chalk.green(
179
+ ` ✓ ${verb}: ${user} (${totalBatches}/${totalBatches})`,
180
+ ),
181
+ );
182
+ }
183
+ }
184
+ logUpdate(lines.join("\n"));
185
+ logUpdate.done();
186
+
187
+ // Restore spinner
188
+ Spinner.prototype.simple = originalSimple;
189
+
190
+ if (errors.length) {
191
+ const summary = errors
192
+ .map(
193
+ (e) => `${e.user} (${e.client}): ${e.error.message || e.error}`,
194
+ )
195
+ .join("\n");
196
+ const err = new Error(
197
+ `${errors.length} task${errors.length > 1 ? "s" : ""} failed:\n${summary}`,
198
+ );
199
+ err.errors = errors;
200
+ throw err;
201
+ }
202
+
203
+ return results;
204
+ };
@@ -0,0 +1,59 @@
1
+ const _ = require("../../../globals.js");
2
+ const inquirer = require("inquirer");
3
+ const os = require("os");
4
+ const { writeFileSync } = require("fs");
5
+
6
+ module.exports = async function resolveOperator() {
7
+ let operators = _.config.users.filter((d) => d.operator);
8
+
9
+ if (operators.length === 1) {
10
+ return operators[0];
11
+ }
12
+
13
+ let candidates =
14
+ operators.length > 1
15
+ ? operators
16
+ : _.config.users.filter(
17
+ (d) =>
18
+ d.permissions && d.permissions.includes("manage-users"),
19
+ );
20
+
21
+ if (!candidates.length) {
22
+ throw new Error(
23
+ "No users with manage-users permission found. A user with IAMFullAccess is required to rotate keys.",
24
+ );
25
+ }
26
+
27
+ let message =
28
+ operators.length > 1
29
+ ? "Multiple operators found. Which user are you?"
30
+ : "No operator configured. Which user are you?";
31
+
32
+ let answer = await inquirer.prompt([
33
+ {
34
+ type: "list",
35
+ name: "operator",
36
+ message,
37
+ choices: candidates.map((d) => d.username),
38
+ },
39
+ ]);
40
+
41
+ _.config.users.forEach((d) => {
42
+ delete d.operator;
43
+ });
44
+
45
+ let selected = _.config.users.find((d) => d.username === answer.operator);
46
+ selected.operator = true;
47
+
48
+ writeFileSync(
49
+ `${os.homedir()}/.lab-env`,
50
+ JSON.stringify(_.config, null, 4),
51
+ { encoding: "utf8" },
52
+ );
53
+
54
+ console.log(
55
+ `\nOperator set to "${selected.username}" and saved to ~/.lab-env\n`,
56
+ );
57
+
58
+ return selected;
59
+ };
@@ -28,6 +28,7 @@ const colorize = (module.exports.colorize = (str, type) => {
28
28
  });
29
29
 
30
30
  let latestSpinner = null;
31
+ let spinnerQueue = Promise.resolve();
31
32
 
32
33
  module.exports.Spinner = class Spinner {
33
34
  constructor(startMessage, simple = false) {
@@ -67,31 +68,42 @@ module.exports.Spinner = class Spinner {
67
68
  }
68
69
 
69
70
  simple(message, action) {
71
+ const ready = spinnerQueue;
72
+ let release;
73
+ spinnerQueue = new Promise((r) => {
74
+ release = r;
75
+ });
76
+
70
77
  // eslint-disable-next-line no-async-promise-executor
71
78
  return new Promise(async (resolve, reject) => {
79
+ await ready;
80
+
72
81
  let instance = new this.constructor(message, true);
73
82
  let res;
74
83
 
75
84
  try {
76
- res = await action();
85
+ res = await action(instance);
77
86
 
78
87
  instance.ora.succeed();
79
88
  } catch (e) {
80
89
  instance.ora.fail();
90
+ release();
81
91
  reject(e);
92
+ return;
82
93
  }
83
94
 
95
+ release();
84
96
  resolve(res);
85
97
  });
86
98
  }
87
99
 
88
- ping() {
89
- latestSpinner.ora.color =
90
- latestSpinner.ora.color === "cyan" ? "magenta" : "cyan";
100
+ ping(spinner) {
101
+ const target = spinner || latestSpinner;
102
+ target.ora.color = target.ora.color === "cyan" ? "magenta" : "cyan";
91
103
  let message = " - check: ";
92
- let split = latestSpinner.ora.text.split(message);
104
+ let split = target.ora.text.split(message);
93
105
  let iteration = (split[1] && +split[1].split(":")[0]) || 0;
94
- latestSpinner.ora.text = `${split[0]}${message}${iteration + 1}`;
106
+ target.ora.text = `${split[0]}${message}${iteration + 1}`;
95
107
  }
96
108
  };
97
109
 
@@ -124,6 +136,8 @@ module.exports.nameSafe = (name, service = "s3") => {
124
136
  maxLength = 40;
125
137
  } else if (service === "os") {
126
138
  maxLength = 28;
139
+ } else if (service === "cache") {
140
+ maxLength = 40;
127
141
  }
128
142
 
129
143
  safe = safe.substring(0, maxLength - suffix.length - prefix.length);
@@ -146,13 +160,13 @@ module.exports.copyToClipboard = (text) => {
146
160
  }
147
161
  };
148
162
 
149
- module.exports.poll = async (cb, resolve, reject) => {
163
+ module.exports.poll = async (cb, resolve, reject, spinner) => {
150
164
  let res;
151
165
 
152
166
  do {
153
167
  await new Promise((resolve) => setTimeout(() => resolve(), 5000));
154
168
 
155
- await module.exports.Spinner.prototype.ping();
169
+ await module.exports.Spinner.prototype.ping(spinner);
156
170
 
157
171
  res = await cb();
158
172
 
@@ -163,3 +177,52 @@ module.exports.poll = async (cb, resolve, reject) => {
163
177
 
164
178
  return res;
165
179
  };
180
+
181
+ module.exports.pollAll = async (
182
+ services,
183
+ message = "Waiting for services to provision",
184
+ ) => {
185
+ const instance = new module.exports.Spinner(
186
+ `${message} - checking (${services[0].name})`,
187
+ true,
188
+ );
189
+
190
+ const results = new Array(services.length).fill(null);
191
+ const pending = services.map((s, i) => ({ ...s, index: i, checks: 0 }));
192
+ let cursor = 0;
193
+
194
+ while (pending.length > 0) {
195
+ const service = pending[cursor % pending.length];
196
+ service.checks++;
197
+
198
+ instance.ora.text = `${message} - checking (${service.name}) - check: ${service.checks}`;
199
+
200
+ await new Promise((r) => setTimeout(r, 5000));
201
+
202
+ instance.ora.color = instance.ora.color === "cyan" ? "magenta" : "cyan";
203
+
204
+ const res = await service.cb();
205
+
206
+ if (service.reject && service.reject(res)) {
207
+ instance.ora.fail();
208
+ throw new Error(
209
+ `Polling received reject response for ${service.name}`,
210
+ );
211
+ }
212
+
213
+ if (!service.shouldContinue(res)) {
214
+ results[service.index] = res;
215
+ pending.splice(cursor % pending.length, 1);
216
+
217
+ if (pending.length > 0) {
218
+ cursor = cursor % pending.length;
219
+ }
220
+ } else {
221
+ cursor++;
222
+ }
223
+ }
224
+
225
+ instance.ora.succeed();
226
+
227
+ return results;
228
+ };