@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.
- package/CHANGELOG.md +55 -0
- package/LICENSE +21 -0
- package/README.md +144 -0
- package/dist/auth.js +89 -0
- package/dist/bindings.js +201 -0
- package/dist/child.js +137 -0
- package/dist/cli.js +371 -0
- package/dist/config.js +80 -0
- package/dist/credentials.js +93 -0
- package/dist/db.js +162 -0
- package/dist/dispatch.js +176 -0
- package/dist/events.js +114 -0
- package/dist/http.js +91 -0
- package/dist/interact.js +88 -0
- package/dist/logger.js +50 -0
- package/dist/metrics.js +226 -0
- package/dist/probe.js +224 -0
- package/dist/prober.js +94 -0
- package/dist/redact.js +72 -0
- package/dist/registry.js +197 -0
- package/dist/routing.js +219 -0
- package/dist/server.js +540 -0
- package/dist/stream.js +132 -0
- package/dist/sweep.js +183 -0
- package/dist/version.js +7 -0
- package/package.json +25 -0
package/dist/db.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fleet's own SQLite database. Same engine and same operational shape as Mercury, deliberately no new
|
|
3
|
+
* infrastructure (docs/fleet-design.md section 5).
|
|
4
|
+
*
|
|
5
|
+
* The schema is split by what a crash costs, and that split is structural rather than a comment
|
|
6
|
+
* convention:
|
|
7
|
+
*
|
|
8
|
+
* TRUTH -- hosts. Losing it means Fleet does not know what it may talk to.
|
|
9
|
+
* CACHE -- host_probe. Rebuildable in one sweep, so it is never worth backing up.
|
|
10
|
+
*
|
|
11
|
+
* Phase 0 has no dispatch, so fleet_runs/run_state (the other truth table, and the one the design says
|
|
12
|
+
* must be backed up as seriously as a Mercury database) arrive with Phase 1.
|
|
13
|
+
*/
|
|
14
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
15
|
+
import { mkdirSync } from 'node:fs';
|
|
16
|
+
import { dirname } from 'node:path';
|
|
17
|
+
const MIGRATIONS = [
|
|
18
|
+
{
|
|
19
|
+
version: 1,
|
|
20
|
+
sql: `
|
|
21
|
+
CREATE TABLE IF NOT EXISTS hosts (
|
|
22
|
+
id TEXT PRIMARY KEY,
|
|
23
|
+
base_url TEXT NOT NULL,
|
|
24
|
+
credential_ref TEXT NOT NULL,
|
|
25
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
26
|
+
labels TEXT NOT NULL DEFAULT '{}',
|
|
27
|
+
local_paths TEXT NOT NULL DEFAULT '[]',
|
|
28
|
+
agents_cache TEXT NOT NULL DEFAULT '[]',
|
|
29
|
+
added_at TEXT NOT NULL,
|
|
30
|
+
last_seen_at TEXT
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
-- Probe results are a cache, so they live in their own table rather than as extra columns on
|
|
34
|
+
-- hosts. That keeps the truth/cache line visible in the schema: deleting this table is always
|
|
35
|
+
-- safe and costs one sweep, while deleting hosts is not.
|
|
36
|
+
CREATE TABLE IF NOT EXISTS host_probe (
|
|
37
|
+
host_id TEXT PRIMARY KEY REFERENCES hosts(id) ON DELETE CASCADE,
|
|
38
|
+
-- One of: ok | unreachable | unauthorized | not_mercury | not_serving | http_error | timeout.
|
|
39
|
+
-- Distinct values on purpose: "down" would collapse failures that need different fixes.
|
|
40
|
+
outcome TEXT NOT NULL,
|
|
41
|
+
detail TEXT,
|
|
42
|
+
-- Live capacity, present only on a successful probe.
|
|
43
|
+
active_runs INTEGER,
|
|
44
|
+
queue_depth INTEGER,
|
|
45
|
+
worker_count INTEGER,
|
|
46
|
+
worker_id TEXT,
|
|
47
|
+
agents TEXT,
|
|
48
|
+
probed_at TEXT NOT NULL,
|
|
49
|
+
last_error TEXT
|
|
50
|
+
);
|
|
51
|
+
`,
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
version: 2,
|
|
55
|
+
sql: `
|
|
56
|
+
-- TRUTH. Section 5 is explicit about what losing this costs: orphaned Runs on remote machines that
|
|
57
|
+
-- nobody can find. child_run_id is nullable, and the NULL is not a placeholder for "failed" -- it
|
|
58
|
+
-- means Fleet asked a child for a Run and has not yet learned the answer. Section 7's rule that
|
|
59
|
+
-- UNKNOWN is not FAILED is expressed by that column staying NULL rather than by a status string.
|
|
60
|
+
CREATE TABLE IF NOT EXISTS fleet_runs (
|
|
61
|
+
fleet_run_id TEXT PRIMARY KEY,
|
|
62
|
+
-- Deliberately NOT ON DELETE CASCADE. A cascade here would mean that removing a host from the
|
|
63
|
+
-- registry silently deletes the record of Runs still executing on it. That is section 5's worst
|
|
64
|
+
-- failure -- orphaned Runs nobody can find -- reachable through an ordinary registry edit. The FK
|
|
65
|
+
-- is RESTRICT by omission, and HostRegistry.remove turns it into an explanation.
|
|
66
|
+
host_id TEXT NOT NULL REFERENCES hosts(id),
|
|
67
|
+
child_run_id TEXT,
|
|
68
|
+
-- The caller Fleet authenticated, never a value taken from the request body. Idempotency is scoped
|
|
69
|
+
-- to it below: a globally unique token would let one caller who guessed another's token receive
|
|
70
|
+
-- that caller's run id, host and status.
|
|
71
|
+
owner_id TEXT NOT NULL,
|
|
72
|
+
client_token TEXT,
|
|
73
|
+
requested TEXT NOT NULL,
|
|
74
|
+
created_at TEXT NOT NULL,
|
|
75
|
+
bound_at TEXT,
|
|
76
|
+
UNIQUE (host_id, child_run_id),
|
|
77
|
+
-- Scoped rather than global: the same token string from two callers is two distinct keys.
|
|
78
|
+
UNIQUE (owner_id, client_token)
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
-- CACHE ONLY: rebuildable by re-reading the child. status holds the child's own status string, or
|
|
82
|
+
-- UNKNOWN when Fleet could not reach the child -- which is deliberately not the same value.
|
|
83
|
+
CREATE TABLE IF NOT EXISTS run_state (
|
|
84
|
+
fleet_run_id TEXT PRIMARY KEY REFERENCES fleet_runs(fleet_run_id) ON DELETE CASCADE,
|
|
85
|
+
status TEXT NOT NULL,
|
|
86
|
+
cursor INTEGER NOT NULL DEFAULT 0,
|
|
87
|
+
last_seen_at TEXT,
|
|
88
|
+
last_error TEXT
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
CREATE INDEX IF NOT EXISTS idx_fleet_runs_pending ON fleet_runs(host_id) WHERE child_run_id IS NULL;
|
|
92
|
+
`,
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
version: 3,
|
|
96
|
+
sql: `
|
|
97
|
+
-- CACHE, like host_probe: this mirrors what the child already has, so deleting it costs a re-read and
|
|
98
|
+
-- nothing else. The child stays the source of truth (section 2). Keeping it in its own table is what
|
|
99
|
+
-- makes that claim checkable rather than aspirational.
|
|
100
|
+
--
|
|
101
|
+
-- Metadata only by default (section 8): agent output can be large, and mirroring bodies turns Fleet
|
|
102
|
+
-- into a second copy of every Run's transcript with all the retention and secret questions that raises.
|
|
103
|
+
-- payload is therefore NULL unless the host opted in.
|
|
104
|
+
CREATE TABLE IF NOT EXISTS fleet_events (
|
|
105
|
+
fleet_run_id TEXT NOT NULL REFERENCES fleet_runs(fleet_run_id) ON DELETE CASCADE,
|
|
106
|
+
-- The child's own per-Run sequence, not a Fleet-assigned one. Re-mirroring the same event must be a
|
|
107
|
+
-- no-op, and the child's sequence is the only stable identity available.
|
|
108
|
+
sequence INTEGER NOT NULL,
|
|
109
|
+
type TEXT NOT NULL,
|
|
110
|
+
timestamp TEXT NOT NULL,
|
|
111
|
+
payload TEXT,
|
|
112
|
+
PRIMARY KEY (fleet_run_id, sequence)
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
CREATE INDEX IF NOT EXISTS fleet_events_run_seq
|
|
116
|
+
ON fleet_events (fleet_run_id, sequence);
|
|
117
|
+
|
|
118
|
+
-- Opt-in per host, because the right default differs by machine: a laptop Fleet may mirror freely where
|
|
119
|
+
-- a shared instance should not hold transcripts at all.
|
|
120
|
+
ALTER TABLE hosts ADD COLUMN mirror_bodies INTEGER NOT NULL DEFAULT 0;
|
|
121
|
+
|
|
122
|
+
-- Set once a mirror pass finds nothing more to read. Without it there is no way to leave a finished Run
|
|
123
|
+
-- alone: skipping terminal Runs outright means a Run that ended before the first sweep never has its
|
|
124
|
+
-- log mirrored at all, and Fleet could never show how anything finished.
|
|
125
|
+
ALTER TABLE run_state ADD COLUMN events_drained INTEGER NOT NULL DEFAULT 0;
|
|
126
|
+
`,
|
|
127
|
+
},
|
|
128
|
+
];
|
|
129
|
+
/** Open Fleet's database and apply any pending migrations. */
|
|
130
|
+
export function openFleetDb(path) {
|
|
131
|
+
if (path !== ':memory:') {
|
|
132
|
+
const dir = dirname(path);
|
|
133
|
+
if (dir && dir !== '.')
|
|
134
|
+
mkdirSync(dir, { recursive: true });
|
|
135
|
+
}
|
|
136
|
+
const db = new DatabaseSync(path);
|
|
137
|
+
// Fleet may probe hosts from a timer while a CLI command reads, so readers must not block on the writer.
|
|
138
|
+
db.exec('PRAGMA journal_mode = WAL');
|
|
139
|
+
db.exec('PRAGMA foreign_keys = ON');
|
|
140
|
+
db.exec('CREATE TABLE IF NOT EXISTS fleet_meta (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)');
|
|
141
|
+
const done = new Set(db.prepare('SELECT version FROM fleet_meta').all().map((r) => r.version));
|
|
142
|
+
const applied = [];
|
|
143
|
+
const insert = db.prepare('INSERT INTO fleet_meta (version, applied_at) VALUES (?, ?)');
|
|
144
|
+
for (const m of MIGRATIONS) {
|
|
145
|
+
if (done.has(m.version))
|
|
146
|
+
continue;
|
|
147
|
+
// One transaction per migration: a half-applied migration is worse than none, because the version row
|
|
148
|
+
// would claim the schema exists when only part of it does.
|
|
149
|
+
db.exec('BEGIN');
|
|
150
|
+
try {
|
|
151
|
+
db.exec(m.sql);
|
|
152
|
+
insert.run(m.version, new Date().toISOString());
|
|
153
|
+
db.exec('COMMIT');
|
|
154
|
+
applied.push(m.version);
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
db.exec('ROLLBACK');
|
|
158
|
+
throw new Error(`fleet migration ${m.version} failed: ${err.message}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return { db, appliedVersions: applied };
|
|
162
|
+
}
|
package/dist/dispatch.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dispatch: submit a task to a named host and record the binding.
|
|
3
|
+
*
|
|
4
|
+
* Phase 1 of docs/fleet-design.md section 12. No routing -- the caller names the host. What this phase has to
|
|
5
|
+
* prove is the binding model, idempotency across restarts, and crash recovery.
|
|
6
|
+
*
|
|
7
|
+
* The ordering is the whole design. A binding is written BEFORE the child is contacted, and the child call
|
|
8
|
+
* carries an idempotency key derived from Fleet's own run id. So the dangerous interleaving -- child created
|
|
9
|
+
* a Run, the response was lost -- resolves on the next attempt to the SAME child Run rather than to a second
|
|
10
|
+
* one or to an orphan.
|
|
11
|
+
*/
|
|
12
|
+
import { randomUUID } from 'node:crypto';
|
|
13
|
+
import { UNKNOWN } from "./bindings.js";
|
|
14
|
+
export class DispatchError extends Error {
|
|
15
|
+
status;
|
|
16
|
+
constructor(status, message) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.status = status;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function newFleetRunId() {
|
|
22
|
+
// A distinct prefix from a child's run ids so an id is never ambiguous about which system issued it.
|
|
23
|
+
return `fr_${randomUUID().replace(/-/g, '')}`;
|
|
24
|
+
}
|
|
25
|
+
/** Resolve a host the caller may use, or explain why not. */
|
|
26
|
+
export function resolveHost(deps, hostId) {
|
|
27
|
+
const host = deps.registry.get(hostId);
|
|
28
|
+
if (!host)
|
|
29
|
+
throw new DispatchError(404, `no such host: ${hostId}`);
|
|
30
|
+
if (!host.enabled) {
|
|
31
|
+
throw new DispatchError(409, `host ${hostId} is disabled; enable it before submitting work to it`);
|
|
32
|
+
}
|
|
33
|
+
return { baseUrl: host.baseUrl, token: deps.resolveToken(host.credentialRef) };
|
|
34
|
+
}
|
|
35
|
+
export async function submitRun(deps, input) {
|
|
36
|
+
// Caller-level idempotency, checked before anything is written. This is what makes a retry after a
|
|
37
|
+
// transport failure cheap: the same token returns the same binding, so no second child call happens and
|
|
38
|
+
// no second Run is paid for.
|
|
39
|
+
if (input.clientToken) {
|
|
40
|
+
const existing = deps.bindings.findByClientToken(input.ownerId, input.clientToken);
|
|
41
|
+
if (existing) {
|
|
42
|
+
// Reuse is only correct if the caller means the same thing it meant the first time. Silently
|
|
43
|
+
// returning a binding for a DIFFERENT host would suppress dispatch to the host the caller just named
|
|
44
|
+
// and hand back a Run running somewhere else.
|
|
45
|
+
if (existing.hostId !== input.hostId) {
|
|
46
|
+
throw new DispatchError(409, `idempotency token was already used for host ${existing.hostId}, not ${input.hostId}. ` +
|
|
47
|
+
`Reuse would return the existing Run rather than dispatch to the host you named; use a new token.`);
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
binding: existing,
|
|
51
|
+
reused: true,
|
|
52
|
+
pending: existing.childRunId === null,
|
|
53
|
+
// Accurate about WHEN this resolves: Phase 1 recovers at startup or on an explicit call. The
|
|
54
|
+
// background sweep is Phase 2, and claiming otherwise here would send an operator to wait for
|
|
55
|
+
// something that does not exist yet.
|
|
56
|
+
note: existing.childRunId === null
|
|
57
|
+
? 'existing binding is still awaiting its child answer; it is resolved at Fleet startup or via a manual recovery'
|
|
58
|
+
: undefined,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const host = resolveHost(deps, input.hostId);
|
|
63
|
+
const fleetRunId = newFleetRunId();
|
|
64
|
+
const binding = deps.bindings.createPending({
|
|
65
|
+
fleetRunId,
|
|
66
|
+
hostId: input.hostId,
|
|
67
|
+
ownerId: input.ownerId,
|
|
68
|
+
requested: input.requested,
|
|
69
|
+
clientToken: input.clientToken ?? null,
|
|
70
|
+
});
|
|
71
|
+
const result = await deps.child.createRun(host, input.requested, fleetRunId);
|
|
72
|
+
if (result.kind === 'ok') {
|
|
73
|
+
const bound = deps.bindings.bind(fleetRunId, result.value.runId);
|
|
74
|
+
deps.bindings.recordState({
|
|
75
|
+
fleetRunId, status: result.value.status, cursor: 0,
|
|
76
|
+
lastSeenAt: new Date().toISOString(), lastError: null,
|
|
77
|
+
});
|
|
78
|
+
return { binding: bound, reused: false, pending: false };
|
|
79
|
+
}
|
|
80
|
+
if (result.kind === 'rejected') {
|
|
81
|
+
// A 4xx means the child refused and created nothing, so the binding is dead weight and removing it is
|
|
82
|
+
// safe. This is the ONLY branch that discards.
|
|
83
|
+
deps.bindings.discard(fleetRunId);
|
|
84
|
+
throw new DispatchError(result.status === 404 ? 404 : 400, `host ${input.hostId} rejected the submission (HTTP ${result.status}${result.detail ? `: ${result.detail}` : ''})`);
|
|
85
|
+
}
|
|
86
|
+
// Unknown: transport failure or 5xx. A Run may exist and be running right now. The binding stays, marked
|
|
87
|
+
// UNKNOWN, and recovery resolves it later. Reporting failure here would be the section 7 mistake.
|
|
88
|
+
deps.bindings.recordState({
|
|
89
|
+
fleetRunId, status: UNKNOWN, cursor: 0,
|
|
90
|
+
lastSeenAt: null, lastError: result.reason,
|
|
91
|
+
});
|
|
92
|
+
return {
|
|
93
|
+
binding: deps.bindings.get(fleetRunId),
|
|
94
|
+
reused: false,
|
|
95
|
+
pending: true,
|
|
96
|
+
note: `child answer unknown (${result.reason}); the Run may already exist and will be resolved on recovery`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Crash recovery: re-derive every binding whose child answer was never recorded.
|
|
101
|
+
*
|
|
102
|
+
* Re-sends the stored payload with the SAME idempotency key. If the original request reached the child, the
|
|
103
|
+
* child's dedupe returns the Run it already created; if it never arrived, this creates it. Either way Fleet
|
|
104
|
+
* ends up able to name the Run, which is the point of the table.
|
|
105
|
+
*/
|
|
106
|
+
export async function recoverPending(deps) {
|
|
107
|
+
const pending = deps.bindings.pending();
|
|
108
|
+
let resolved = 0;
|
|
109
|
+
for (const binding of pending) {
|
|
110
|
+
let host;
|
|
111
|
+
try {
|
|
112
|
+
host = resolveHost(deps, binding.hostId);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
// Host removed or disabled since the binding was made. Leave it pending: deleting the binding here is
|
|
116
|
+
// exactly the orphaning this function exists to prevent.
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const result = await deps.child.createRun(host, binding.requested, binding.fleetRunId);
|
|
120
|
+
if (result.kind === 'ok') {
|
|
121
|
+
deps.bindings.bind(binding.fleetRunId, result.value.runId);
|
|
122
|
+
deps.bindings.recordState({
|
|
123
|
+
fleetRunId: binding.fleetRunId, status: result.value.status, cursor: 0,
|
|
124
|
+
lastSeenAt: new Date().toISOString(), lastError: null,
|
|
125
|
+
});
|
|
126
|
+
resolved++;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (result.kind === 'rejected') {
|
|
130
|
+
// The child says it never had this key and refuses the payload now. Nothing is running.
|
|
131
|
+
deps.bindings.discard(binding.fleetRunId);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
deps.bindings.recordState({
|
|
135
|
+
fleetRunId: binding.fleetRunId, status: UNKNOWN, cursor: 0,
|
|
136
|
+
lastSeenAt: null, lastError: result.reason,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
return { resolved, stillPending: deps.bindings.pending().length };
|
|
140
|
+
}
|
|
141
|
+
/** Refresh cached status for every bound Run the caller can see. */
|
|
142
|
+
export async function refreshStates(deps, hostIds) {
|
|
143
|
+
const views = deps.bindings.list(hostIds);
|
|
144
|
+
let updated = 0;
|
|
145
|
+
for (const view of views) {
|
|
146
|
+
if (!view.childRunId)
|
|
147
|
+
continue;
|
|
148
|
+
let host;
|
|
149
|
+
try {
|
|
150
|
+
host = resolveHost(deps, view.hostId);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
const result = await deps.child.getRun(host, view.childRunId);
|
|
156
|
+
if (result.kind === 'ok') {
|
|
157
|
+
deps.bindings.recordState({
|
|
158
|
+
fleetRunId: view.fleetRunId, status: result.value.status,
|
|
159
|
+
cursor: view.state?.cursor ?? 0,
|
|
160
|
+
lastSeenAt: new Date().toISOString(), lastError: result.value.error ?? null,
|
|
161
|
+
});
|
|
162
|
+
updated++;
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
// Unreachable is not failed. Keep the last known status but record why it may be stale.
|
|
166
|
+
deps.bindings.recordState({
|
|
167
|
+
fleetRunId: view.fleetRunId,
|
|
168
|
+
status: view.state?.status ?? UNKNOWN,
|
|
169
|
+
cursor: view.state?.cursor ?? 0,
|
|
170
|
+
lastSeenAt: view.state?.lastSeenAt ?? null,
|
|
171
|
+
lastError: result.kind === 'unknown' ? result.reason : `child said HTTP ${result.status}`,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return updated;
|
|
176
|
+
}
|
package/dist/events.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { DispatchError, resolveHost } from "./dispatch.js";
|
|
2
|
+
/**
|
|
3
|
+
* Pages are capped per call rather than drained until exhausted. A Run emitting thousands of events per
|
|
4
|
+
* second would otherwise hold the sweep open indefinitely and starve every other Run of reconciliation --
|
|
5
|
+
* the same class of mistake as letting one slow host stall a probe sweep.
|
|
6
|
+
*/
|
|
7
|
+
const MAX_PAGES_PER_RUN = 5;
|
|
8
|
+
const PAGE_LIMIT = 1000;
|
|
9
|
+
function mirrorBodiesEnabled(db, hostId) {
|
|
10
|
+
const row = db.prepare('SELECT mirror_bodies FROM hosts WHERE id = ?').get(hostId);
|
|
11
|
+
return row?.mirror_bodies === 1;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Pull the next window of a Run's events and store them.
|
|
15
|
+
*
|
|
16
|
+
* Idempotent by construction: the primary key is the child's own (run, sequence) pair and inserts are
|
|
17
|
+
* INSERT OR IGNORE, so re-reading a window after a crash costs nothing. That is what makes it safe to call
|
|
18
|
+
* this from a timer that may be interrupted at any point.
|
|
19
|
+
*/
|
|
20
|
+
export async function mirrorEvents(deps, fleetRunId) {
|
|
21
|
+
const binding = deps.bindings.get(fleetRunId);
|
|
22
|
+
if (!binding)
|
|
23
|
+
throw new DispatchError(404, `no Fleet Run ${fleetRunId}`);
|
|
24
|
+
if (!binding.childRunId) {
|
|
25
|
+
// Not drained: there is no child Run to read yet, so nothing has been learned about any log.
|
|
26
|
+
return { fleetRunId, inserted: 0, cursor: 0, hasMore: false, drained: false, pages: 0 };
|
|
27
|
+
}
|
|
28
|
+
const host = resolveHost(deps, binding.hostId);
|
|
29
|
+
const withBodies = mirrorBodiesEnabled(deps.db, binding.hostId);
|
|
30
|
+
const state = deps.bindings.state(fleetRunId);
|
|
31
|
+
let cursor = state?.cursor ?? 0;
|
|
32
|
+
const insert = deps.db.prepare(`INSERT OR IGNORE INTO fleet_events (fleet_run_id, sequence, type, timestamp, payload)
|
|
33
|
+
VALUES (?, ?, ?, ?, ?)`);
|
|
34
|
+
let inserted = 0;
|
|
35
|
+
let hasMore = false;
|
|
36
|
+
let drained = false;
|
|
37
|
+
let pages = 0;
|
|
38
|
+
for (let page = 0; page < MAX_PAGES_PER_RUN; page++) {
|
|
39
|
+
const res = await deps.child.getEvents(host, binding.childRunId, cursor, PAGE_LIMIT);
|
|
40
|
+
if (res.kind !== 'ok') {
|
|
41
|
+
// Nothing is lost: the stored cursor still points at the last event actually mirrored, so the next
|
|
42
|
+
// sweep resumes from here. A failed read must never advance the cursor.
|
|
43
|
+
if (pages === 0) {
|
|
44
|
+
throw new DispatchError(res.kind === 'rejected' ? res.status : 502, res.kind === 'rejected' ? `child refused events: ${res.detail}` : `events unreadable: ${res.reason}`);
|
|
45
|
+
}
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
pages++;
|
|
49
|
+
const events = res.value.events ?? [];
|
|
50
|
+
if (events.length === 0) {
|
|
51
|
+
// Nothing beyond the cursor and the child says so: the log is drained.
|
|
52
|
+
hasMore = false;
|
|
53
|
+
drained = !res.value.hasMore;
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
for (const ev of events) {
|
|
57
|
+
const payload = withBodies && ev.payload !== undefined ? JSON.stringify(ev.payload) : null;
|
|
58
|
+
const r = insert.run(fleetRunId, ev.sequence, ev.type, ev.timestamp, payload);
|
|
59
|
+
if (r.changes > 0)
|
|
60
|
+
inserted++;
|
|
61
|
+
}
|
|
62
|
+
// Resume from what the child says it returned, never from lastSequence. That distinction is the whole
|
|
63
|
+
// content of Mercury issue #54 and the reason a truncated page is still safe to page from.
|
|
64
|
+
const next = res.value.nextCursor;
|
|
65
|
+
if (typeof next !== 'number' || next <= cursor) {
|
|
66
|
+
// A child that will not advance the cursor would spin this loop forever. Stop rather than repeat -- but
|
|
67
|
+
// do NOT call this drained. The log may hold plenty more; we simply cannot make progress right now, and
|
|
68
|
+
// recording drained here is what would make a terminal Run's missing log permanent.
|
|
69
|
+
hasMore = false;
|
|
70
|
+
drained = false;
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
cursor = next;
|
|
74
|
+
hasMore = Boolean(res.value.hasMore);
|
|
75
|
+
if (!hasMore) {
|
|
76
|
+
drained = true;
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// Reaching the per-call page cap with more still pending is explicitly not drained; the next pass continues.
|
|
81
|
+
deps.bindings.setCursor(fleetRunId, cursor, drained);
|
|
82
|
+
return { fleetRunId, inserted, cursor, hasMore, drained, pages };
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Read the mirrored window back out, in the same cursor shape the child uses, so a Fleet client resumes the
|
|
86
|
+
* same way whether it is talking to Fleet or to a host directly.
|
|
87
|
+
*/
|
|
88
|
+
export function listMirroredEvents(db, fleetRunId, after, limit) {
|
|
89
|
+
const rows = db
|
|
90
|
+
.prepare(`SELECT sequence, type, timestamp, payload FROM fleet_events
|
|
91
|
+
WHERE fleet_run_id = ? AND sequence > ?
|
|
92
|
+
ORDER BY sequence ASC LIMIT ?`)
|
|
93
|
+
.all(fleetRunId, after, limit);
|
|
94
|
+
const events = rows.map((r) => {
|
|
95
|
+
const base = { sequence: Number(r.sequence), type: r.type, timestamp: r.timestamp };
|
|
96
|
+
if (r.payload !== null && r.payload !== undefined) {
|
|
97
|
+
try {
|
|
98
|
+
base.payload = JSON.parse(r.payload);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
// A payload that will not parse is still worth surfacing as text rather than dropping the event.
|
|
102
|
+
base.payload = r.payload;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return base;
|
|
106
|
+
});
|
|
107
|
+
const last = events.length > 0 ? events[events.length - 1].sequence : after;
|
|
108
|
+
// Same rule as the child: the resume point is the last sequence returned, and hasMore is decided by whether
|
|
109
|
+
// anything further exists -- not by the page size, which lies on the final partial page.
|
|
110
|
+
const more = db
|
|
111
|
+
.prepare('SELECT 1 AS x FROM fleet_events WHERE fleet_run_id = ? AND sequence > ? LIMIT 1')
|
|
112
|
+
.get(fleetRunId, last);
|
|
113
|
+
return { events, nextCursor: last, hasMore: more !== undefined };
|
|
114
|
+
}
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A minimal HTTP layer: bearer auth, JSON bodies, and a route table with no dependencies.
|
|
3
|
+
*
|
|
4
|
+
* Fleet deliberately declares no runtime dependencies (docs/fleet-design.md section 11, enforced by
|
|
5
|
+
* fleet/test/coupling.test.ts). Mercury uses express; Fleet cannot import it and adding it would make Fleet
|
|
6
|
+
* a second web framework to keep patched for a surface this small. node:http plus this file is enough, and
|
|
7
|
+
* "enough" is checked by the tests rather than asserted here.
|
|
8
|
+
*/
|
|
9
|
+
import { isAdminToken } from "./auth.js";
|
|
10
|
+
export class HttpError extends Error {
|
|
11
|
+
status;
|
|
12
|
+
code;
|
|
13
|
+
/**
|
|
14
|
+
* Structured detail for callers that can act on it -- routing returns which hosts were considered and why
|
|
15
|
+
* each was ruled out, which is the difference between a five-second fix and an hour of guessing.
|
|
16
|
+
*/
|
|
17
|
+
details;
|
|
18
|
+
constructor(status, message, code, details) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.status = status;
|
|
21
|
+
this.code = code;
|
|
22
|
+
this.details = details;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function matchRoute(routes, method, path) {
|
|
26
|
+
const parts = path.split('/').filter(Boolean);
|
|
27
|
+
for (const route of routes) {
|
|
28
|
+
if (route.method !== method)
|
|
29
|
+
continue;
|
|
30
|
+
if (route.pattern.length !== parts.length)
|
|
31
|
+
continue;
|
|
32
|
+
const params = [];
|
|
33
|
+
let ok = true;
|
|
34
|
+
for (let i = 0; i < route.pattern.length; i++) {
|
|
35
|
+
const p = route.pattern[i];
|
|
36
|
+
if (p.startsWith(':'))
|
|
37
|
+
params.push(decodeURIComponent(parts[i]));
|
|
38
|
+
else if (p !== parts[i]) {
|
|
39
|
+
ok = false;
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (ok)
|
|
44
|
+
return { route, params };
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
const MAX_BODY_BYTES = 1024 * 1024;
|
|
49
|
+
/** Read and parse a JSON body, bounded. An unbounded read is a memory-exhaustion route. */
|
|
50
|
+
export async function readJsonBody(req) {
|
|
51
|
+
const chunks = [];
|
|
52
|
+
let total = 0;
|
|
53
|
+
for await (const chunk of req) {
|
|
54
|
+
const buf = chunk;
|
|
55
|
+
total += buf.length;
|
|
56
|
+
if (total > MAX_BODY_BYTES)
|
|
57
|
+
throw new HttpError(413, `request body exceeds ${MAX_BODY_BYTES} bytes`);
|
|
58
|
+
chunks.push(buf);
|
|
59
|
+
}
|
|
60
|
+
if (total === 0)
|
|
61
|
+
return {};
|
|
62
|
+
try {
|
|
63
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
throw new HttpError(400, `request body is not valid JSON: ${err.message}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Resolve the caller from the Authorization header.
|
|
71
|
+
*
|
|
72
|
+
* Returns null rather than responding, so the caller of this function decides the status: a missing
|
|
73
|
+
* credential is 401 and a credential that names a host the caller may not touch is 403. Collapsing those
|
|
74
|
+
* tells an operator nothing about which of the two problems they have.
|
|
75
|
+
*/
|
|
76
|
+
export function authenticate(req, deps) {
|
|
77
|
+
const header = req.headers.authorization ?? '';
|
|
78
|
+
const m = /^Bearer\s+(.+)$/i.exec(header);
|
|
79
|
+
if (!m)
|
|
80
|
+
return null;
|
|
81
|
+
const token = m[1];
|
|
82
|
+
if (isAdminToken(deps.adminToken, token)) {
|
|
83
|
+
return { ownerId: 'admin', isAdmin: true, allowedHosts: '*' };
|
|
84
|
+
}
|
|
85
|
+
return deps.callers.resolve(token);
|
|
86
|
+
}
|
|
87
|
+
export function sendJson(res, status, payload) {
|
|
88
|
+
const body = JSON.stringify(payload, null, 2) + '\n';
|
|
89
|
+
res.writeHead(status, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) });
|
|
90
|
+
res.end(body);
|
|
91
|
+
}
|
package/dist/interact.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { DispatchError, resolveHost } from "./dispatch.js";
|
|
2
|
+
function requireBound(deps, fleetRunId) {
|
|
3
|
+
const binding = deps.bindings.get(fleetRunId);
|
|
4
|
+
if (!binding)
|
|
5
|
+
throw new DispatchError(404, `no Fleet Run ${fleetRunId}`);
|
|
6
|
+
if (!binding.childRunId) {
|
|
7
|
+
// Nothing exists on the child yet, so there is nothing to talk to. This is not a failure of the action.
|
|
8
|
+
throw new DispatchError(409, `Fleet Run ${fleetRunId} has no child Run yet; its dispatch is still unresolved`);
|
|
9
|
+
}
|
|
10
|
+
return { binding, host: resolveHost(deps, binding.hostId) };
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* A child refusal is the caller's problem to see, so it surfaces as a 400 carrying the child's own reason.
|
|
14
|
+
*
|
|
15
|
+
* Only refusals go through here. An unreachable child is NOT an error and never was: it returns an outcome with
|
|
16
|
+
* unknown set, because throwing would invite someone to map it to a status that implies the action failed.
|
|
17
|
+
*/
|
|
18
|
+
function refuse(detail) {
|
|
19
|
+
throw new DispatchError(400, detail);
|
|
20
|
+
}
|
|
21
|
+
export async function sendInput(deps, fleetRunId, input) {
|
|
22
|
+
const { binding, host } = requireBound(deps, fleetRunId);
|
|
23
|
+
const res = await deps.child.submitInput(host, binding.childRunId, input);
|
|
24
|
+
if (res.kind === 'rejected')
|
|
25
|
+
refuse(`child refused input: ${res.detail}`);
|
|
26
|
+
if (res.kind === 'unknown') {
|
|
27
|
+
return {
|
|
28
|
+
fleetRunId, hostId: binding.hostId, childRunId: binding.childRunId, status: null, unknown: true,
|
|
29
|
+
note: `input delivery unconfirmed (${res.reason}); the Run may still have received it`,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
return { fleetRunId, hostId: binding.hostId, childRunId: binding.childRunId, status: null, unknown: false };
|
|
33
|
+
}
|
|
34
|
+
export async function cancelRun(deps, fleetRunId) {
|
|
35
|
+
const { binding, host } = requireBound(deps, fleetRunId);
|
|
36
|
+
const res = await deps.child.cancelRun(host, binding.childRunId);
|
|
37
|
+
if (res.kind === 'rejected')
|
|
38
|
+
refuse(`child refused cancel: ${res.detail}`);
|
|
39
|
+
if (res.kind === 'unknown') {
|
|
40
|
+
// Deliberately not recorded as CANCELLED. Reconciliation will read the real status on the next pass.
|
|
41
|
+
return {
|
|
42
|
+
fleetRunId, hostId: binding.hostId, childRunId: binding.childRunId, status: null, unknown: true,
|
|
43
|
+
note: `cancel unconfirmed (${res.reason}); status will be corrected by reconciliation`,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
// The child answered, so record what it said rather than the status we hoped for.
|
|
47
|
+
deps.bindings.recordState({
|
|
48
|
+
fleetRunId, status: res.value.status, cursor: deps.bindings.state(fleetRunId)?.cursor ?? 0,
|
|
49
|
+
lastSeenAt: new Date().toISOString(), lastError: null,
|
|
50
|
+
});
|
|
51
|
+
return {
|
|
52
|
+
fleetRunId, hostId: binding.hostId, childRunId: binding.childRunId,
|
|
53
|
+
status: res.value.status, unknown: false,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Retry, and follow the binding to the new child Run.
|
|
58
|
+
*
|
|
59
|
+
* Mercury's retry creates a NEW Run (it answers a fresh runId plus retryOf). The binding must move with it or
|
|
60
|
+
* Fleet would keep polling a Run the operator has superseded -- and the mirrored window has to be cleared,
|
|
61
|
+
* because it is keyed on the child's sequence and the new Run restarts at 1.
|
|
62
|
+
*/
|
|
63
|
+
export async function retryRun(deps, fleetRunId) {
|
|
64
|
+
const { binding, host } = requireBound(deps, fleetRunId);
|
|
65
|
+
const previousChildRunId = binding.childRunId;
|
|
66
|
+
const res = await deps.child.retryRun(host, previousChildRunId);
|
|
67
|
+
if (res.kind === 'rejected')
|
|
68
|
+
refuse(`child refused retry: ${res.detail}`);
|
|
69
|
+
if (res.kind === 'unknown') {
|
|
70
|
+
// A retry that we cannot confirm is the dangerous case: the child may have created a Run that Fleet does
|
|
71
|
+
// not know about. Say so plainly rather than leaving the binding silently pointing at the old Run.
|
|
72
|
+
return {
|
|
73
|
+
fleetRunId, hostId: binding.hostId, childRunId: previousChildRunId, status: null, unknown: true,
|
|
74
|
+
note: `retry unconfirmed (${res.reason}); a child Run may exist that Fleet has not bound. `
|
|
75
|
+
+ 'Re-submit with a new idempotency key only after checking the host.',
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const rebound = deps.bindings.rebind(fleetRunId, res.value.runId);
|
|
79
|
+
deps.bindings.recordState({
|
|
80
|
+
fleetRunId, status: res.value.status, cursor: 0,
|
|
81
|
+
lastSeenAt: new Date().toISOString(), lastError: null,
|
|
82
|
+
});
|
|
83
|
+
return {
|
|
84
|
+
fleetRunId, hostId: rebound.hostId, childRunId: res.value.runId,
|
|
85
|
+
status: res.value.status, unknown: false,
|
|
86
|
+
note: `retried: child Run ${previousChildRunId} superseded by ${res.value.runId}`,
|
|
87
|
+
};
|
|
88
|
+
}
|
package/dist/logger.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured JSON logging for the Fleet service.
|
|
3
|
+
*
|
|
4
|
+
* Same shape as Mercury's log lines on purpose -- one journald query across the fleet -- but a separate
|
|
5
|
+
* implementation, because the coupling rule forbids importing Mercury's logger. Every line goes through the
|
|
6
|
+
* redactor, including the message and every string field, so a caller-supplied string cannot smuggle a
|
|
7
|
+
* secret into a log and neither can an exception message.
|
|
8
|
+
*/
|
|
9
|
+
const ORDER = { debug: 10, info: 20, warn: 30, error: 40 };
|
|
10
|
+
function redactDeep(value, redactor) {
|
|
11
|
+
if (typeof value === 'string')
|
|
12
|
+
return redactor.redact(value);
|
|
13
|
+
if (value instanceof Error)
|
|
14
|
+
return { name: value.name, message: redactor.redact(value.message) };
|
|
15
|
+
if (Array.isArray(value))
|
|
16
|
+
return value.map((v) => redactDeep(v, redactor));
|
|
17
|
+
if (value && typeof value === 'object') {
|
|
18
|
+
const out = {};
|
|
19
|
+
for (const [k, v] of Object.entries(value))
|
|
20
|
+
out[k] = redactDeep(v, redactor);
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
export function createLogger(redactor, minLevel = 'info', sink) {
|
|
26
|
+
const write = sink ?? ((line, level) => {
|
|
27
|
+
const stream = level === 'error' || level === 'warn' ? process.stderr : process.stdout;
|
|
28
|
+
stream.write(line + '\n');
|
|
29
|
+
});
|
|
30
|
+
const emit = (level, msg, fields) => {
|
|
31
|
+
if (ORDER[level] < ORDER[minLevel])
|
|
32
|
+
return;
|
|
33
|
+
const rec = { ts: new Date().toISOString(), level, msg: redactor.redact(msg) };
|
|
34
|
+
if (fields)
|
|
35
|
+
Object.assign(rec, redactDeep(fields, redactor));
|
|
36
|
+
try {
|
|
37
|
+
write(JSON.stringify(rec), level);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// A field with a circular reference must not take the service down while reporting something else.
|
|
41
|
+
write(JSON.stringify({ ts: new Date().toISOString(), level, msg: 'log serialization failed' }), level);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
return {
|
|
45
|
+
debug: (m, f) => emit('debug', m, f),
|
|
46
|
+
info: (m, f) => emit('info', m, f),
|
|
47
|
+
warn: (m, f) => emit('warn', m, f),
|
|
48
|
+
error: (m, f) => emit('error', m, f),
|
|
49
|
+
};
|
|
50
|
+
}
|