@fishawack/lab-env 5.7.0-beta.5 → 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 CHANGED
@@ -1,5 +1,16 @@
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
+
3
14
  ### 5.7.0-beta.5 (2026-07-03)
4
15
 
5
16
  #### Bug Fixes
@@ -75,7 +75,7 @@ module.exports = [
75
75
  }));
76
76
 
77
77
  await parallelRunner(batches, {
78
- concurrency: 10,
78
+ concurrency: 5,
79
79
  verb: "Remove credentials",
80
80
  });
81
81
  },
@@ -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
- const creds = await aws.ses.ensureOperatorSESCredentials(
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 created and saved to misc.json\n",
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: 10,
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
- const creds = await aws.ses.ensureOperatorSESCredentials(
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 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",
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 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);
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: 10,
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) => ({
@@ -6,6 +6,7 @@ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧",
6
6
  const MAX_RETRIES = 15;
7
7
  const INITIAL_DELAY = 2000;
8
8
  const MAX_DELAY = 60000;
9
+ const TASK_TIMEOUT = 120000; // 2 min hard timeout per attempt — prevents hung TCP connections
9
10
 
10
11
  const RETRYABLE_ERRORS = [
11
12
  "Throttling",
@@ -180,7 +181,24 @@ module.exports = async function parallelRunner(
180
181
  state.taskStart = Date.now();
181
182
 
182
183
  try {
183
- const res = await batch.fn(user);
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));
184
202
  results
185
203
  .get(user)
186
204
  .push({ client: batch.client, res });
@@ -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
- const file = JSON.parse(readFileSync(MISC_PATH, { encoding: "utf8" }));
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fishawack/lab-env",
3
- "version": "5.7.0-beta.5",
3
+ "version": "5.7.0-beta.6",
4
4
  "description": "Docker manager for FW",
5
5
  "main": "cli.js",
6
6
  "scripts": {