@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.
Files changed (56) hide show
  1. package/README.md +32 -13
  2. package/ansible/macos/manager-privilege.yml +37 -0
  3. package/ansible/macos/manager.yml +51 -6
  4. package/ansible/macos/policy.json +6 -1
  5. package/ansible/macos/worker-host-key.yml +33 -0
  6. package/ansible/monitoring/agent.yml +2 -0
  7. package/ansible/ubuntu/baseline.yml +6 -0
  8. package/ansible/ubuntu/recovery.yml +2 -0
  9. package/ansible/ubuntu/tailscale.yml +2 -0
  10. package/bootstrap/worker-seed.yaml +61 -0
  11. package/docs/authentication.md +2 -0
  12. package/docs/consumer-setup.md +34 -35
  13. package/docs/declared.md +24 -24
  14. package/docs/manager-recovery.md +78 -0
  15. package/docs/manager-setup.md +27 -0
  16. package/docs/operations.md +8 -0
  17. package/docs/runbooks.md +15 -1
  18. package/docs/state.md +38 -6
  19. package/docs/topology.md +19 -11
  20. package/docs/ubuntu-sync.md +21 -15
  21. package/package.json +2 -2
  22. package/schema/check-ids.json +36 -0
  23. package/schema/fleet.schema.json +22 -2
  24. package/src/check-owners.ts +164 -0
  25. package/src/cli/fleet-manifest.ts +102 -0
  26. package/src/cli/fleet-node-lifecycle.ts +455 -0
  27. package/src/cli/fleet-node-seed.ts +151 -0
  28. package/src/cli/fleet-restore.ts +205 -0
  29. package/src/cli/fleet-state.ts +57 -5
  30. package/src/cli/fleet-sync-forward.ts +2 -0
  31. package/src/cli/fleet-sync-manager.ts +101 -18
  32. package/src/cli/fleet-sync-remote.ts +21 -6
  33. package/src/cli/fleet-sync-state.ts +30 -11
  34. package/src/cli/fleet-sync-vault.ts +75 -26
  35. package/src/cli/fleet-sync.ts +54 -5
  36. package/src/cli/fleet-validate.ts +239 -0
  37. package/src/cli/register-fleet.ts +167 -0
  38. package/src/cli/registry.ts +47 -1
  39. package/src/definition.ts +444 -237
  40. package/src/machine/state.ts +33 -1
  41. package/src/manager-sync.ts +24 -4
  42. package/src/platform/manager-host.ts +184 -0
  43. package/src/repair-catalog.ts +64 -0
  44. package/src/repair-plan.ts +25 -13
  45. package/src/secrets.ts +3 -0
  46. package/src/state/check.ts +7 -0
  47. package/src/state/holder.ts +50 -0
  48. package/src/state/litestream.ts +6 -3
  49. package/src/state/migrations/0001-documents.sql +13 -0
  50. package/src/state/migrations/0002-lease.sql +7 -0
  51. package/src/state/store.ts +125 -26
  52. package/src/state/sync.ts +131 -10
  53. package/src/worker-credentials.ts +201 -0
  54. package/src/worker-manifest.ts +201 -0
  55. package/src/worker-policy.ts +21 -35
  56. package/src/worker-seed.ts +138 -0
@@ -1,7 +1,14 @@
1
1
  import { Database } from 'bun:sqlite';
2
2
  import { randomUUID } from 'node:crypto';
3
- import { chmodSync, existsSync, mkdirSync } from 'node:fs';
4
- import { dirname } from 'node:path';
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
- const SCHEMA = `
39
- CREATE TABLE IF NOT EXISTS documents (
40
- name TEXT PRIMARY KEY,
41
- body TEXT NOT NULL,
42
- updated_at TEXT NOT NULL
43
- ) STRICT;
44
- CREATE TABLE IF NOT EXISTS head (
45
- one INTEGER PRIMARY KEY CHECK (one = 1),
46
- id TEXT NOT NULL,
47
- sequence INTEGER NOT NULL,
48
- written_at TEXT NOT NULL
49
- ) STRICT;
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.exec(SCHEMA);
87
- return db.transaction(write).immediate(db);
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: Head = {
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 { readHead, type Head } from './store';
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
- return { status: 'unreachable' };
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: 'unreachable', local, remote };
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
- if (!bucket || before === 'unreachable') return Promise.resolve(before);
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
+ }