flecto 3.0.2 → 4.0.0
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 +257 -1
- package/README.md +12 -1
- package/drift.js +226 -0
- package/index.js +672 -152
- package/package.json +10 -7
- package/src/alerter.js +272 -24
- package/src/config.js +369 -3
- package/src/differ.js +76 -12
- package/src/drift-sources.js +444 -0
- package/src/explain.js +706 -0
- package/src/lsp-analysis.js +397 -0
- package/src/lsp-worker.js +13 -0
- package/src/lsp.js +407 -0
- package/src/mcp.js +487 -0
- package/src/parser.js +24 -14
- package/src/policy.js +71 -47
- package/src/positions.js +1057 -0
- package/src/pr-comment.js +75 -1
- package/src/pr-providers.js +8 -1
- package/src/regex-engine.js +138 -0
- package/src/renderer.js +27 -15
- package/src/report.js +4 -2
- package/src/secrets.js +27 -0
- package/src/snapshot-store.js +672 -0
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"access": "public",
|
|
5
5
|
"provenance": true
|
|
6
6
|
},
|
|
7
|
-
"version": "
|
|
7
|
+
"version": "4.0.0",
|
|
8
8
|
"description": "Flecto \u2014 semantic config watcher that reports meaningful changes in plain English",
|
|
9
9
|
"license": "MIT",
|
|
10
10
|
"keywords": [
|
|
@@ -30,15 +30,17 @@
|
|
|
30
30
|
"type": "module",
|
|
31
31
|
"main": "index.js",
|
|
32
32
|
"bin": {
|
|
33
|
-
"flecto": "index.js"
|
|
33
|
+
"flecto": "index.js",
|
|
34
|
+
"flecto-drift": "drift.js"
|
|
34
35
|
},
|
|
35
36
|
"files": [
|
|
37
|
+
"CHANGELOG.md",
|
|
38
|
+
"LICENSE",
|
|
39
|
+
"README.md",
|
|
40
|
+
"drift.js",
|
|
36
41
|
"index.js",
|
|
37
|
-
"src/**/*",
|
|
38
42
|
"schemas/**/*",
|
|
39
|
-
"
|
|
40
|
-
"LICENSE",
|
|
41
|
-
"CHANGELOG.md"
|
|
43
|
+
"src/**/*"
|
|
42
44
|
],
|
|
43
45
|
"scripts": {
|
|
44
46
|
"test": "node --test test/*.test.js",
|
|
@@ -56,6 +58,7 @@
|
|
|
56
58
|
"commander": "^12.1.0",
|
|
57
59
|
"dotenv": "^17.4.2",
|
|
58
60
|
"fast-glob": "^3.3.3",
|
|
59
|
-
"js-yaml": "^4.3.0"
|
|
61
|
+
"js-yaml": "^4.3.0",
|
|
62
|
+
"re2js": "^2.8.6"
|
|
60
63
|
}
|
|
61
64
|
}
|
package/src/alerter.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawn } from 'child_process';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { createHash } from 'crypto';
|
|
3
|
+
import { mkdirSync, writeFileSync, readFileSync, readdirSync, unlinkSync, rmSync } from 'fs';
|
|
4
|
+
import { join, resolve } from 'path';
|
|
4
5
|
import { renderWarn } from './renderer.js';
|
|
5
6
|
import { formatWebhookPayload } from './notifiers.js';
|
|
6
7
|
|
|
@@ -8,6 +9,101 @@ const ALERT_TMP_DIR = '.flecto-tmp';
|
|
|
8
9
|
const ALERT_QUEUE_DIR = '.flecto-queue';
|
|
9
10
|
const MAX_ENV_CHANGES_CHARS = 16_000;
|
|
10
11
|
|
|
12
|
+
/**
|
|
13
|
+
* A webhook URL reduced to the part that identifies it without carrying its
|
|
14
|
+
* credential.
|
|
15
|
+
*
|
|
16
|
+
* Webhook URLs routinely *are* credentials: a Slack incoming webhook puts its
|
|
17
|
+
* secret in the path (`/services/T.../B.../XXXX`), and userinfo and query
|
|
18
|
+
* strings carry tokens just as often. Warnings about a failed delivery are
|
|
19
|
+
* printed to a terminal and, in CI, into a log that is frequently world
|
|
20
|
+
* readable and retained — so a transient 500 was enough to copy the credential
|
|
21
|
+
* somewhere it outlives the run.
|
|
22
|
+
*
|
|
23
|
+
* Only the origin survives, plus a marker showing a path was elided. That is
|
|
24
|
+
* enough for an operator to tell *which* endpoint failed (they configured it)
|
|
25
|
+
* and not enough for a reader of the log to call it.
|
|
26
|
+
* @param {string} raw
|
|
27
|
+
* @returns {string}
|
|
28
|
+
*/
|
|
29
|
+
export function redactWebhookUrl(raw) {
|
|
30
|
+
try {
|
|
31
|
+
const url = new URL(raw);
|
|
32
|
+
const path = url.pathname && url.pathname !== '/' ? '/...' : '';
|
|
33
|
+
return `${url.protocol}//${url.host}${path}`;
|
|
34
|
+
} catch {
|
|
35
|
+
// Not parseable as a URL, so nothing can be said about which part is the
|
|
36
|
+
// secret. Say nothing.
|
|
37
|
+
return '<webhook>';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* An error's message with every URL in it redacted.
|
|
43
|
+
*
|
|
44
|
+
* Redacting the URL Flecto interpolates is not enough on its own, because
|
|
45
|
+
* `fetch` puts the URL into the error it throws. Two cases reach this
|
|
46
|
+
* deterministically, and both are precisely where redaction matters most:
|
|
47
|
+
* a URL carrying userinfo (`https://user:TOKEN@host/x`) fails with *"Request
|
|
48
|
+
* cannot be constructed from a URL that includes credentials: <the whole
|
|
49
|
+
* thing>"*, and an unparseable URL fails with *"Failed to parse URL from
|
|
50
|
+
* <the whole thing>"* -- the very case `redactWebhookUrl` answers `<webhook>`
|
|
51
|
+
* to, because nothing can be said about which part is the secret.
|
|
52
|
+
*
|
|
53
|
+
* So the configured URL is substituted out by value, and any other URL-shaped
|
|
54
|
+
* run is redacted on sight in case the engine spelled it differently.
|
|
55
|
+
* @param {unknown} err
|
|
56
|
+
* @param {string} [url] the URL this delivery was configured with
|
|
57
|
+
* @returns {string}
|
|
58
|
+
*/
|
|
59
|
+
function redactedFailureDetail(err, url) {
|
|
60
|
+
let message = String(/** @type {{ message?: unknown }} */ (err)?.message ?? err);
|
|
61
|
+
if (url) message = message.split(url).join(redactWebhookUrl(url));
|
|
62
|
+
return message.replace(/[a-z][a-z0-9+.-]*:\/\/\S+/gi, (found) => redactWebhookUrl(found));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A stable fingerprint of everything that decides *where* a delivery goes and
|
|
67
|
+
* what it looks like on arrival.
|
|
68
|
+
*
|
|
69
|
+
* The persistent queue used to store the envelope alone, so a flush delivered
|
|
70
|
+
* it with whatever options the *current* `fireAlerts` call happened to carry.
|
|
71
|
+
* An event queued while `watch` pointed at one endpoint would be posted to
|
|
72
|
+
* whichever endpoint the next run named -- a different team's channel, a
|
|
73
|
+
* different vendor, a URL from a different profile -- carrying configuration
|
|
74
|
+
* data the operator had directed elsewhere.
|
|
75
|
+
*
|
|
76
|
+
* Hashing rather than storing keeps the URL and any auth header out of the
|
|
77
|
+
* queue file. It is a fingerprint, not a secret: anyone who can read
|
|
78
|
+
* `.flecto-queue/` could confirm a *guessed* URL against it, but they can read
|
|
79
|
+
* the directory only if they can already read the config that names it.
|
|
80
|
+
*
|
|
81
|
+
* Header *values* are included, so rotating a token strands the old backlog
|
|
82
|
+
* rather than replaying it under new credentials. That is the safe direction,
|
|
83
|
+
* and `warnAboutStrandedQueue` is what stops it being silent.
|
|
84
|
+
* @param {{ webhook?: string, webhookHeaders?: Record<string, string>, webhookFormat?: string }} options
|
|
85
|
+
* @returns {string}
|
|
86
|
+
*/
|
|
87
|
+
function destinationId(options) {
|
|
88
|
+
// Normalize first, so one destination does not split into several
|
|
89
|
+
// directories over spellings that address the same endpoint. `new URL().href`
|
|
90
|
+
// settles host case and a redundant default port; HTTP header names are
|
|
91
|
+
// case-insensitive by spec, so they are lowercased before sorting. A trailing
|
|
92
|
+
// slash is left alone -- it is a different path, and a receiver may treat it
|
|
93
|
+
// as one.
|
|
94
|
+
let url = options.webhook ?? '';
|
|
95
|
+
try {
|
|
96
|
+
url = new URL(url).href;
|
|
97
|
+
} catch {
|
|
98
|
+
// Not parseable: hash it as written rather than guessing at it.
|
|
99
|
+
}
|
|
100
|
+
const headers = Object.entries(options.webhookHeaders ?? {})
|
|
101
|
+
.map(([key, value]) => [key.toLowerCase(), value])
|
|
102
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
103
|
+
const material = JSON.stringify([url, headers, options.webhookFormat ?? 'flecto']);
|
|
104
|
+
return createHash('sha256').update(material).digest('hex').slice(0, 32);
|
|
105
|
+
}
|
|
106
|
+
|
|
11
107
|
/** @type {Promise<void>} */
|
|
12
108
|
let alertQueue = Promise.resolve();
|
|
13
109
|
|
|
@@ -22,7 +118,19 @@ function enqueue(fn) {
|
|
|
22
118
|
}
|
|
23
119
|
|
|
24
120
|
/**
|
|
121
|
+
* Build the environment a `--command` subprocess runs with, spilling an
|
|
122
|
+
* oversized change set to a file.
|
|
123
|
+
*
|
|
124
|
+
* The spill file holds the complete, unmasked change set -- every value the
|
|
125
|
+
* diff touched, including ones `--mask-secrets` would have hidden on screen.
|
|
126
|
+
* It is created `0600` inside a `0700` directory and deleted once the command
|
|
127
|
+
* exits, so it is readable only by the user running Flecto and only while
|
|
128
|
+
* there is a process that needs it. Previously it inherited the process umask
|
|
129
|
+
* (`0644` on a typical runner) and was never removed, which left the full
|
|
130
|
+
* change set of every oversized event sitting in the workspace for whatever
|
|
131
|
+
* ran next -- a later build step, an artifact upload, a cache action.
|
|
25
132
|
* @param {import('./envelope.js').FlectoEnvelope} envelope
|
|
133
|
+
* @returns {{ env: Record<string, string>, cleanup: () => void }}
|
|
26
134
|
*/
|
|
27
135
|
function buildCommandEnv(envelope) {
|
|
28
136
|
const json = JSON.stringify(envelope.changes);
|
|
@@ -33,24 +141,35 @@ function buildCommandEnv(envelope) {
|
|
|
33
141
|
FLECTO_BATCH_ID: envelope.batch_id,
|
|
34
142
|
FLECTO_SCHEMA_VERSION: envelope.schema_version,
|
|
35
143
|
};
|
|
144
|
+
const noop = () => {};
|
|
36
145
|
|
|
37
146
|
if (json.length <= MAX_ENV_CHANGES_CHARS) {
|
|
38
147
|
env.FLECTO_CHANGES = json;
|
|
39
|
-
return env;
|
|
148
|
+
return { env, cleanup: noop };
|
|
40
149
|
}
|
|
41
150
|
|
|
42
151
|
try {
|
|
43
|
-
mkdirSync(ALERT_TMP_DIR, { recursive: true });
|
|
152
|
+
mkdirSync(ALERT_TMP_DIR, { recursive: true, mode: 0o700 });
|
|
44
153
|
const outPath = resolve(`${ALERT_TMP_DIR}/changes-${Date.now()}-${envelope.event_id}.json`);
|
|
45
|
-
writeFileSync(outPath, json, 'utf8');
|
|
154
|
+
writeFileSync(outPath, json, { encoding: 'utf8', mode: 0o600 });
|
|
46
155
|
env.FLECTO_CHANGES_FILE = outPath;
|
|
47
156
|
env.FLECTO_CHANGES = '[]';
|
|
48
|
-
return
|
|
157
|
+
return {
|
|
158
|
+
env,
|
|
159
|
+
cleanup: () => {
|
|
160
|
+
try {
|
|
161
|
+
rmSync(outPath, { force: true });
|
|
162
|
+
} catch {
|
|
163
|
+
// The command may have moved or removed it; either way there is
|
|
164
|
+
// nothing left to clean and nothing useful to say.
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
};
|
|
49
168
|
} catch (err) {
|
|
50
169
|
env.FLECTO_CHANGES = '[]';
|
|
51
170
|
env.FLECTO_CHANGES_TRUNCATED = '1';
|
|
52
171
|
renderWarn(`Could not write changes payload to temp file: ${err.message}`);
|
|
53
|
-
return env;
|
|
172
|
+
return { env, cleanup: noop };
|
|
54
173
|
}
|
|
55
174
|
}
|
|
56
175
|
|
|
@@ -61,7 +180,17 @@ function buildCommandEnv(envelope) {
|
|
|
61
180
|
*/
|
|
62
181
|
export function runCommand(command, envelope) {
|
|
63
182
|
return new Promise((resolveDone) => {
|
|
64
|
-
const env = buildCommandEnv(envelope);
|
|
183
|
+
const { env, cleanup } = buildCommandEnv(envelope);
|
|
184
|
+
// The spill file exists for exactly as long as the subprocess that reads
|
|
185
|
+
// it. `settle` runs on every exit path, including the throw below, so no
|
|
186
|
+
// path leaves the change set on disk.
|
|
187
|
+
let settled = false;
|
|
188
|
+
const settle = (ok) => {
|
|
189
|
+
if (settled) return;
|
|
190
|
+
settled = true;
|
|
191
|
+
cleanup();
|
|
192
|
+
resolveDone(ok);
|
|
193
|
+
};
|
|
65
194
|
try {
|
|
66
195
|
const child = spawn(command, {
|
|
67
196
|
shell: true,
|
|
@@ -73,19 +202,19 @@ export function runCommand(command, envelope) {
|
|
|
73
202
|
child.stderr?.on('data', (d) => process.stderr.write(d));
|
|
74
203
|
child.on('error', (err) => {
|
|
75
204
|
renderWarn(`Command failed to start: ${err.message}`);
|
|
76
|
-
|
|
205
|
+
settle(false);
|
|
77
206
|
});
|
|
78
207
|
child.on('close', (code) => {
|
|
79
208
|
if (code && code !== 0) {
|
|
80
209
|
renderWarn(`Command failed (exit ${code}): ${command}`);
|
|
81
|
-
|
|
210
|
+
settle(false);
|
|
82
211
|
return;
|
|
83
212
|
}
|
|
84
|
-
|
|
213
|
+
settle(true);
|
|
85
214
|
});
|
|
86
215
|
} catch (err) {
|
|
87
216
|
renderWarn(`Command execution error: ${err.message}`);
|
|
88
|
-
|
|
217
|
+
settle(false);
|
|
89
218
|
}
|
|
90
219
|
});
|
|
91
220
|
}
|
|
@@ -95,23 +224,62 @@ function sleep(ms) {
|
|
|
95
224
|
}
|
|
96
225
|
|
|
97
226
|
/**
|
|
227
|
+
* The queue directory holding undelivered events for one destination.
|
|
228
|
+
*
|
|
229
|
+
* Each destination gets its own subdirectory, keyed by the fingerprint of the
|
|
230
|
+
* URL, headers, and format. That is what binds a queued event to the endpoint
|
|
231
|
+
* it was addressed to: a flush reads only its own directory, so it can no
|
|
232
|
+
* longer pick up an event queued for somewhere else. Keying the directory,
|
|
233
|
+
* rather than filtering files after reading them, also means a destination
|
|
234
|
+
* never even opens another's backlog.
|
|
235
|
+
* @param {{ webhook?: string, webhookHeaders?: Record<string, string>, webhookFormat?: string }} options
|
|
236
|
+
* @returns {string}
|
|
237
|
+
*/
|
|
238
|
+
function queueDirFor(options) {
|
|
239
|
+
return resolve(join(ALERT_QUEUE_DIR, destinationId(options)));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* `queueDirFor`, exposed so tests can assert which destinations share a queue.
|
|
244
|
+
* @type {typeof queueDirFor}
|
|
245
|
+
*/
|
|
246
|
+
export const queueDirForTest = queueDirFor;
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* @param {{ webhook?: string, webhookHeaders?: Record<string, string>, webhookFormat?: string }} options
|
|
98
250
|
* @param {import('./envelope.js').FlectoEnvelope} envelope
|
|
99
251
|
*/
|
|
100
|
-
function enqueuePersistent(envelope) {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
252
|
+
function enqueuePersistent(options, envelope) {
|
|
253
|
+
const dir = queueDirFor(options);
|
|
254
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
255
|
+
const path = join(dir, `${Date.now()}-${envelope.event_id}.json`);
|
|
256
|
+
// The envelope carries the change set, so it gets the same 0600 the spill
|
|
257
|
+
// file gets -- an undelivered alert is the same data, just waiting.
|
|
258
|
+
writeFileSync(path, JSON.stringify(envelope, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
104
259
|
}
|
|
105
260
|
|
|
106
261
|
/**
|
|
262
|
+
* Deliver everything queued *for this destination*, oldest first.
|
|
263
|
+
*
|
|
264
|
+
* Stops at the first failure and leaves the rest queued, so ordering is
|
|
265
|
+
* preserved and at-least-once still means at least once.
|
|
266
|
+
* @param {{ webhook?: string, webhookHeaders?: Record<string, string>, webhookFormat?: string }} options
|
|
107
267
|
* @param {(envelope: import('./envelope.js').FlectoEnvelope) => Promise<boolean>} deliver
|
|
108
268
|
*/
|
|
109
|
-
async function flushPersistentQueue(deliver) {
|
|
269
|
+
async function flushPersistentQueue(options, deliver) {
|
|
270
|
+
const dir = queueDirFor(options);
|
|
110
271
|
try {
|
|
111
|
-
|
|
112
|
-
|
|
272
|
+
// Deliberately no mkdir: a flush that created its own directory left an
|
|
273
|
+
// empty one behind on every successful run, one per destination ever
|
|
274
|
+
// configured, never cleaned. Nothing queued means nothing to do.
|
|
275
|
+
let files;
|
|
276
|
+
try {
|
|
277
|
+
files = readdirSync(dir).filter((f) => f.endsWith('.json')).sort();
|
|
278
|
+
} catch {
|
|
279
|
+
return true;
|
|
280
|
+
}
|
|
113
281
|
for (const file of files) {
|
|
114
|
-
const fullPath =
|
|
282
|
+
const fullPath = join(dir, file);
|
|
115
283
|
let envelope;
|
|
116
284
|
try {
|
|
117
285
|
envelope = JSON.parse(readFileSync(fullPath, 'utf8'));
|
|
@@ -126,6 +294,13 @@ async function flushPersistentQueue(deliver) {
|
|
|
126
294
|
return false;
|
|
127
295
|
}
|
|
128
296
|
}
|
|
297
|
+
// Drained: take the directory with it, so the queue root does not
|
|
298
|
+
// accumulate one entry per destination that ever had a backlog.
|
|
299
|
+
try {
|
|
300
|
+
if (readdirSync(dir).length === 0) rmSync(dir, { recursive: false, force: true });
|
|
301
|
+
} catch {
|
|
302
|
+
// A concurrent writer may have just queued into it. Leaving it is fine.
|
|
303
|
+
}
|
|
129
304
|
return true;
|
|
130
305
|
} catch (err) {
|
|
131
306
|
renderWarn(`Could not flush persistent queue: ${err.message}`);
|
|
@@ -133,6 +308,78 @@ async function flushPersistentQueue(deliver) {
|
|
|
133
308
|
}
|
|
134
309
|
}
|
|
135
310
|
|
|
311
|
+
/**
|
|
312
|
+
* Queue roots already warned about, so a watch on a churning file says this
|
|
313
|
+
* once rather than on every event. Keyed by path rather than a single flag:
|
|
314
|
+
* one process can legitimately serve more than one workspace, and each
|
|
315
|
+
* deserves to be told about its own backlog.
|
|
316
|
+
* @type {Set<string>}
|
|
317
|
+
*/
|
|
318
|
+
const strandedQueuesWarned = new Set();
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Warn about queued events that this run will not deliver.
|
|
322
|
+
*
|
|
323
|
+
* Two kinds, and neither may be delivered silently to the endpoint currently
|
|
324
|
+
* configured -- doing that is the bug this change fixes:
|
|
325
|
+
*
|
|
326
|
+
* - Events queued by Flecto 3.x sit at the top level of `.flecto-queue/` with
|
|
327
|
+
* no destination recorded at all.
|
|
328
|
+
* - Events queued for a destination that is not the one now configured sit in
|
|
329
|
+
* a sibling directory. Rotating an auth token on the same URL is enough to
|
|
330
|
+
* produce this, and it is the likelier cause in practice.
|
|
331
|
+
*
|
|
332
|
+
* Deleting either would discard an event the operator was promised
|
|
333
|
+
* at-least-once delivery of, so they are left alone and named. Naming them is
|
|
334
|
+
* the whole point: the earlier version of this warning only looked at
|
|
335
|
+
* top-level files, so a stranded 4.x backlog was invisible -- strictly worse
|
|
336
|
+
* than the 3.x case it did report.
|
|
337
|
+
* @param {string} currentDir the destination directory this run is flushing
|
|
338
|
+
*/
|
|
339
|
+
function warnAboutStrandedQueue(currentDir) {
|
|
340
|
+
const root = resolve(ALERT_QUEUE_DIR);
|
|
341
|
+
if (strandedQueuesWarned.has(root)) return;
|
|
342
|
+
let entries;
|
|
343
|
+
try {
|
|
344
|
+
entries = readdirSync(ALERT_QUEUE_DIR, { withFileTypes: true });
|
|
345
|
+
} catch {
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
const legacy = entries.filter((e) => e.isFile() && e.name.endsWith('.json')).length;
|
|
349
|
+
let stranded = 0;
|
|
350
|
+
for (const entry of entries) {
|
|
351
|
+
if (!entry.isDirectory()) continue;
|
|
352
|
+
const dir = join(ALERT_QUEUE_DIR, entry.name);
|
|
353
|
+
if (resolve(dir) === resolve(currentDir)) continue;
|
|
354
|
+
try {
|
|
355
|
+
stranded += readdirSync(dir).filter((f) => f.endsWith('.json')).length;
|
|
356
|
+
} catch {
|
|
357
|
+
// Unreadable: not something to report as a queued event.
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
if (legacy === 0 && stranded === 0) return;
|
|
361
|
+
strandedQueuesWarned.add(root);
|
|
362
|
+
|
|
363
|
+
const parts = [];
|
|
364
|
+
if (legacy > 0) {
|
|
365
|
+
parts.push(
|
|
366
|
+
`${legacy} event(s) queued by Flecto 3.x, which recorded no destination at all`,
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
if (stranded > 0) {
|
|
370
|
+
parts.push(
|
|
371
|
+
`${stranded} event(s) queued for a destination that is not the one configured now`
|
|
372
|
+
+ ' (rotating a webhook token or editing the URL does this)',
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
renderWarn(
|
|
376
|
+
`${ALERT_QUEUE_DIR}/ holds undelivered events this run will not send: ${parts.join('; ')}. `
|
|
377
|
+
+ 'They are kept, not dropped. Delivering them to the endpoint configured now is the '
|
|
378
|
+
+ 'wrong-endpoint delivery this version exists to prevent, so re-send them deliberately '
|
|
379
|
+
+ 'or remove them once you have looked.',
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
|
|
136
383
|
/**
|
|
137
384
|
* @param {string} url
|
|
138
385
|
* @param {import('./envelope.js').FlectoEnvelope} envelope
|
|
@@ -170,7 +417,7 @@ export async function postWebhook(url, envelope, options = {}) {
|
|
|
170
417
|
signal: controller.signal,
|
|
171
418
|
});
|
|
172
419
|
if (!response.ok) {
|
|
173
|
-
renderWarn(`Webhook returned HTTP ${response.status}: ${url}`);
|
|
420
|
+
renderWarn(`Webhook returned HTTP ${response.status}: ${redactWebhookUrl(url)}`);
|
|
174
421
|
if (response.status >= 500 && attempt < retries) {
|
|
175
422
|
const backoff = Math.min(2_000, 200 * Math.pow(2, attempt));
|
|
176
423
|
await sleep(backoff + Math.floor(Math.random() * 150));
|
|
@@ -182,7 +429,7 @@ export async function postWebhook(url, envelope, options = {}) {
|
|
|
182
429
|
} catch (err) {
|
|
183
430
|
const msg = err?.name === 'AbortError' ? `Webhook timed out after ${timeoutMs}ms` : 'Webhook failed';
|
|
184
431
|
if (attempt >= retries) {
|
|
185
|
-
renderWarn(`${msg}: ${url} (${err
|
|
432
|
+
renderWarn(`${msg}: ${redactWebhookUrl(url)} (${redactedFailureDetail(err, url)})`);
|
|
186
433
|
return false;
|
|
187
434
|
}
|
|
188
435
|
const backoff = Math.min(2_000, 200 * Math.pow(2, attempt));
|
|
@@ -251,7 +498,8 @@ export async function fireAlerts(options, envelope) {
|
|
|
251
498
|
|
|
252
499
|
if (options.webhook) {
|
|
253
500
|
if (options.deliveryMode === 'at-least-once') {
|
|
254
|
-
|
|
501
|
+
warnAboutStrandedQueue(queueDirFor(options));
|
|
502
|
+
await flushPersistentQueue(options, (queued) => deliverWebhook(options, queued));
|
|
255
503
|
}
|
|
256
504
|
|
|
257
505
|
let webhookOk = await deliverWebhook(options, envelope);
|
|
@@ -259,7 +507,7 @@ export async function fireAlerts(options, envelope) {
|
|
|
259
507
|
webhookOk = await deliverWebhook({ ...options, webhookRetries: 5 }, envelope);
|
|
260
508
|
}
|
|
261
509
|
if (!webhookOk && options.deliveryMode === 'at-least-once') {
|
|
262
|
-
enqueuePersistent(envelope);
|
|
510
|
+
enqueuePersistent(options, envelope);
|
|
263
511
|
}
|
|
264
512
|
ok = ok && webhookOk;
|
|
265
513
|
}
|