@crvouga/dev-fleet 0.5.0 → 0.7.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 +4 -10
  2. package/ansible/macos/manager.yml +39 -2
  3. package/ansible/macos/policy.json +6 -1
  4. package/ansible/monitoring/agent.yml +2 -0
  5. package/ansible/ubuntu/baseline.yml +8 -0
  6. package/ansible/ubuntu/recovery.yml +2 -0
  7. package/ansible/ubuntu/tailscale.yml +2 -0
  8. package/ansible/ubuntu/tasks/github.yml +50 -0
  9. package/bootstrap/worker-auth.ts +6 -7
  10. package/bootstrap/worker-github.ts +55 -44
  11. package/docs/authentication.md +4 -1
  12. package/docs/consumer-setup.md +7 -4
  13. package/docs/github.md +107 -0
  14. package/docs/manager-recovery.md +78 -0
  15. package/docs/operations.md +9 -1
  16. package/docs/runbooks.md +15 -1
  17. package/docs/state.md +38 -6
  18. package/docs/topology.md +4 -5
  19. package/docs/ubuntu-sync.md +5 -5
  20. package/package.json +2 -2
  21. package/schema/check-ids.json +51 -3
  22. package/schema/fleet.schema.json +79 -1
  23. package/src/auth-checks.ts +14 -6
  24. package/src/check-owners.ts +163 -0
  25. package/src/cli/fleet-restore.ts +205 -0
  26. package/src/cli/fleet-state.ts +57 -5
  27. package/src/cli/fleet-sync-manager.ts +62 -15
  28. package/src/cli/fleet-sync-state.ts +18 -8
  29. package/src/cli/fleet-sync.ts +32 -3
  30. package/src/cli/github-verify.ts +57 -0
  31. package/src/cli/register-fleet.ts +33 -0
  32. package/src/cli/registry.ts +21 -1
  33. package/src/definition.ts +11 -1
  34. package/src/github/checks.ts +412 -0
  35. package/src/github/converge.ts +62 -0
  36. package/src/github/copilot.ts +54 -0
  37. package/src/github/credential.ts +21 -0
  38. package/src/github/policy.ts +141 -0
  39. package/src/github/sync.ts +123 -0
  40. package/src/github/workflow.ts +223 -0
  41. package/src/node-converge-linux.ts +3 -2
  42. package/src/node-converge-shared.ts +6 -2
  43. package/src/platform/manager-host.ts +184 -0
  44. package/src/repair-catalog.ts +66 -2
  45. package/src/repair-plan.ts +25 -13
  46. package/src/secrets.ts +58 -13
  47. package/src/service-auth.ts +2 -2
  48. package/src/state/check.ts +7 -0
  49. package/src/state/holder.ts +50 -0
  50. package/src/state/litestream.ts +6 -3
  51. package/src/state/migrations/0001-documents.sql +13 -0
  52. package/src/state/migrations/0002-lease.sql +7 -0
  53. package/src/state/store.ts +125 -26
  54. package/src/state/sync.ts +131 -10
  55. package/src/worker-access-checks.ts +66 -16
  56. package/src/worker-readiness.ts +1 -1
@@ -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
  }
@@ -4,29 +4,79 @@ import { secretsProvider } from './secrets';
4
4
  import { registry } from './agents/registry';
5
5
  import { resolveVaultConfig, usesVault } from './vault-config';
6
6
  import { record } from './config';
7
+ import { githubCredential } from './github/credential';
8
+ import { hostId } from './topology';
9
+ import { hostname } from 'node:os';
10
+ import { githubSignInState } from './github/sync';
7
11
 
8
12
  /** Inject every subprocess; never put credentials in argv, reports, or logs. */
