@fishawack/lab-env 5.7.0-beta.4 → 5.7.0-beta.6
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 +19 -0
- package/commands/create/cmds/dekey.js +1 -1
- package/commands/create/cmds/diagnose.js +10 -0
- package/commands/create/cmds/key.js +21 -7
- package/commands/create/cmds/rekey.js +73 -11
- package/commands/create/libs/parallel-runner.js +106 -14
- package/commands/create/libs/vars.js +3 -3
- package/commands/create/services/aws/ses.js +9 -1
- package/globals.js +1 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
## Changelog
|
|
2
2
|
|
|
3
|
+
### 5.7.0-beta.6 (2026-07-03)
|
|
4
|
+
|
|
5
|
+
#### Features
|
|
6
|
+
|
|
7
|
+
* **devops:** misc.json setup, SES rotation recovery, runner ordering ([3c6ec70](https://bitbucket.org/fishawackdigital/lab-env/commits/3c6ec70df5e54ad4683a9a5b4e8e3accb681121d))
|
|
8
|
+
|
|
9
|
+
#### Bug Fixes
|
|
10
|
+
|
|
11
|
+
* add timeout for aws key calls themselves ([389444c](https://bitbucket.org/fishawackdigital/lab-env/commits/389444cf7c0c61273b8dc7edf2d0d6695a1047a7))
|
|
12
|
+
* concurrency set back to 5 for more stable key commands ([a5e7382](https://bitbucket.org/fishawackdigital/lab-env/commits/a5e7382b9731ad2a5a60110a54e27ebe4af45391))
|
|
13
|
+
|
|
14
|
+
### 5.7.0-beta.5 (2026-07-03)
|
|
15
|
+
|
|
16
|
+
#### Bug Fixes
|
|
17
|
+
|
|
18
|
+
* allow rekey outside of projects ([94b511b](https://bitbucket.org/fishawackdigital/lab-env/commits/94b511b629fbf80b842a9ab5db9f3671f1f603f0))
|
|
19
|
+
* retry on rate limits and aws errors on key commands ([0244221](https://bitbucket.org/fishawackdigital/lab-env/commits/0244221b8a263256f015380280024c862213b885))
|
|
20
|
+
* safe check for misc bitbucket vars ([cee035c](https://bitbucket.org/fishawackdigital/lab-env/commits/cee035c25f927d1b5f55c00f10fa35f08733e4bf))
|
|
21
|
+
|
|
3
22
|
### 5.7.0-beta.4 (2026-07-03)
|
|
4
23
|
|
|
5
24
|
#### Bug Fixes
|
|
@@ -142,6 +142,16 @@ module.exports = [
|
|
|
142
142
|
}
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
+
if (preset === "devops") {
|
|
146
|
+
while (!(await test.targets())) {
|
|
147
|
+
await guide.targets(argv);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
while (!(await test.misc())) {
|
|
151
|
+
await guide.misc(argv);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
145
155
|
// Update diagnosis version in ~/.lab-env config file
|
|
146
156
|
let spinner = new Spinner(
|
|
147
157
|
`Writing options to ${path.join(os.homedir(), "", ".lab-env")}`,
|
|
@@ -95,18 +95,32 @@ module.exports = [
|
|
|
95
95
|
|
|
96
96
|
if (answer.check) {
|
|
97
97
|
try {
|
|
98
|
-
|
|
98
|
+
let creds = await aws.ses.ensureOperatorSESCredentials(
|
|
99
99
|
operator.username,
|
|
100
100
|
);
|
|
101
101
|
|
|
102
|
+
if (!creds) {
|
|
103
|
+
answer = await inquirer.prompt([
|
|
104
|
+
{
|
|
105
|
+
type: "confirm",
|
|
106
|
+
name: "check",
|
|
107
|
+
message:
|
|
108
|
+
"SES IAM user exists but secret is not stored locally. Rotate credentials to recapture?",
|
|
109
|
+
default: "Y",
|
|
110
|
+
},
|
|
111
|
+
]);
|
|
112
|
+
|
|
113
|
+
if (answer.check) {
|
|
114
|
+
creds = await aws.ses.rotateOperatorSESCredentials(
|
|
115
|
+
operator.username,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
102
120
|
if (creds) {
|
|
103
121
|
aws.ses.updateMiscNodemailer(creds);
|
|
104
122
|
console.log(
|
|
105
|
-
"\n ✓ SES email credentials
|
|
106
|
-
);
|
|
107
|
-
} else {
|
|
108
|
-
console.log(
|
|
109
|
-
"\n ⚠ SES IAM user exists but keys were already created. Rotate with `fw rekey` to generate new credentials.\n",
|
|
123
|
+
"\n ✓ SES email credentials saved to misc.json\n",
|
|
110
124
|
);
|
|
111
125
|
}
|
|
112
126
|
} catch (err) {
|
|
@@ -133,7 +147,7 @@ module.exports = [
|
|
|
133
147
|
}));
|
|
134
148
|
|
|
135
149
|
const resultsMap = await parallelRunner(batches, {
|
|
136
|
-
concurrency:
|
|
150
|
+
concurrency: 5,
|
|
137
151
|
verb: "Create credentials",
|
|
138
152
|
});
|
|
139
153
|
|
|
@@ -95,18 +95,32 @@ module.exports = [
|
|
|
95
95
|
|
|
96
96
|
if (answer.check) {
|
|
97
97
|
try {
|
|
98
|
-
|
|
98
|
+
let creds = await aws.ses.ensureOperatorSESCredentials(
|
|
99
99
|
operator.username,
|
|
100
100
|
);
|
|
101
101
|
|
|
102
|
+
if (!creds) {
|
|
103
|
+
answer = await inquirer.prompt([
|
|
104
|
+
{
|
|
105
|
+
type: "confirm",
|
|
106
|
+
name: "check",
|
|
107
|
+
message:
|
|
108
|
+
"SES IAM user exists but secret is not stored locally. Rotate credentials to recapture?",
|
|
109
|
+
default: "Y",
|
|
110
|
+
},
|
|
111
|
+
]);
|
|
112
|
+
|
|
113
|
+
if (answer.check) {
|
|
114
|
+
creds = await aws.ses.rotateOperatorSESCredentials(
|
|
115
|
+
operator.username,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
102
120
|
if (creds) {
|
|
103
121
|
aws.ses.updateMiscNodemailer(creds);
|
|
104
122
|
console.log(
|
|
105
|
-
"\n \u2713 SES email credentials
|
|
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",
|
|
123
|
+
"\n \u2713 SES email credentials saved to misc.json\n",
|
|
110
124
|
);
|
|
111
125
|
}
|
|
112
126
|
} catch (err) {
|
|
@@ -117,10 +131,19 @@ module.exports = [
|
|
|
117
131
|
}
|
|
118
132
|
}
|
|
119
133
|
|
|
120
|
-
// Separate
|
|
121
|
-
//
|
|
122
|
-
|
|
123
|
-
|
|
134
|
+
// Separate users into: regular, runners, operator
|
|
135
|
+
// Processing order: regular users → runners → operator (last)
|
|
136
|
+
let runners = users.filter((u) => {
|
|
137
|
+
let config = _.config.users.find((d) => d.username === u);
|
|
138
|
+
return (
|
|
139
|
+
u !== operator.username &&
|
|
140
|
+
config &&
|
|
141
|
+
(config.runner || u === "aws-runner")
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
let nonOperatorUsers = users.filter(
|
|
145
|
+
(u) => u !== operator.username && !runners.includes(u),
|
|
146
|
+
);
|
|
124
147
|
let includeOperator = users.includes(operator.username);
|
|
125
148
|
|
|
126
149
|
let credentials = {};
|
|
@@ -144,7 +167,7 @@ module.exports = [
|
|
|
144
167
|
}));
|
|
145
168
|
|
|
146
169
|
const resultsMap = await parallelRunner(batches, {
|
|
147
|
-
concurrency:
|
|
170
|
+
concurrency: 5,
|
|
148
171
|
verb: "Rotate credentials",
|
|
149
172
|
});
|
|
150
173
|
|
|
@@ -164,6 +187,45 @@ module.exports = [
|
|
|
164
187
|
}
|
|
165
188
|
}
|
|
166
189
|
|
|
190
|
+
// Process runner users as a group (after regular, before operator)
|
|
191
|
+
if (runners.length) {
|
|
192
|
+
const runnerBatches = clients.map((client) => ({
|
|
193
|
+
users: runners,
|
|
194
|
+
client,
|
|
195
|
+
fn: async (user) => {
|
|
196
|
+
setAWSClientDefaults(client);
|
|
197
|
+
|
|
198
|
+
await aws.iam.removeIAMUser(`fw-automation-${user}`);
|
|
199
|
+
|
|
200
|
+
return aws.iam.createFWIAMUser(
|
|
201
|
+
`fw-automation-${user}`,
|
|
202
|
+
_.config.users.find((d) => d.username === user)
|
|
203
|
+
.permissions,
|
|
204
|
+
);
|
|
205
|
+
},
|
|
206
|
+
}));
|
|
207
|
+
|
|
208
|
+
const runnerResults = await parallelRunner(runnerBatches, {
|
|
209
|
+
concurrency: 5,
|
|
210
|
+
verb: "Rotate runner credentials",
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
for (const [user, entries] of runnerResults) {
|
|
214
|
+
credentials[user] = {};
|
|
215
|
+
|
|
216
|
+
for (const { client, res } of entries) {
|
|
217
|
+
credentials[user][client] = {
|
|
218
|
+
key:
|
|
219
|
+
(res.AccessKey && res.AccessKey.AccessKeyId) ||
|
|
220
|
+
res.AccessKeyMetadata[0].AccessKeyId,
|
|
221
|
+
secret:
|
|
222
|
+
(res.AccessKey && res.AccessKey.SecretAccessKey) ||
|
|
223
|
+
"** secret **",
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
167
229
|
// Process operator sequentially — must be last per account
|
|
168
230
|
if (includeOperator) {
|
|
169
231
|
const operatorBatches = clients.map((client) => ({
|
|
@@ -3,6 +3,34 @@ const chalk = require("chalk");
|
|
|
3
3
|
const { Spinner } = require("./utilities");
|
|
4
4
|
|
|
5
5
|
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
6
|
+
const MAX_RETRIES = 15;
|
|
7
|
+
const INITIAL_DELAY = 2000;
|
|
8
|
+
const MAX_DELAY = 60000;
|
|
9
|
+
const TASK_TIMEOUT = 120000; // 2 min hard timeout per attempt — prevents hung TCP connections
|
|
10
|
+
|
|
11
|
+
const RETRYABLE_ERRORS = [
|
|
12
|
+
"Throttling",
|
|
13
|
+
"ThrottlingException",
|
|
14
|
+
"TooManyRequestsException",
|
|
15
|
+
"RequestLimitExceeded",
|
|
16
|
+
"ServiceUnavailable",
|
|
17
|
+
"InternalFailure",
|
|
18
|
+
"RequestTimeout",
|
|
19
|
+
"RequestTimeoutException",
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
function isRetryable(err) {
|
|
23
|
+
if (
|
|
24
|
+
RETRYABLE_ERRORS.includes(err.name) ||
|
|
25
|
+
RETRYABLE_ERRORS.includes(err.Code)
|
|
26
|
+
) {
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const statusCode = err.$metadata?.httpStatusCode;
|
|
31
|
+
|
|
32
|
+
return statusCode === 429 || statusCode === 503 || statusCode === 500;
|
|
33
|
+
}
|
|
6
34
|
|
|
7
35
|
function formatElapsed(ms) {
|
|
8
36
|
const secs = Math.floor(ms / 1000);
|
|
@@ -78,7 +106,20 @@ module.exports = async function parallelRunner(
|
|
|
78
106
|
continue;
|
|
79
107
|
}
|
|
80
108
|
|
|
81
|
-
if (state.status === "
|
|
109
|
+
if (state.status === "retrying") {
|
|
110
|
+
const remaining = Math.max(
|
|
111
|
+
0,
|
|
112
|
+
Math.ceil(
|
|
113
|
+
(state.retryDelay - (Date.now() - state.retryStart)) /
|
|
114
|
+
1000,
|
|
115
|
+
),
|
|
116
|
+
);
|
|
117
|
+
lines.push(
|
|
118
|
+
chalk.yellow(
|
|
119
|
+
` ↻ ${verb}: ${user} - ${state.client} (${state.batchesDone + 1}/${totalBatches}) retry ${state.retryAttempt}/${MAX_RETRIES} ${remaining}s`,
|
|
120
|
+
),
|
|
121
|
+
);
|
|
122
|
+
} else if (state.status === "active") {
|
|
82
123
|
const taskElapsed = Date.now() - state.taskStart;
|
|
83
124
|
let suffix = "";
|
|
84
125
|
if (taskElapsed > 5000) {
|
|
@@ -130,24 +171,75 @@ module.exports = async function parallelRunner(
|
|
|
130
171
|
const groupUsers = group.filter((u) => batch.users.includes(u));
|
|
131
172
|
|
|
132
173
|
await Promise.all(
|
|
133
|
-
groupUsers.map(async (user) => {
|
|
174
|
+
groupUsers.map(async (user, userIndex) => {
|
|
134
175
|
const state = groupStates.get(user);
|
|
135
176
|
state.client = batch.client;
|
|
136
|
-
state.status = "active";
|
|
137
177
|
state.taskStart = Date.now();
|
|
138
178
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
179
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
180
|
+
state.status = "active";
|
|
181
|
+
state.taskStart = Date.now();
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
let timeoutHandle;
|
|
185
|
+
const res = await Promise.race([
|
|
186
|
+
batch.fn(user),
|
|
187
|
+
new Promise((_, reject) => {
|
|
188
|
+
timeoutHandle = setTimeout(
|
|
189
|
+
() =>
|
|
190
|
+
reject(
|
|
191
|
+
Object.assign(
|
|
192
|
+
new Error(
|
|
193
|
+
"Request timed out after 2 minutes — will retry",
|
|
194
|
+
),
|
|
195
|
+
{ name: "RequestTimeout" },
|
|
196
|
+
),
|
|
197
|
+
),
|
|
198
|
+
TASK_TIMEOUT,
|
|
199
|
+
);
|
|
200
|
+
}),
|
|
201
|
+
]).finally(() => clearTimeout(timeoutHandle));
|
|
202
|
+
results
|
|
203
|
+
.get(user)
|
|
204
|
+
.push({ client: batch.client, res });
|
|
205
|
+
state.status = "done";
|
|
206
|
+
state.retryAttempt = 0;
|
|
207
|
+
break;
|
|
208
|
+
} catch (err) {
|
|
209
|
+
if (!isRetryable(err) || attempt === MAX_RETRIES) {
|
|
210
|
+
state.status = "error";
|
|
211
|
+
state.hasError = true;
|
|
212
|
+
state.errorMsg = err.message || String(err);
|
|
213
|
+
errors.push({
|
|
214
|
+
user,
|
|
215
|
+
client: batch.client,
|
|
216
|
+
error: err,
|
|
217
|
+
});
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
let delay = Math.min(
|
|
222
|
+
INITIAL_DELAY * Math.pow(2, attempt),
|
|
223
|
+
MAX_DELAY,
|
|
224
|
+
);
|
|
225
|
+
// Stagger retries per user to avoid thundering herd
|
|
226
|
+
delay +=
|
|
227
|
+
Math.floor(Math.random() * delay * 0.3) +
|
|
228
|
+
userIndex * 1000;
|
|
229
|
+
|
|
230
|
+
state.status = "retrying";
|
|
231
|
+
state.retryAttempt = attempt + 1;
|
|
232
|
+
state.retryDelay = delay;
|
|
233
|
+
state.retryStart = Date.now();
|
|
234
|
+
throttleCount++;
|
|
235
|
+
|
|
236
|
+
await new Promise((resolve) =>
|
|
237
|
+
setTimeout(resolve, delay),
|
|
238
|
+
);
|
|
239
|
+
}
|
|
150
240
|
}
|
|
241
|
+
|
|
242
|
+
state.batchesDone++;
|
|
151
243
|
}),
|
|
152
244
|
);
|
|
153
245
|
}
|
|
@@ -68,17 +68,17 @@ const bitbucketApi = (module.exports.bitbucketApi =
|
|
|
68
68
|
"https://api.bitbucket.org/2.0/repositories");
|
|
69
69
|
|
|
70
70
|
module.exports.apis = {
|
|
71
|
-
bbWorkspaceAPI: `${bitbucketApi}/${misc
|
|
71
|
+
bbWorkspaceAPI: `${bitbucketApi}/${misc?.bitbucket?.workspace}`,
|
|
72
72
|
};
|
|
73
73
|
|
|
74
74
|
// URLs
|
|
75
75
|
module.exports.urls = {
|
|
76
|
-
bitbucketSSH: `git@bitbucket.org:${misc
|
|
76
|
+
bitbucketSSH: `git@bitbucket.org:${misc?.bitbucket?.workspace}`,
|
|
77
77
|
};
|
|
78
78
|
|
|
79
79
|
// Request Headers
|
|
80
80
|
module.exports.headers = {
|
|
81
|
-
bbHeaders: encode(misc
|
|
81
|
+
bbHeaders: encode(misc?.bitbucket?.username, misc?.bitbucket?.token),
|
|
82
82
|
};
|
|
83
83
|
|
|
84
84
|
// Required global node modules
|
|
@@ -176,7 +176,15 @@ const MISC_PATH = `${os.homedir()}/targets/misc.json`;
|
|
|
176
176
|
module.exports.updateMiscNodemailer = (creds) => {
|
|
177
177
|
const { misc } = require("../../libs/vars");
|
|
178
178
|
|
|
179
|
-
|
|
179
|
+
let file = {};
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
file = JSON.parse(readFileSync(MISC_PATH, { encoding: "utf8" }));
|
|
183
|
+
} catch {
|
|
184
|
+
const { mkdirSync } = require("fs");
|
|
185
|
+
mkdirSync(require("path").dirname(MISC_PATH), { recursive: true });
|
|
186
|
+
file = { bitbucket: {}, gitlab: {} };
|
|
187
|
+
}
|
|
180
188
|
|
|
181
189
|
file.nodemailer = {
|
|
182
190
|
driver: "AWSSES",
|
package/globals.js
CHANGED
|
@@ -279,6 +279,7 @@ if (composer && composer.require && composer.require["laravel/framework"]) {
|
|
|
279
279
|
args[0] !== "new" &&
|
|
280
280
|
args[0] !== "key" &&
|
|
281
281
|
args[0] !== "dekey" &&
|
|
282
|
+
args[0] !== "rekey" &&
|
|
282
283
|
args[0] !== "lint" &&
|
|
283
284
|
args[0] !== "workspace" &&
|
|
284
285
|
args[0] !== "prune"
|