@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.
- package/CHANGELOG.md +51 -0
- package/_Test/provision.js +1 -8
- package/_Test/prune.js +8 -5
- package/bitbucket-pipelines.yml +2 -2
- package/cli.js +1 -0
- package/commands/create/cmds/dekey.js +13 -9
- package/commands/create/cmds/deprovision.js +29 -0
- package/commands/create/cmds/key.js +60 -41
- package/commands/create/cmds/provision.js +425 -55
- package/commands/create/cmds/rekey.js +218 -0
- package/commands/create/libs/output-credentials.js +59 -0
- package/commands/create/libs/parallel-runner.js +204 -0
- package/commands/create/libs/resolve-operator.js +59 -0
- package/commands/create/libs/utilities.js +71 -8
- package/commands/create/libs/vars.js +70 -86
- package/commands/create/services/aws/acm.js +4 -2
- package/commands/create/services/aws/cloudfront.js +51 -45
- package/commands/create/services/aws/ec2.js +132 -53
- package/commands/create/services/aws/elasticache.js +159 -0
- package/commands/create/services/aws/elasticbeanstalk.js +141 -60
- package/commands/create/services/aws/iam.js +178 -98
- package/commands/create/services/aws/index.js +1202 -466
- package/commands/create/services/aws/opensearch.js +25 -17
- package/commands/create/services/aws/rds.js +22 -34
- package/commands/create/services/aws/s3.js +14 -27
- package/commands/create/services/aws/secretsmanager.js +8 -15
- package/commands/create/services/aws/ses.js +195 -0
- package/commands/create/templates/elasticbeanstalk/.ebextensions/laravel/cron.config +45 -0
- package/commands/create/templates/elasticbeanstalk/.ebextensions/laravel/queue-worker.config +29 -0
- package/commands/create/templates/elasticbeanstalk/.ebextensions/laravel/software.config +12 -0
- package/commands/create/templates/elasticbeanstalk/.ebextensions/misc/setvars.config +2 -16
- package/commands/create/templates/elasticbeanstalk/.platform/confighooks/postdeploy/rewrite-flush.sh +1 -1
- package/commands/create/templates/elasticbeanstalk/.platform/confighooks/postdeploy/setvars.sh +19 -0
- package/commands/create/templates/elasticbeanstalk/.platform/hooks/postdeploy/restart_queue.sh +4 -0
- package/commands/create/templates/elasticbeanstalk/.platform/hooks/prebuild/setvars.sh +19 -0
- package/commands/scan.js +1 -1
- 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
|
-
|
|
90
|
-
|
|
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 =
|
|
104
|
+
let split = target.ora.text.split(message);
|
|
93
105
|
let iteration = (split[1] && +split[1].split(":")[0]) || 0;
|
|
94
|
-
|
|
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
|
+
};
|