9
- export async function workerAccessChecks(run: Runner): Promise<Check[]> {
10
- const policy = fleet.worker!;
11
- const github = await run(['gh', 'api', 'user', '--jq', '.login']);
13
+ export async function workerAccessChecks(
14
+ run: Runner,
15
+ nodeId?: string
16
+ ): Promise<Check[]> {
12
17
  return [
13
- {
14
- id: 'worker:github-principal',
15
- status:
16
- github.ok && github.stdout.trim() === policy.githubLogin
17
- ? 'pass'
18
- : 'fail',
19
- summary:
20
- 'Worker GitHub authentication must match the declared common account.',
21
- remedy: {
22
- action:
23
- 'Authenticate this worker with the declared GitHub account. Credentials stay on their original machines.',
24
- },
25
- },
18
+ await githubSync(run, nodeId),
26
19
  await proxyAuthentication(run),
27
20
  ...(usesVault() ? [await vaultRenewalCheck(run)] : []),
28
21
  ];
29
22
  }
23
+
24
+ const GITHUB_REMEDIES: Record<string, string> = {
25
+ GITHUB_TOKEN_MISSING:
26
+ 'The secrets provider holds no GitHub token for this worker. Store it under github-token (github-token-<node> with github.credential "per-node"); the next sign-in job or fleet-sync signs the worker in.',
27
+ GITHUB_TOKEN_REJECTED:
28
+ 'GitHub refuses the stored token: it was revoked or has expired. Store a new one under github-token; nothing about the secret store itself needs fixing.',
29
+ GITHUB_LOGIN_MISMATCH:
30
+ 'The stored token belongs to another account than worker.githubLogin. Store the fleet login’s token, or correct worker.githubLogin in the fleet configuration.',
31
+ GITHUB_UNREACHABLE:
32
+ 'GitHub did not answer from this worker. Check its network and DNS; the worker keeps the sign-in it has.',
33
+ GITHUB_SIGN_IN_FAILED:
34
+ 'The stored token is valid but this worker has not adopted it. Inspect the job with: journalctl --user -u personal-worker-github.service, or run fleet-sync.',
35
+ };
36
+
37
+ /** GitHub sign-in is measured on its own, so a GitHub fault is never reported
38
+ * as a fault of the secret store's renewal, and the code names the system to
39
+ * fix.
40
+ */
41
+ async function githubSync(run: Runner, nodeId?: string): Promise<Check> {
42
+ const login = fleet.worker?.githubLogin;
43
+ if (!login)
44
+ return {
45
+ id: 'worker:github-sync',
46
+ status: 'fail',
47
+ summary: 'GITHUB_LOGIN_UNDECLARED',
48
+ remedy: {
49
+ action: 'Declare the fleet GitHub login as worker.githubLogin.',
50
+ },
51
+ };
52
+ const code = await githubSignInState({
53
+ run,
54
+ login,
55
+ token: githubCredential(fleet, nodeId ?? hostId(hostname()), secrets(run)),
56
+ });
57
+ return code === 'GITHUB_OK'
58
+ ? {
59
+ id: 'worker:github-sync',
60
+ status: 'pass',
61
+ summary:
62
+ 'Worker GitHub authentication matches the declared common account.',
63
+ }
64
+ : {
65
+ id: 'worker:github-sync',
66
+ status: 'fail',
67
+ summary: code,
68
+ remedy: { action: GITHUB_REMEDIES[code]! },
69
+ };
70
+ }
71
+
72
+ const secrets = (run: Runner) =>
73
+ secretsProvider(fleet.secrets, {
74
+ run: async (argv) => {
75
+ const result = await run(argv, { timeoutMs: 20_000 });
76
+ return result.ok ? result.stdout : null;
77
+ },
78
+ });
79
+
30
80
  /** Vault refuses a read the token's policy does not allow with a 403. */
31
81
  const PERMISSION_DENIED = /permission denied|Code: 403/i;
32
82
 
@@ -16,7 +16,7 @@ export async function workerReadiness(
16
16
  root = process.cwd()
17
17
  ): Promise<Check[]> {
18
18
  const host = await workerHostChecks(userInfo().username, run);
19
- const access = await workerAccessChecks(run);
19
+ const access = await workerAccessChecks(run, nodeId);
20
20
  const revision = await run(['git', 'rev-parse', 'HEAD']);
21
21
  const singleton = await run(
22
22
  [