@aywengo/mercury-fleet 0.0.1-bootstrap

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.
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Metrics rollup (docs/fleet-design.md section 12, Phase 6): scrape every child's `/metrics`, merge, serve one
3
+ * Prometheus endpoint.
4
+ *
5
+ * Two decisions worth stating, because both were tempting to get wrong:
6
+ *
7
+ * **Series are relabelled, not summed.** Summing across hosts would destroy the per-host view, which is the
8
+ * entire reason to run a fleet, and would be actively wrong for some series -- summing a gauge of lease
9
+ * timestamps or a per-status count across heterogeneous hosts produces a number that means nothing. Instead
10
+ * every series gains `host="<hostId>"` and Prometheus aggregates with `sum by (...)` at query time, which is
11
+ * what it is good at and what keeps the choice reversible.
12
+ *
13
+ * **A scrape failure is data, not an exception.** One unreachable child must not blank the fleet dashboard, so
14
+ * the rollup always renders whatever came back and publishes `mercury_fleet_scrape_success` per host. Without
15
+ * that gauge a host vanishing from the output is indistinguishable from a host with nothing to report.
16
+ */
17
+ /** The label Fleet adds to every scraped series. */
18
+ export const HOST_LABEL = 'host';
19
+ /**
20
+ * Parse the Prometheus text exposition format into families.
21
+ *
22
+ * Deliberately strict about what it does not understand: an unrecognised line is reported rather than skipped,
23
+ * because silently dropping a metric family is how a rollup ends up quietly under-reporting.
24
+ */
25
+ export function parseExposition(text) {
26
+ const families = [];
27
+ const unparsed = [];
28
+ const byName = new Map();
29
+ let pendingHelp = null;
30
+ let pendingType = null;
31
+ const family = (name) => {
32
+ let f = byName.get(name);
33
+ if (!f) {
34
+ f = { name, help: null, type: null, samples: [] };
35
+ byName.set(name, f);
36
+ families.push(f);
37
+ }
38
+ return f;
39
+ };
40
+ for (const raw of text.split('\n')) {
41
+ const line = raw.trimEnd();
42
+ if (line === '')
43
+ continue;
44
+ if (line.startsWith('#')) {
45
+ const m = /^#\s*(HELP|TYPE)\s+(\S+)\s*(.*)$/.exec(line);
46
+ if (!m) {
47
+ // A comment we do not recognise (a `# UNIT`, a human note). Not a violation, but not silently eaten.
48
+ unparsed.push(line);
49
+ continue;
50
+ }
51
+ const [, kind, name, rest] = m;
52
+ if (kind === 'HELP') {
53
+ pendingHelp = rest;
54
+ family(name).help = rest;
55
+ }
56
+ else {
57
+ pendingType = rest;
58
+ family(name).type = rest;
59
+ }
60
+ continue;
61
+ }
62
+ // Sample line: name{labels} value [timestamp]
63
+ // Metric names allow ':' as well as word characters -- recording rules produce names like
64
+ // `job:mercury_runs:rate5m`, and a regex restricted to \w would report those as unparsed and drop a valid
65
+ // family out of the rollup.
66
+ const m = /^([a-zA-Z_:][a-zA-Z0-9_:]*)(?:\{([^}]*)\})?\s+(-?[0-9eE+._infNaNinf]+)(?:\s+\S+)?\s*$/.exec(line);
67
+ if (!m) {
68
+ unparsed.push(line);
69
+ continue;
70
+ }
71
+ const [, name, labelText] = m;
72
+ // A histogram bucket carries `le`, a summary carries `quantile`; the family is the name without the suffix.
73
+ const base = name.replace(/_(bucket|sum|count)$/, '');
74
+ const f = family(base);
75
+ if (f.help === null && pendingHelp !== null)
76
+ f.help = pendingHelp;
77
+ if (f.type === null && pendingType !== null)
78
+ f.type = pendingType;
79
+ f.samples.push({ name, labels: parseLabels(labelText ?? ''), value: m[3] });
80
+ }
81
+ return { families, unparsed };
82
+ }
83
+ /**
84
+ * Split a label list. Handles escaped quotes inside values, because a label value containing a quote is exactly
85
+ * what a hostile or merely unlucky metric can produce.
86
+ */
87
+ export function parseLabels(text) {
88
+ const out = [];
89
+ let i = 0;
90
+ while (i < text.length) {
91
+ while (i < text.length && (text[i] === ',' || text[i] === ' '))
92
+ i++;
93
+ const eq = text.indexOf('=', i);
94
+ if (eq === -1)
95
+ break;
96
+ const key = text.slice(i, eq).trim();
97
+ if (text[eq + 1] !== '"')
98
+ break;
99
+ let j = eq + 2;
100
+ let value = '';
101
+ while (j < text.length) {
102
+ const ch = text[j];
103
+ if (ch === '\\' && j + 1 < text.length) {
104
+ value += text[j + 1];
105
+ j += 2;
106
+ continue;
107
+ }
108
+ if (ch === '"') {
109
+ j++;
110
+ break;
111
+ }
112
+ value += ch;
113
+ j++;
114
+ }
115
+ if (key)
116
+ out.push([key, value]);
117
+ i = j;
118
+ }
119
+ return out;
120
+ }
121
+ function escapeValue(v) {
122
+ return v.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
123
+ }
124
+ function renderLabels(labels) {
125
+ if (labels.length === 0)
126
+ return '';
127
+ const sorted = [...labels].sort(([a], [b]) => (a < b ? -1 : 1));
128
+ return '{' + sorted.map(([k, v]) => `${k}="${escapeValue(v)}"`).join(',') + '}';
129
+ }
130
+ /**
131
+ * Merge scrapes into one exposition.
132
+ *
133
+ * Pure, and returns the unparsed lines rather than logging them, so a test can assert on exactly what was
134
+ * dropped and why. A rollup that quietly loses a family is worse than one that fails loudly.
135
+ */
136
+ export function mergeRollup(results) {
137
+ const dropped = [];
138
+ // Family order follows first-seen order across hosts, and HELP/TYPE are emitted once no matter how many
139
+ // children declared them -- duplicate TYPE lines make the whole scrape invalid to Prometheus.
140
+ const order = [];
141
+ const merged = new Map();
142
+ const slot = (name) => {
143
+ let s = merged.get(name);
144
+ if (!s) {
145
+ s = { help: null, type: null, lines: [] };
146
+ merged.set(name, s);
147
+ order.push(name);
148
+ }
149
+ return s;
150
+ };
151
+ for (const scrape of results) {
152
+ const success = slot('mercury_fleet_scrape_success');
153
+ success.help ??= 'Whether Fleet could scrape this host (1) or not (0).';
154
+ success.type ??= 'gauge';
155
+ success.lines.push(`${HOST_LABEL}_placeholder`); // replaced below; keeps ordering simple
156
+ if (scrape.text === null) {
157
+ dropped.push(`${scrape.hostId}: ${scrape.reason ?? 'scrape failed'}`);
158
+ continue;
159
+ }
160
+ const { families, unparsed } = parseExposition(scrape.text);
161
+ for (const line of unparsed)
162
+ dropped.push(`${scrape.hostId}: unparsed line ${JSON.stringify(line)}`);
163
+ for (const f of families) {
164
+ const s = slot(f.name);
165
+ if (s.help === null && f.help !== null)
166
+ s.help = f.help;
167
+ if (s.type === null && f.type !== null)
168
+ s.type = f.type;
169
+ else if (s.type !== null && f.type !== null && s.type !== f.type) {
170
+ // Two children disagreeing about a type cannot both be honoured: the first wins and the disagreement is
171
+ // reported, because a silent mismatch makes one host's series unreadable in a way nobody notices.
172
+ dropped.push(`${scrape.hostId}: TYPE mismatch for ${f.name} (${s.type} kept, ${f.type} ignored)`);
173
+ }
174
+ for (const sample of f.samples) {
175
+ const keys = new Set(sample.labels.map(([k]) => k));
176
+ if (keys.size !== sample.labels.length) {
177
+ // A child emitting `t{a="1",a="2"}` produces text Prometheus rejects outright, and it rejects the
178
+ // whole scrape rather than the one line. Same reasoning as the host-label guard below: one malformed
179
+ // series must not take the fleet endpoint down.
180
+ dropped.push(`${scrape.hostId}: series ${sample.name} has duplicate label keys; skipped`);
181
+ continue;
182
+ }
183
+ if (sample.labels.some(([k]) => k === HOST_LABEL)) {
184
+ // Defensive: Mercury never labels a series `host` today. If a child ever does, emitting two labels
185
+ // with the same name produces text Prometheus rejects outright, so dropping one with a report beats
186
+ // poisoning the whole endpoint.
187
+ dropped.push(`${scrape.hostId}: series ${sample.name} already carries a ${HOST_LABEL} label; skipped`);
188
+ continue;
189
+ }
190
+ s.lines.push(`${sample.name}${renderLabels([...sample.labels, [HOST_LABEL, scrape.hostId]])} ${sample.value}`);
191
+ }
192
+ }
193
+ }
194
+ const out = [];
195
+ for (const name of order) {
196
+ const s = merged.get(name);
197
+ if (s.help !== null)
198
+ out.push(`# HELP ${name} ${s.help}`);
199
+ if (s.type !== null)
200
+ out.push(`# TYPE ${name} ${s.type}`);
201
+ if (name === 'mercury_fleet_scrape_success') {
202
+ for (const scrape of results) {
203
+ out.push(`mercury_fleet_scrape_success${renderLabels([[HOST_LABEL, scrape.hostId]])} ${scrape.text === null ? 0 : 1}`);
204
+ }
205
+ continue;
206
+ }
207
+ out.push(...s.lines.filter((l) => l !== `${HOST_LABEL}_placeholder`));
208
+ }
209
+ return { text: out.join('\n') + '\n', dropped };
210
+ }
211
+ /** Scrape every named host in parallel. Each scrape is independently bounded by the child client's timeout. */
212
+ export async function scrapeAll(deps, hostIds) {
213
+ return Promise.all(hostIds.map(async (hostId) => {
214
+ const host = deps.registry.get(hostId);
215
+ if (!host)
216
+ return { hostId, text: null, reason: 'not in the registry' };
217
+ const token = deps.resolveToken(host.credentialRef);
218
+ if (!token)
219
+ return { hostId, text: null, reason: 'credential unavailable' };
220
+ const res = await deps.child.getMetrics({ baseUrl: host.baseUrl, token });
221
+ if (res.kind !== 'ok') {
222
+ return { hostId, text: null, reason: res.kind === 'rejected' ? `child answered ${res.status}` : res.reason };
223
+ }
224
+ return { hostId, text: res.value, reason: null };
225
+ }));
226
+ }
package/dist/probe.js ADDED
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Probe one Mercury host over HTTP.
3
+ *
4
+ * Three endpoints, each answering a different question, and the outcomes are kept separate on purpose:
5
+ *
6
+ * GET /healthz is anything there, and is it Mercury? (public, no credential)
7
+ * GET /healthz/workers is it actually serving work? (public, no credential)
8
+ * GET /api/agents is our credential good, and what can it run? (requires credential)
9
+ *
10
+ * Collapsing these into "down" is the mistake the design calls out in section 7: a host that refuses our
11
+ * token, a host whose worker is not running, and a host that is unplugged all need different fixes, and a
12
+ * single status string would send the operator to the wrong one.
13
+ */
14
+ /**
15
+ * Strip terminal control sequences from text a CHILD produced.
16
+ *
17
+ * Fleet's trust boundary is HTTP: it may be pointed at a Mercury it did not build, and any child it can
18
+ * reach can answer with arbitrary JSON. Without this, a hostile or compromised child returns
19
+ * `{"error": "\u001b]0;pwned\u0007..."}` from /healthz/workers and writes raw escapes into the operator's
20
+ * terminal -- enough to set the window title, clear the screen, or print a line that looks like Fleet's own
21
+ * output. Verified against a live fake before this function existed.
22
+ *
23
+ * Applied where child text enters the system rather than at each print site, so a future command cannot
24
+ * forget to sanitize. Control characters are removed rather than escaped: `detail` is a single-line
25
+ * diagnostic, and a newline is exactly how a child would forge a second one.
26
+ */
27
+ export function stripTerminalControls(raw) {
28
+ return raw
29
+ .replace(/\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g, '') // OSC ... BEL or ST
30
+ .replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, '') // CSI ... final byte
31
+ .replace(/[\u0000-\u001f\u007f]/g, ''); // remaining C0 (incl. CR/LF/TAB) and DEL
32
+ }
33
+ /**
34
+ * One HTTP call with a hard deadline.
35
+ *
36
+ * The timeout is per request rather than per probe: a host that answers /healthz instantly and then hangs on
37
+ * /api/agents must still report what /healthz told us, and must not hold the sweep open for three times the
38
+ * timeout.
39
+ */
40
+ async function call(fetchImpl, url, token, timeoutMs) {
41
+ const headers = { accept: 'application/json' };
42
+ if (token)
43
+ headers.authorization = `Bearer ${token}`;
44
+ let res;
45
+ try {
46
+ res = await fetchImpl(url, { method: 'GET', headers, signal: AbortSignal.timeout(timeoutMs) });
47
+ }
48
+ catch (err) {
49
+ const e = err;
50
+ // AbortSignal.timeout surfaces as TimeoutError; a plain abort would be named AbortError. Naming the
51
+ // difference matters in the report: a slow host and a refused host are different problems.
52
+ const timedOut = e.name === 'TimeoutError' || e.name === 'AbortError';
53
+ return { status: 0, json: null, transportError: e, timedOut };
54
+ }
55
+ let json = null;
56
+ try {
57
+ json = await res.json();
58
+ }
59
+ catch {
60
+ // Non-JSON body. Left null so the caller can report the status, which is the useful part.
61
+ }
62
+ return { status: res.status, json, transportError: null, timedOut: false };
63
+ }
64
+ function describeTransportError(err) {
65
+ const cause = err.cause;
66
+ if (cause?.code)
67
+ return `${cause.code}`;
68
+ return err.message.slice(0, 200);
69
+ }
70
+ /** Probe a host. Never throws: every failure mode becomes a classified outcome, because a sweep must
71
+ * report on all hosts rather than abort on the first bad one. */
72
+ export async function probeHost(target, fetchImpl = fetch) {
73
+ const base = target.baseUrl;
74
+ const empty = {
75
+ outcome: 'unreachable',
76
+ detail: null,
77
+ activeRuns: null,
78
+ queueDepth: null,
79
+ workerCount: null,
80
+ workerId: null,
81
+ agents: null,
82
+ lastError: null,
83
+ };
84
+ // 1) Liveness. Nothing else is meaningful until we know something is listening.
85
+ const health = await call(fetchImpl, `${base}/healthz`, null, target.timeoutMs);
86
+ if (health.transportError) {
87
+ return {
88
+ ...empty,
89
+ outcome: health.timedOut ? 'timeout' : 'unreachable',
90
+ detail: health.timedOut
91
+ ? `no response within ${target.timeoutMs} ms`
92
+ : describeTransportError(health.transportError),
93
+ lastError: health.transportError.message.slice(0, 300),
94
+ };
95
+ }
96
+ if (health.status === 404) {
97
+ return {
98
+ ...empty,
99
+ outcome: 'not_mercury',
100
+ detail: 'answered but has no /healthz, so this is not a Mercury API',
101
+ lastError: `HTTP 404 from ${base}/healthz`,
102
+ };
103
+ }
104
+ if (health.status < 200 || health.status >= 300) {
105
+ return {
106
+ ...empty,
107
+ outcome: 'http_error',
108
+ detail: `HTTP ${health.status} from /healthz`,
109
+ lastError: `HTTP ${health.status} from ${base}/healthz`,
110
+ };
111
+ }
112
+ // 2) Serving. /healthz proves the API process; this proves a worker is claiming work.
113
+ const workers = await call(fetchImpl, `${base}/healthz/workers`, null, target.timeoutMs);
114
+ let activeRuns = null;
115
+ let queueDepth = null;
116
+ let workerCount = null;
117
+ let workerId = null;
118
+ let notServingDetail = null;
119
+ let capacityUnknown = null;
120
+ if (workers.transportError) {
121
+ return {
122
+ ...empty,
123
+ outcome: workers.timedOut ? 'timeout' : 'unreachable',
124
+ detail: `/healthz answered but /healthz/workers failed: ${describeTransportError(workers.transportError)}`,
125
+ lastError: workers.transportError.message.slice(0, 300),
126
+ };
127
+ }
128
+ if (workers.status === 503) {
129
+ // Reachable and serving HTTP, but no queue wired up: this Mercury cannot execute anything. Distinct
130
+ // from unreachable because the fix is operator-side configuration, not a network or host problem.
131
+ notServingDetail = stripTerminalControls(workers.json?.error ?? 'queue not configured');
132
+ }
133
+ else if (workers.status === 404) {
134
+ // An older Mercury that predates the endpoint. It is still perfectly dispatchable, so this is NOT
135
+ // not_serving: that outcome means "cannot execute anything", and claiming it here would take a healthy
136
+ // host out of rotation because of a missing telemetry route.
137
+ capacityUnknown = 'no /healthz/workers endpoint; capacity unknown (older Mercury?)';
138
+ }
139
+ else if (workers.status < 200 || workers.status >= 300) {
140
+ return {
141
+ ...empty,
142
+ outcome: 'http_error',
143
+ detail: `HTTP ${workers.status} from /healthz/workers`,
144
+ lastError: `HTTP ${workers.status} from ${base}/healthz/workers`,
145
+ };
146
+ }
147
+ else {
148
+ const body = (workers.json ?? {});
149
+ const list = Array.isArray(body.workers) ? body.workers : [];
150
+ workerCount = list.length;
151
+ // Sum rather than take one: a host may run several workers, and "how busy is this machine" is the
152
+ // question routing will eventually ask.
153
+ activeRuns = list.reduce((sum, w) => sum + (typeof w.activeRuns === 'number' ? w.activeRuns : 0), 0);
154
+ workerId = list.map((w) => w.workerId).filter((x) => typeof x === 'string').join(',') || null;
155
+ queueDepth = typeof body.queueDepth === 'number' ? body.queueDepth : null;
156
+ }
157
+ // 3) Credential and capability. This is the only authenticated call, so it is where a bad token shows up.
158
+ const agents = await call(fetchImpl, `${base}/api/agents`, target.token, target.timeoutMs);
159
+ let agentList = null;
160
+ let agentsDetail = null;
161
+ let unauthorized = false;
162
+ if (agents.transportError) {
163
+ return {
164
+ ...empty,
165
+ outcome: agents.timedOut ? 'timeout' : 'unreachable',
166
+ detail: `/healthz answered but /api/agents failed: ${describeTransportError(agents.transportError)}`,
167
+ lastError: agents.transportError.message.slice(0, 300),
168
+ };
169
+ }
170
+ if (agents.status === 401 || agents.status === 403) {
171
+ unauthorized = true;
172
+ agentsDetail = `HTTP ${agents.status} from /api/agents: the host is reachable but this credential was ` +
173
+ `rejected. Check credential_ref; do not assume the host is down.`;
174
+ }
175
+ else if (agents.status >= 200 && agents.status < 300) {
176
+ const raw = agents.json?.agents;
177
+ agentList = Array.isArray(raw)
178
+ ? raw.filter((x) => typeof x === 'string').map(stripTerminalControls)
179
+ : [];
180
+ }
181
+ else {
182
+ agentsDetail = `HTTP ${agents.status} from /api/agents`;
183
+ }
184
+ // Precedence: a host we cannot authenticate against is reported as unauthorized even though it is
185
+ // healthy, because that is the condition blocking Fleet from using it.
186
+ if (unauthorized) {
187
+ return { ...empty, outcome: 'unauthorized', detail: agentsDetail, agents: null,
188
+ activeRuns, queueDepth, workerCount, workerId, lastError: agentsDetail };
189
+ }
190
+ if (notServingDetail) {
191
+ return { ...empty, outcome: 'not_serving', detail: notServingDetail, agents: agentList,
192
+ activeRuns, queueDepth, workerCount, workerId, lastError: notServingDetail };
193
+ }
194
+ if (agentsDetail) {
195
+ return { ...empty, outcome: 'http_error', detail: agentsDetail, agents: agentList,
196
+ activeRuns, queueDepth, workerCount, workerId, lastError: agentsDetail };
197
+ }
198
+ return {
199
+ outcome: 'ok',
200
+ detail: capacityUnknown,
201
+ activeRuns,
202
+ queueDepth,
203
+ workerCount,
204
+ workerId,
205
+ agents: agentList,
206
+ lastError: null,
207
+ };
208
+ }
209
+ /** Run a probe and shape it into a registry record. */
210
+ export async function probeAndRecord(target, fetchImpl = fetch) {
211
+ const r = await probeHost(target, fetchImpl);
212
+ return {
213
+ hostId: target.hostId,
214
+ outcome: r.outcome,
215
+ detail: r.detail,
216
+ activeRuns: r.activeRuns,
217
+ queueDepth: r.queueDepth,
218
+ workerCount: r.workerCount,
219
+ workerId: r.workerId,
220
+ agents: r.agents,
221
+ probedAt: new Date().toISOString(),
222
+ lastError: r.lastError,
223
+ };
224
+ }
package/dist/prober.js ADDED
@@ -0,0 +1,94 @@
1
+ /**
2
+ * The sweep: probe every enabled host on a timer and write the results to the cache table.
3
+ *
4
+ * Design section 12 puts this in Phase 0 alongside the registry. Two properties matter more than the
5
+ * details. Hosts are probed concurrently, because a serial sweep over a fleet with one dead host spends its
6
+ * whole budget waiting on the dead one. And the timer is unref'd, so an imported prober never keeps a
7
+ * process alive by itself.
8
+ */
9
+ import { probeAndRecord } from "./probe.js";
10
+ export function createProber(opts) {
11
+ let timer = null;
12
+ let sweeping = null;
13
+ async function runSweep() {
14
+ const hosts = opts.registry.list().filter((h) => h.enabled);
15
+ return await (async () => {
16
+ const results = await Promise.all(hosts.map(async (host) => {
17
+ let token;
18
+ try {
19
+ token = opts.resolveToken(host.credentialRef);
20
+ }
21
+ catch (err) {
22
+ // A missing ref is Fleet's own misconfiguration, not the host's fault. Recorded as
23
+ // unauthorized because the actionable message is the same: the host may be perfectly healthy,
24
+ // and Fleet is the side that cannot prove otherwise.
25
+ const detail = `credential ref "${host.credentialRef}" could not be resolved: ` +
26
+ `${err.message}. Host not contacted.`;
27
+ const rec = {
28
+ hostId: host.id, outcome: 'unauthorized', detail, activeRuns: null, queueDepth: null,
29
+ workerCount: null, workerId: null, agents: null,
30
+ probedAt: new Date().toISOString(), lastError: detail,
31
+ };
32
+ opts.registry.recordProbe(rec);
33
+ return rec;
34
+ }
35
+ try {
36
+ const rec = await probeAndRecord({ hostId: host.id, baseUrl: host.baseUrl, token, timeoutMs: opts.timeoutMs }, opts.fetchImpl);
37
+ opts.registry.recordProbe(rec);
38
+ return rec;
39
+ }
40
+ catch (err) {
41
+ // probeAndRecord classifies rather than throwing, so this is a bug or an unexpected rejection.
42
+ // Swallowing it silently would leave the cache showing a stale "up".
43
+ const e = err;
44
+ opts.onError?.(host.id, e);
45
+ const rec = {
46
+ hostId: host.id, outcome: 'http_error', detail: `probe raised: ${e.message}`.slice(0, 300),
47
+ activeRuns: null, queueDepth: null, workerCount: null, workerId: null, agents: null,
48
+ probedAt: new Date().toISOString(), lastError: e.message.slice(0, 300),
49
+ };
50
+ opts.registry.recordProbe(rec);
51
+ return rec;
52
+ }
53
+ }));
54
+ return results;
55
+ })();
56
+ }
57
+ /**
58
+ * Overlap guard: a sweep that outlives its interval (many hosts, all slow) must not stack up, or a fleet
59
+ * behind a saturated network spawns a fresh sweep every interval forever.
60
+ *
61
+ * The flag is assigned synchronously, before any await. It used to be set after an `await import()`, so
62
+ * two concurrent callers both read the flag as unset and both started a sweep -- which the overlap test
63
+ * caught as two sweeps one millisecond apart.
64
+ */
65
+ function sweepOnce() {
66
+ if (sweeping)
67
+ return sweeping;
68
+ sweeping = runSweep().finally(() => {
69
+ sweeping = null;
70
+ });
71
+ return sweeping;
72
+ }
73
+ return {
74
+ sweepOnce,
75
+ start() {
76
+ if (timer)
77
+ return;
78
+ timer = setInterval(() => {
79
+ // The sweep never rejects (every branch records a result), but an unexpected rejection must not
80
+ // become an unhandled rejection that takes the process down.
81
+ void sweepOnce().catch(() => { });
82
+ }, opts.intervalMs);
83
+ timer.unref?.();
84
+ },
85
+ stop() {
86
+ if (timer)
87
+ clearInterval(timer);
88
+ timer = null;
89
+ },
90
+ get running() {
91
+ return timer !== null;
92
+ },
93
+ };
94
+ }
package/dist/redact.js ADDED
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Redaction for Fleet's own logs.
3
+ *
4
+ * Fleet cannot import Mercury's redactor -- the coupling rule in docs/fleet-design.md section 11 forbids it
5
+ * -- and this is not cosmetic parity. Fleet holds a credential for every Mercury on the network, so a single
6
+ * bearer token reaching a log file is a fleet-wide compromise sitting in a file with journald's retention.
7
+ * The realistic path is mundane: an HTTP client error can echo request headers, and a stack trace that
8
+ * carries a request object carries the Authorization header with it.
9
+ *
10
+ * Two mechanisms, because they fail differently:
11
+ *
12
+ * - a generic pattern pass catches `Authorization: Bearer ...` wherever it appears, including in text
13
+ * written by code that forgot to be careful;
14
+ * - an exact-value pass over the known secrets catches a token that appears BARE, with no header name to
15
+ * anchor on. This is why the store is seeded at startup from the credential file: a pattern pass alone
16
+ * cannot recognise a secret it has no label for.
17
+ */
18
+ export const REDACTED = '[REDACTED]';
19
+ /**
20
+ * Header-shaped secrets, matched structurally so unknown token formats are still caught.
21
+ *
22
+ * The value class stops at whitespace and quotes so a match cannot run past the end of the header into
23
+ * surrounding text and redact a whole line, which would turn a leak into an unreadable log.
24
+ */
25
+ const PATTERNS = [
26
+ /\b(authorization|proxy-authorization|x-api-key|x-auth-token)\b\s*[:=]\s*(?:bearer|basic|token)?\s*["']?[A-Za-z0-9._~+/=-]{8,}/gi,
27
+ /\bbearer\s+[A-Za-z0-9._~+/=-]{8,}/gi,
28
+ // A URL that embedded credentials, e.g. after a fetch failure echoes the request target.
29
+ /\b[a-z][a-z0-9+.-]*:\/\/[^/\s:@"'`]+:[^@\s@"'`]+@/gi,
30
+ ];
31
+ export function createRedactor(secrets = []) {
32
+ // Longest first. Redacting a short secret before a longer one that contains it would leave the tail of
33
+ // the long secret visible, which is still enough to correlate a leak.
34
+ const values = [...new Set([...secrets].filter((s) => typeof s === 'string' && s.length >= 4))]
35
+ .sort((a, b) => b.length - a.length);
36
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
37
+ const literal = values.length === 0 ? null : new RegExp(values.map(escapeRe).join('|'), 'g');
38
+ return {
39
+ redact(text) {
40
+ if (typeof text !== 'string' || text.length === 0)
41
+ return text;
42
+ let out = text;
43
+ for (const re of PATTERNS) {
44
+ out = out.replace(re, (m) => {
45
+ const sep = m.match(/[:=]/);
46
+ // Keep the header name so the log still says WHAT leaked, without saying what it leaked.
47
+ return sep ? `${m.slice(0, m.indexOf(sep[0]))}=${REDACTED}`.replace('=', ': ') : REDACTED;
48
+ });
49
+ }
50
+ if (literal)
51
+ out = out.replace(literal, REDACTED);
52
+ return out;
53
+ },
54
+ get seededCount() {
55
+ return values.length;
56
+ },
57
+ };
58
+ }
59
+ /**
60
+ * Build the redactor a running Fleet service needs.
61
+ *
62
+ * Extracted from the serve path so that "every secret class Fleet holds is seeded" is a tested invariant
63
+ * rather than a comment. It used to be a comment that was wrong: the code seeded child credentials only,
64
+ * so a bare caller or admin token reaching a log line through an exception message went unredacted.
65
+ */
66
+ export function createServiceRedactor(sources) {
67
+ return createRedactor([
68
+ ...sources.childSecrets,
69
+ ...sources.callerTokens,
70
+ ...(sources.adminToken ? [sources.adminToken] : []),
71
+ ]);
72
+ }