@crvouga/dev-fleet 0.6.0 → 0.8.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/README.md +32 -13
- package/ansible/macos/manager-privilege.yml +37 -0
- package/ansible/macos/manager.yml +51 -6
- package/ansible/macos/policy.json +6 -1
- package/ansible/macos/worker-host-key.yml +33 -0
- package/ansible/monitoring/agent.yml +2 -0
- package/ansible/ubuntu/baseline.yml +6 -0
- package/ansible/ubuntu/recovery.yml +2 -0
- package/ansible/ubuntu/tailscale.yml +2 -0
- package/bootstrap/worker-seed.yaml +61 -0
- package/docs/authentication.md +2 -0
- package/docs/consumer-setup.md +34 -35
- package/docs/declared.md +24 -24
- package/docs/manager-recovery.md +78 -0
- package/docs/manager-setup.md +27 -0
- package/docs/operations.md +8 -0
- package/docs/runbooks.md +15 -1
- package/docs/state.md +38 -6
- package/docs/topology.md +19 -11
- package/docs/ubuntu-sync.md +21 -15
- package/package.json +2 -2
- package/schema/check-ids.json +36 -0
- package/schema/fleet.schema.json +22 -2
- package/src/check-owners.ts +164 -0
- package/src/cli/fleet-manifest.ts +102 -0
- package/src/cli/fleet-node-lifecycle.ts +455 -0
- package/src/cli/fleet-node-seed.ts +151 -0
- package/src/cli/fleet-restore.ts +205 -0
- package/src/cli/fleet-state.ts +57 -5
- package/src/cli/fleet-sync-forward.ts +2 -0
- package/src/cli/fleet-sync-manager.ts +101 -18
- package/src/cli/fleet-sync-remote.ts +21 -6
- package/src/cli/fleet-sync-state.ts +30 -11
- package/src/cli/fleet-sync-vault.ts +75 -26
- package/src/cli/fleet-sync.ts +54 -5
- package/src/cli/fleet-validate.ts +239 -0
- package/src/cli/register-fleet.ts +167 -0
- package/src/cli/registry.ts +47 -1
- package/src/definition.ts +444 -237
- package/src/machine/state.ts +33 -1
- package/src/manager-sync.ts +24 -4
- package/src/platform/manager-host.ts +184 -0
- package/src/repair-catalog.ts +64 -0
- package/src/repair-plan.ts +25 -13
- package/src/secrets.ts +3 -0
- package/src/state/check.ts +7 -0
- package/src/state/holder.ts +50 -0
- package/src/state/litestream.ts +6 -3
- package/src/state/migrations/0001-documents.sql +13 -0
- package/src/state/migrations/0002-lease.sql +7 -0
- package/src/state/store.ts +125 -26
- package/src/state/sync.ts +131 -10
- package/src/worker-credentials.ts +201 -0
- package/src/worker-manifest.ts +201 -0
- package/src/worker-policy.ts +21 -35
- package/src/worker-seed.ts +138 -0
package/src/state/store.ts
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import { Database } from 'bun:sqlite';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
|
-
import {
|
|
4
|
-
|
|
3
|
+
import {
|
|
4
|
+
chmodSync,
|
|
5
|
+
existsSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
readdirSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
} from 'node:fs';
|
|
10
|
+
import { dirname, join } from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
5
12
|
import { FleetUsageError } from '../errors';
|
|
6
13
|
|
|
7
14
|
/** Everything the manager must not lose, as one JSON text each:
|
|
@@ -35,19 +42,64 @@ export interface DocumentInfo {
|
|
|
35
42
|
|
|
36
43
|
const MAX_DOCUMENT_BYTES = 1024 * 1024;
|
|
37
44
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
45
|
+
export interface Migration {
|
|
46
|
+
version: number;
|
|
47
|
+
sql: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const MIGRATIONS_DIRECTORY = fileURLToPath(
|
|
51
|
+
new URL('./migrations/', import.meta.url)
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
/** The schema as numbered files (`0001-documents.sql`, …), applied in order.
|
|
55
|
+
* A database records the last one it received in `PRAGMA user_version`; one
|
|
56
|
+
* written before the schema had a version holds exactly the first.
|
|
57
|
+
*/
|
|
58
|
+
export function migrations(directory = MIGRATIONS_DIRECTORY): Migration[] {
|
|
59
|
+
return readdirSync(directory)
|
|
60
|
+
.filter((name) => /^\d{4}-[a-z0-9-]+\.sql$/.test(name))
|
|
61
|
+
.sort()
|
|
62
|
+
.map((name, index) => {
|
|
63
|
+
const version = Number(name.slice(0, 4));
|
|
64
|
+
if (version !== index + 1)
|
|
65
|
+
throw new Error(`State migrations must be numbered from 1: ${name}`);
|
|
66
|
+
return { version, sql: readFileSync(join(directory, name), 'utf8') };
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const MIGRATIONS = migrations();
|
|
71
|
+
|
|
72
|
+
/** The schema version this dev-fleet writes and the newest it can read. */
|
|
73
|
+
export const SCHEMA_VERSION = MIGRATIONS.length;
|
|
74
|
+
|
|
75
|
+
/** A fleet state written by a newer dev-fleet: this one must neither read nor
|
|
76
|
+
* change it, or a rollback (or an old second manager) would damage it.
|
|
77
|
+
*/
|
|
78
|
+
export class StateNewerError extends FleetUsageError {}
|
|
79
|
+
|
|
80
|
+
function schemaVersion(db: Database): number {
|
|
81
|
+
return (db.query('PRAGMA user_version').get() as { user_version: number })
|
|
82
|
+
.user_version;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Refuses a database a newer dev-fleet wrote; returns its version. */
|
|
86
|
+
function supported(db: Database): number {
|
|
87
|
+
const version = schemaVersion(db);
|
|
88
|
+
if (version > SCHEMA_VERSION)
|
|
89
|
+
throw new StateNewerError(
|
|
90
|
+
`FLEET_STATE_NEWER_THAN_CLI: the fleet state has schema version ${version}, and this dev-fleet reads version ${SCHEMA_VERSION} at most. Nothing was read or changed. Install the dev-fleet version the fleet runs (npx devfleet --version on the manager that wrote it) and run the command again.`
|
|
91
|
+
);
|
|
92
|
+
return version;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Brings a database forward to `SCHEMA_VERSION`, one migration at a time. */
|
|
96
|
+
function migrate(db: Database): void {
|
|
97
|
+
const from = supported(db);
|
|
98
|
+
for (const migration of MIGRATIONS.filter((entry) => entry.version > from)) {
|
|
99
|
+
db.exec(migration.sql);
|
|
100
|
+
db.exec(`PRAGMA user_version = ${migration.version}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
51
103
|
|
|
52
104
|
function unreadable(): FleetUsageError {
|
|
53
105
|
return new FleetUsageError(
|
|
@@ -65,8 +117,10 @@ function reading<T>(file: string, read: (db: Database) => T): T | null {
|
|
|
65
117
|
let db: Database | undefined;
|
|
66
118
|
try {
|
|
67
119
|
db = new Database(file, { readwrite: true, create: false });
|
|
120
|
+
supported(db);
|
|
68
121
|
return read(db);
|
|
69
|
-
} catch {
|
|
122
|
+
} catch (error) {
|
|
123
|
+
if (error instanceof FleetUsageError) throw error;
|
|
70
124
|
throw unreadable();
|
|
71
125
|
} finally {
|
|
72
126
|
db?.close();
|
|
@@ -82,9 +136,16 @@ function writing<T>(file: string, write: (db: Database) => T): T {
|
|
|
82
136
|
try {
|
|
83
137
|
db = new Database(file, { create: true, readwrite: true });
|
|
84
138
|
chmodSync(file, 0o600);
|
|
139
|
+
// Checked before anything is written, the journal mode included.
|
|
140
|
+
supported(db);
|
|
85
141
|
db.exec('PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000;');
|
|
86
|
-
db
|
|
87
|
-
return
|
|
142
|
+
const opened = db;
|
|
143
|
+
return opened
|
|
144
|
+
.transaction(() => {
|
|
145
|
+
migrate(opened);
|
|
146
|
+
return write(opened);
|
|
147
|
+
})
|
|
148
|
+
.immediate();
|
|
88
149
|
} catch (error) {
|
|
89
150
|
if (error instanceof FleetUsageError) throw error;
|
|
90
151
|
throw new FleetUsageError(
|
|
@@ -140,6 +201,21 @@ export function readHead(file: string): Head | null {
|
|
|
140
201
|
});
|
|
141
202
|
}
|
|
142
203
|
|
|
204
|
+
function nextHead(db: Database): Head {
|
|
205
|
+
const head = db
|
|
206
|
+
.query('SELECT id, sequence, written_at AS writtenAt FROM head')
|
|
207
|
+
.get() as Head | null;
|
|
208
|
+
const next: Head = {
|
|
209
|
+
id: randomUUID(),
|
|
210
|
+
sequence: (head?.sequence ?? 0) + 1,
|
|
211
|
+
writtenAt: new Date().toISOString(),
|
|
212
|
+
};
|
|
213
|
+
db.query(
|
|
214
|
+
'INSERT INTO head (one, id, sequence, written_at) VALUES (1, ?1, ?2, ?3) ON CONFLICT (one) DO UPDATE SET id = ?1, sequence = ?2, written_at = ?3'
|
|
215
|
+
).run(next.id, next.sequence, next.writtenAt);
|
|
216
|
+
return next;
|
|
217
|
+
}
|
|
218
|
+
|
|
143
219
|
/** Stores one document and advances the head. Writing the text already
|
|
144
220
|
* stored changes nothing, so repeating a command never looks like new state.
|
|
145
221
|
*/
|
|
@@ -160,17 +236,40 @@ export function writeDocument(
|
|
|
160
236
|
.query('SELECT id, sequence, written_at AS writtenAt FROM head')
|
|
161
237
|
.get() as Head | null;
|
|
162
238
|
if (head && current?.body === body) return head;
|
|
163
|
-
const next
|
|
164
|
-
id: randomUUID(),
|
|
165
|
-
sequence: (head?.sequence ?? 0) + 1,
|
|
166
|
-
writtenAt: new Date().toISOString(),
|
|
167
|
-
};
|
|
239
|
+
const next = nextHead(db);
|
|
168
240
|
db.query(
|
|
169
241
|
'INSERT INTO documents (name, body, updated_at) VALUES (?1, ?2, ?3) ON CONFLICT (name) DO UPDATE SET body = ?2, updated_at = ?3'
|
|
170
242
|
).run(name, body, next.writtenAt);
|
|
171
|
-
db.query(
|
|
172
|
-
'INSERT INTO head (one, id, sequence, written_at) VALUES (1, ?1, ?2, ?3) ON CONFLICT (one) DO UPDATE SET id = ?1, sequence = ?2, written_at = ?3'
|
|
173
|
-
).run(next.id, next.sequence, next.writtenAt);
|
|
174
243
|
return next;
|
|
175
244
|
});
|
|
176
245
|
}
|
|
246
|
+
|
|
247
|
+
/** Which manager may change the fleet state, and until when. */
|
|
248
|
+
export interface Lease {
|
|
249
|
+
holder: string;
|
|
250
|
+
expiresAt: string;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function readLease(file: string): Lease | null {
|
|
254
|
+
return reading(file, (db) => {
|
|
255
|
+
const present = db
|
|
256
|
+
.query(
|
|
257
|
+
"SELECT count(*) AS found FROM sqlite_master WHERE type = 'table' AND name = 'lease'"
|
|
258
|
+
)
|
|
259
|
+
.get() as { found: number };
|
|
260
|
+
if (!present.found) return null;
|
|
261
|
+
return db
|
|
262
|
+
.query('SELECT holder, expires_at AS expiresAt FROM lease')
|
|
263
|
+
.get() as Lease | null;
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Records the lease and advances the head, so that the bucket carries it. */
|
|
268
|
+
export function writeLease(file: string, lease: Lease): Head {
|
|
269
|
+
return writing(file, (db) => {
|
|
270
|
+
db.query(
|
|
271
|
+
'INSERT INTO lease (one, holder, expires_at) VALUES (1, ?1, ?2) ON CONFLICT (one) DO UPDATE SET holder = ?1, expires_at = ?2'
|
|
272
|
+
).run(lease.holder, lease.expiresAt);
|
|
273
|
+
return nextHead(db);
|
|
274
|
+
});
|
|
275
|
+
}
|
package/src/state/sync.ts
CHANGED
|
@@ -18,12 +18,29 @@ import { bucketIdentity, type Bucket } from './bucket';
|
|
|
18
18
|
import { connection } from './connection';
|
|
19
19
|
import { replicate, restore } from './litestream';
|
|
20
20
|
import { databasePath, replicatedPath, stateRoot } from './paths';
|
|
21
|
-
import {
|
|
21
|
+
import { managerHolder } from './holder';
|
|
22
|
+
import {
|
|
23
|
+
StateNewerError,
|
|
24
|
+
readHead,
|
|
25
|
+
readLease,
|
|
26
|
+
writeLease,
|
|
27
|
+
type Head,
|
|
28
|
+
type Lease,
|
|
29
|
+
} from './store';
|
|
30
|
+
|
|
31
|
+
export type { Lease } from './store';
|
|
22
32
|
|
|
23
33
|
export interface StateContext {
|
|
24
34
|
root: string;
|
|
25
35
|
bucket: Bucket | null;
|
|
26
36
|
run: Runner;
|
|
37
|
+
/** This machine, as the manager lease names it. Without one the lease is
|
|
38
|
+
* neither checked nor taken.
|
|
39
|
+
*/
|
|
40
|
+
holder?: string | null;
|
|
41
|
+
/** Take the lease from another manager before it expired. */
|
|
42
|
+
takeOver?: boolean;
|
|
43
|
+
now?: () => Date;
|
|
27
44
|
}
|
|
28
45
|
|
|
29
46
|
export function stateContext(
|
|
@@ -34,12 +51,14 @@ export function stateContext(
|
|
|
34
51
|
root: stateRoot(env),
|
|
35
52
|
bucket: connection(env).bucket,
|
|
36
53
|
run,
|
|
54
|
+
holder: managerHolder(env),
|
|
37
55
|
};
|
|
38
56
|
}
|
|
39
57
|
|
|
40
58
|
/** How this machine's copy compares with the bucket.
|
|
41
59
|
* - `local-only`: no bucket is configured; this copy is the only one.
|
|
42
60
|
* - `unreachable`: the bucket did not answer, or did not take an upload.
|
|
61
|
+
* - `corrupt`: the bucket answered with a version that cannot be read.
|
|
43
62
|
* - `empty`: neither side holds any state.
|
|
44
63
|
* - `unpublished`: the bucket holds nothing yet; this copy can seed it.
|
|
45
64
|
* - `in-sync`: both hold the same version.
|
|
@@ -50,6 +69,7 @@ export function stateContext(
|
|
|
50
69
|
export type Standing =
|
|
51
70
|
| 'local-only'
|
|
52
71
|
| 'unreachable'
|
|
72
|
+
| 'corrupt'
|
|
53
73
|
| 'empty'
|
|
54
74
|
| 'unpublished'
|
|
55
75
|
| 'in-sync'
|
|
@@ -109,19 +129,24 @@ const INCOMING = /^incoming-[0-9a-f-]{36}\.db/;
|
|
|
109
129
|
|
|
110
130
|
type Remote =
|
|
111
131
|
| { status: 'unreachable' }
|
|
132
|
+
| { status: 'corrupt' }
|
|
112
133
|
| { status: 'absent' }
|
|
113
134
|
| { status: 'present'; file: string; head: Head };
|
|
114
135
|
|
|
115
|
-
/** Downloads the bucket's current version beside the local one
|
|
136
|
+
/** Downloads the bucket's current version beside the local one, or the
|
|
137
|
+
* version it held at `timestamp`.
|
|
138
|
+
*/
|
|
116
139
|
async function fetchRemote(
|
|
117
140
|
context: StateContext & { bucket: Bucket },
|
|
118
|
-
install = true
|
|
141
|
+
install = true,
|
|
142
|
+
timestamp?: string
|
|
119
143
|
): Promise<Remote> {
|
|
120
144
|
const database = databasePath(context.root);
|
|
121
145
|
const file = join(context.root, `incoming-${randomUUID()}.db`);
|
|
122
146
|
const outcome = await restore(
|
|
123
147
|
{ database, bucket: context.bucket, run: context.run, install },
|
|
124
|
-
file
|
|
148
|
+
file,
|
|
149
|
+
timestamp
|
|
125
150
|
);
|
|
126
151
|
if (outcome === 'absent') return { status: 'absent' };
|
|
127
152
|
try {
|
|
@@ -130,10 +155,12 @@ async function fetchRemote(
|
|
|
130
155
|
discard(file);
|
|
131
156
|
// A replica that holds no version yet is the same as no replica.
|
|
132
157
|
return { status: outcome === 'restored' ? 'absent' : 'unreachable' };
|
|
133
|
-
} catch {
|
|
134
|
-
// A download that cannot be read says nothing about the bucket.
|
|
158
|
+
} catch (error) {
|
|
135
159
|
discard(file);
|
|
136
|
-
|
|
160
|
+
// A newer dev-fleet wrote it: refused, never taken for damage.
|
|
161
|
+
if (error instanceof StateNewerError) throw error;
|
|
162
|
+
// The bucket answered, and what it holds cannot be read.
|
|
163
|
+
return { status: 'corrupt' };
|
|
137
164
|
}
|
|
138
165
|
}
|
|
139
166
|
|
|
@@ -163,8 +190,8 @@ async function observe(
|
|
|
163
190
|
const { bucket } = context;
|
|
164
191
|
if (!bucket) return { standing: 'local-only', local, remote: null };
|
|
165
192
|
const remote = await fetchRemote({ ...context, bucket }, install);
|
|
166
|
-
if (remote.status === 'unreachable')
|
|
167
|
-
return { standing:
|
|
193
|
+
if (remote.status === 'unreachable' || remote.status === 'corrupt')
|
|
194
|
+
return { standing: remote.status, local, remote };
|
|
168
195
|
if (remote.status === 'absent')
|
|
169
196
|
return { standing: local ? 'unpublished' : 'empty', local, remote };
|
|
170
197
|
const replicated = readReplicated(context.root, bucket);
|
|
@@ -261,7 +288,9 @@ async function upload(
|
|
|
261
288
|
*/
|
|
262
289
|
function settle(context: StateContext, before: Standing): Promise<Standing> {
|
|
263
290
|
const { bucket } = context;
|
|
264
|
-
|
|
291
|
+
// A damaged replica keeps its history for a point-in-time restore.
|
|
292
|
+
if (!bucket || before === 'unreachable' || before === 'corrupt')
|
|
293
|
+
return Promise.resolve(before);
|
|
265
294
|
const head = readHead(databasePath(context.root));
|
|
266
295
|
if (
|
|
267
296
|
before === 'in-sync' &&
|
|
@@ -319,6 +348,40 @@ export function pull(context: StateContext): Promise<Standing> {
|
|
|
319
348
|
return locked(context.root, () => observed(context, true));
|
|
320
349
|
}
|
|
321
350
|
|
|
351
|
+
/** Replaces this copy with the version the bucket held at `timestamp`, then
|
|
352
|
+
* uploads it as the bucket's current version: the way back from a replica
|
|
353
|
+
* whose latest version is damaged. `unreachable` when the bucket holds no
|
|
354
|
+
* readable version at that time.
|
|
355
|
+
*/
|
|
356
|
+
export function pullAt(
|
|
357
|
+
context: StateContext,
|
|
358
|
+
timestamp: string
|
|
359
|
+
): Promise<Standing> {
|
|
360
|
+
if (Number.isNaN(Date.parse(timestamp)))
|
|
361
|
+
throw new FleetUsageError(
|
|
362
|
+
'--timestamp needs a time such as 2026-11-01T00:00:00Z.'
|
|
363
|
+
);
|
|
364
|
+
const { bucket } = context;
|
|
365
|
+
if (!bucket)
|
|
366
|
+
throw new FleetUsageError(
|
|
367
|
+
'No bucket is configured, so there is no earlier version to restore.'
|
|
368
|
+
);
|
|
369
|
+
return locked(context.root, async () => {
|
|
370
|
+
const remote = await fetchRemote(
|
|
371
|
+
{ ...context, bucket },
|
|
372
|
+
true,
|
|
373
|
+
new Date(timestamp).toISOString()
|
|
374
|
+
);
|
|
375
|
+
if (remote.status !== 'present')
|
|
376
|
+
return remote.status === 'absent' ? 'empty' : remote.status;
|
|
377
|
+
const database = databasePath(context.root);
|
|
378
|
+
discard(database);
|
|
379
|
+
renameSync(remote.file, database);
|
|
380
|
+
chmodSync(database, 0o600);
|
|
381
|
+
return upload({ ...context, bucket }, true);
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
322
385
|
/** Uploads this copy's changes, only when the bucket has not moved since
|
|
323
386
|
* this copy last matched it. A copy with no changes of its own is brought up
|
|
324
387
|
* to the bucket instead; a diverged one is left alone and reported.
|
|
@@ -332,6 +395,62 @@ export function push(context: StateContext): Promise<Standing> {
|
|
|
332
395
|
});
|
|
333
396
|
}
|
|
334
397
|
|
|
398
|
+
/** How long a manager holds the lease after its last change. A machine that
|
|
399
|
+
* wakes up later than this may take it without being asked.
|
|
400
|
+
*/
|
|
401
|
+
export const LEASE_DAYS = 7;
|
|
402
|
+
const LEASE_MS = LEASE_DAYS * 24 * 60 * 60 * 1000;
|
|
403
|
+
|
|
404
|
+
/** Another manager holds the lease: this one changes nothing. */
|
|
405
|
+
export class LeaseHeldError extends FleetUsageError {}
|
|
406
|
+
|
|
407
|
+
/** The lease another manager holds over this copy, when it is not expired
|
|
408
|
+
* and this machine was not told to take it over. Reads local files only: call
|
|
409
|
+
* it on the bucket's version, after a refresh.
|
|
410
|
+
*/
|
|
411
|
+
export function leaseConflict(
|
|
412
|
+
context: StateContext,
|
|
413
|
+
now: Date = (context.now ?? (() => new Date()))()
|
|
414
|
+
): Lease | null {
|
|
415
|
+
const lease = readLease(databasePath(context.root));
|
|
416
|
+
if (!context.holder || !lease || lease.holder === context.holder) return null;
|
|
417
|
+
if (Date.parse(lease.expiresAt) <= now.getTime() || context.takeOver)
|
|
418
|
+
return null;
|
|
419
|
+
return lease;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export function leaseHeldMessage(lease: Lease): string {
|
|
423
|
+
return `FLEET_MANAGER_LEASE_HELD: another manager (${lease.holder}) holds the fleet state until ${lease.expiresAt}. Nothing was changed. Run devfleet from that manager; to make this machine the manager instead, repeat with --take-over once the other one is gone (see docs/manager-recovery.md).`;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** Refuses when another manager holds the lease. Call it before the first
|
|
427
|
+
* write of a session.
|
|
428
|
+
*/
|
|
429
|
+
export function refuseHeldLease(context: StateContext): void {
|
|
430
|
+
const held = leaseConflict(context);
|
|
431
|
+
if (held) throw new LeaseHeldError(leaseHeldMessage(held));
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** Takes or renews the lease for this machine: when it is not this machine's
|
|
435
|
+
* yet, or half spent. Call it at the end of a session that may write.
|
|
436
|
+
*/
|
|
437
|
+
export function claimLease(
|
|
438
|
+
context: StateContext,
|
|
439
|
+
now: Date = (context.now ?? (() => new Date()))()
|
|
440
|
+
): void {
|
|
441
|
+
refuseHeldLease(context);
|
|
442
|
+
if (!context.holder) return;
|
|
443
|
+
const lease = readLease(databasePath(context.root));
|
|
444
|
+
const fresh =
|
|
445
|
+
lease?.holder === context.holder &&
|
|
446
|
+
Date.parse(lease.expiresAt) - now.getTime() > LEASE_MS / 2;
|
|
447
|
+
if (fresh) return;
|
|
448
|
+
writeLease(databasePath(context.root), {
|
|
449
|
+
holder: context.holder,
|
|
450
|
+
expiresAt: new Date(now.getTime() + LEASE_MS).toISOString(),
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
|
|
335
454
|
export const DIVERGED_REMEDY =
|
|
336
455
|
'This machine and the bucket both changed the fleet state. The bucket is the source of truth: keep a copy of anything you still need (devfleet fleet-state --export), then run devfleet fleet-state --restore --yes.';
|
|
337
456
|
|
|
@@ -346,7 +465,9 @@ export function mutateState<T>(
|
|
|
346
465
|
return locked(context.root, async () => {
|
|
347
466
|
const before = await observed(context, false);
|
|
348
467
|
if (before === 'diverged') throw new FleetUsageError(DIVERGED_REMEDY);
|
|
468
|
+
refuseHeldLease(context);
|
|
349
469
|
const value = await change(before);
|
|
470
|
+
claimLease(context);
|
|
350
471
|
return { value, standing: await settle(context, before) };
|
|
351
472
|
});
|
|
352
473
|
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { fleet } from './fleet-config';
|
|
2
|
+
import { resolveVaultConfig, type VaultConfig } from './vault-config';
|
|
3
|
+
import type { Check, Runner } from './types';
|
|
4
|
+
|
|
5
|
+
/** Each worker's own Vault token is kept in a document of its own, beside
|
|
6
|
+
* the fleet's shared one: `<project>/workers/<id>`, field `token`, with the
|
|
7
|
+
* token's `accessor` so the manager can revoke it without holding it. A
|
|
8
|
+
* policy that grants one worker its document grants it nothing of another's.
|
|
9
|
+
*/
|
|
10
|
+
export interface WorkerTokenLocation {
|
|
11
|
+
mount: string;
|
|
12
|
+
path: string;
|
|
13
|
+
field: 'token';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function workerTokenLocation(
|
|
17
|
+
node: string,
|
|
18
|
+
vault: VaultConfig = resolveVaultConfig()
|
|
19
|
+
): WorkerTokenLocation {
|
|
20
|
+
return {
|
|
21
|
+
mount: vault.mount,
|
|
22
|
+
path: `${vault.project}/workers/${node}`,
|
|
23
|
+
field: 'token',
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The Vault policy each worker token is issued with. */
|
|
28
|
+
export const workerPolicyName = (node: string): string =>
|
|
29
|
+
`fleet-worker-${node}`;
|
|
30
|
+
|
|
31
|
+
/** The least a worker needs: the fleet's shared document (the proxy key and
|
|
32
|
+
* what the providers read) and its own token document. Nothing grants a
|
|
33
|
+
* worker the documents of the others, so one worker's token cannot read
|
|
34
|
+
* another's. Token renewal and lookup come from Vault's default policy.
|
|
35
|
+
*/
|
|
36
|
+
export function workerVaultPolicy(
|
|
37
|
+
node: string,
|
|
38
|
+
vault: VaultConfig = resolveVaultConfig(),
|
|
39
|
+
shared: string = sharedDocument()
|
|
40
|
+
): string {
|
|
41
|
+
const own = workerTokenLocation(node, vault);
|
|
42
|
+
return [
|
|
43
|
+
`# dev-fleet: the policy of the worker ${node}. Written by dev-fleet; do not edit.`,
|
|
44
|
+
`path "${vault.mount}/data/${shared}" {`,
|
|
45
|
+
' capabilities = ["read"]',
|
|
46
|
+
'}',
|
|
47
|
+
`path "${own.mount}/data/${own.path}" {`,
|
|
48
|
+
' capabilities = ["read"]',
|
|
49
|
+
'}',
|
|
50
|
+
'',
|
|
51
|
+
].join('\n');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function sharedDocument(): string {
|
|
55
|
+
const secrets = fleet.secrets;
|
|
56
|
+
return secrets.provider === 'vault'
|
|
57
|
+
? secrets.locations['proxy-client-key'].path
|
|
58
|
+
: '';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const TIMEOUT = { timeoutMs: 20_000 };
|
|
62
|
+
|
|
63
|
+
/** Issues a worker its own periodic, orphan token under its own policy and
|
|
64
|
+
* keeps it in its own document. The token reaches `vault kv put` on standard
|
|
65
|
+
* input, never as an argument. Run on the manager, logged in to Vault.
|
|
66
|
+
*/
|
|
67
|
+
export async function issueWorkerToken(
|
|
68
|
+
node: string,
|
|
69
|
+
run: Runner,
|
|
70
|
+
vault: VaultConfig = resolveVaultConfig()
|
|
71
|
+
): Promise<Check> {
|
|
72
|
+
const address = `-address=${vault.addr}`;
|
|
73
|
+
const policy = await run(
|
|
74
|
+
['vault', 'policy', 'write', address, workerPolicyName(node), '-'],
|
|
75
|
+
{ ...TIMEOUT, input: workerVaultPolicy(node, vault) }
|
|
76
|
+
);
|
|
77
|
+
if (!policy.ok) return credentialFailure('VAULT_WORKER_POLICY_FAILED');
|
|
78
|
+
const created = await run(
|
|
79
|
+
[
|
|
80
|
+
'vault',
|
|
81
|
+
'token',
|
|
82
|
+
'create',
|
|
83
|
+
address,
|
|
84
|
+
`-policy=${workerPolicyName(node)}`,
|
|
85
|
+
'-orphan',
|
|
86
|
+
'-period=768h',
|
|
87
|
+
`-display-name=${workerPolicyName(node)}`,
|
|
88
|
+
'-format=json',
|
|
89
|
+
],
|
|
90
|
+
TIMEOUT
|
|
91
|
+
);
|
|
92
|
+
const issued = created.ok ? parseIssued(created.stdout) : null;
|
|
93
|
+
if (!issued) return credentialFailure('VAULT_WORKER_TOKEN_NOT_ISSUED');
|
|
94
|
+
const location = workerTokenLocation(node, vault);
|
|
95
|
+
const stored = await run(
|
|
96
|
+
[
|
|
97
|
+
'vault',
|
|
98
|
+
'kv',
|
|
99
|
+
'put',
|
|
100
|
+
address,
|
|
101
|
+
`-mount=${location.mount}`,
|
|
102
|
+
location.path,
|
|
103
|
+
'token=-',
|
|
104
|
+
`accessor=${issued.accessor}`,
|
|
105
|
+
],
|
|
106
|
+
{ ...TIMEOUT, input: issued.token }
|
|
107
|
+
);
|
|
108
|
+
if (!stored.ok) return credentialFailure('VAULT_WORKER_TOKEN_NOT_STORED');
|
|
109
|
+
return {
|
|
110
|
+
id: 'auth:worker-token',
|
|
111
|
+
status: 'pass',
|
|
112
|
+
summary: `${node} has a token of its own, readable by nothing but itself and the manager.`,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function parseIssued(
|
|
117
|
+
stdout: string
|
|
118
|
+
): { token: string; accessor: string } | null {
|
|
119
|
+
try {
|
|
120
|
+
const auth = (JSON.parse(stdout) as { auth?: Record<string, unknown> })
|
|
121
|
+
.auth;
|
|
122
|
+
const token = auth?.['client_token'];
|
|
123
|
+
const accessor = auth?.['accessor'];
|
|
124
|
+
return typeof token === 'string' &&
|
|
125
|
+
typeof accessor === 'string' &&
|
|
126
|
+
/^[\x21-\x7e]{16,4096}$/.test(token) &&
|
|
127
|
+
/^[A-Za-z0-9._-]{8,256}$/.test(accessor)
|
|
128
|
+
? { token, accessor }
|
|
129
|
+
: null;
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Revokes a worker's token by its accessor (the manager never holds the
|
|
136
|
+
* token to do so), then removes its document and its policy. A worker that
|
|
137
|
+
* has none already is revoked: running it twice changes nothing.
|
|
138
|
+
*/
|
|
139
|
+
export async function revokeWorkerToken(
|
|
140
|
+
node: string,
|
|
141
|
+
run: Runner,
|
|
142
|
+
vault: VaultConfig = resolveVaultConfig()
|
|
143
|
+
): Promise<Check> {
|
|
144
|
+
const address = `-address=${vault.addr}`;
|
|
145
|
+
const location = workerTokenLocation(node, vault);
|
|
146
|
+
const read = await run(
|
|
147
|
+
[
|
|
148
|
+
'vault',
|
|
149
|
+
'kv',
|
|
150
|
+
'get',
|
|
151
|
+
address,
|
|
152
|
+
`-mount=${location.mount}`,
|
|
153
|
+
'-field=accessor',
|
|
154
|
+
location.path,
|
|
155
|
+
],
|
|
156
|
+
TIMEOUT
|
|
157
|
+
);
|
|
158
|
+
const accessor = read.ok ? read.stdout.trim() : '';
|
|
159
|
+
if (accessor) {
|
|
160
|
+
if (!/^[A-Za-z0-9._-]{8,256}$/.test(accessor))
|
|
161
|
+
return credentialFailure('VAULT_WORKER_TOKEN_NOT_REVOKED');
|
|
162
|
+
const revoked = await run(
|
|
163
|
+
['vault', 'token', 'revoke', address, '-accessor', accessor],
|
|
164
|
+
TIMEOUT
|
|
165
|
+
);
|
|
166
|
+
if (!revoked.ok) return credentialFailure('VAULT_WORKER_TOKEN_NOT_REVOKED');
|
|
167
|
+
}
|
|
168
|
+
await run(
|
|
169
|
+
[
|
|
170
|
+
'vault',
|
|
171
|
+
'kv',
|
|
172
|
+
'metadata',
|
|
173
|
+
'delete',
|
|
174
|
+
address,
|
|
175
|
+
`-mount=${location.mount}`,
|
|
176
|
+
location.path,
|
|
177
|
+
],
|
|
178
|
+
TIMEOUT
|
|
179
|
+
);
|
|
180
|
+
await run(
|
|
181
|
+
['vault', 'policy', 'delete', address, workerPolicyName(node)],
|
|
182
|
+
TIMEOUT
|
|
183
|
+
);
|
|
184
|
+
return {
|
|
185
|
+
id: 'auth:worker-token',
|
|
186
|
+
status: 'pass',
|
|
187
|
+
summary: `${node}'s token is revoked and its document and policy removed.`,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function credentialFailure(summary: string): Check {
|
|
192
|
+
return {
|
|
193
|
+
id: 'auth:worker-token',
|
|
194
|
+
status: 'fail',
|
|
195
|
+
summary,
|
|
196
|
+
remedy: {
|
|
197
|
+
action:
|
|
198
|
+
'Log the manager in to Vault with a token that may write policies, issue tokens and write the fleet mount, then run the command again.',
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
}
|