@crvouga/dev-fleet 0.3.0 → 0.4.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 (77) hide show
  1. package/README.md +22 -16
  2. package/ansible/github/git.yml +66 -0
  3. package/ansible/macos/policy.json +11 -1
  4. package/ansible/monitoring/compose.yml +10 -0
  5. package/ansible/monitoring/container-watchdog.py +118 -0
  6. package/ansible/monitoring/hub-darwin.yml +146 -0
  7. package/ansible/net/remote-probe.sh +5 -0
  8. package/ansible/ubuntu/baseline.yml +2 -0
  9. package/ansible/ubuntu/disk-gc.py +25 -3
  10. package/ansible/ubuntu/disk-guard.py +24 -5
  11. package/ansible/ubuntu/policy.json +50 -1
  12. package/ansible/ubuntu/tasks/browser.yml +56 -0
  13. package/ansible/ubuntu/tasks/docker.yml +52 -0
  14. package/ansible/ubuntu/tasks/resources.yml +2 -2
  15. package/ansible/ubuntu/tasks/services.yml +13 -0
  16. package/bootstrap/doctor.ts +32 -6
  17. package/bootstrap/fleet-remote-bootstrap.sh +31 -4
  18. package/bootstrap/setup.ts +23 -8
  19. package/bootstrap/worker-tools.ts +118 -0
  20. package/cliproxy/README.md +1 -1
  21. package/cliproxy/compose.yaml +4 -0
  22. package/cliproxy/scripts/checkpoint.py +1 -1
  23. package/docs/authentication.md +90 -0
  24. package/docs/declared.md +2 -0
  25. package/docs/machine-setup.md +22 -12
  26. package/docs/operations.md +87 -31
  27. package/docs/runbooks.md +380 -0
  28. package/docs/state.md +13 -7
  29. package/docs/ubuntu-sync.md +7 -6
  30. package/examples/fleet.json +19 -1
  31. package/llms.txt +2 -0
  32. package/mise.toml +7 -0
  33. package/package.json +2 -2
  34. package/schema/check-ids.json +522 -0
  35. package/schema/fleet.schema.json +178 -1
  36. package/schema/output/check.schema.json +23 -0
  37. package/schema/output/error.schema.json +14 -0
  38. package/schema/output/fleet-report.schema.json +79 -0
  39. package/schema/output/fleet-sync.schema.json +112 -0
  40. package/schema/output/report.schema.json +38 -0
  41. package/src/auth-checks.ts +3 -0
  42. package/src/cli/agent-guide.ts +5 -3
  43. package/src/cli/fleet-report.ts +126 -0
  44. package/src/cli/fleet-sync-manager.ts +11 -0
  45. package/src/cli/fleet-sync-plan.ts +200 -0
  46. package/src/cli/fleet-sync-remote.ts +3 -1
  47. package/src/cli/fleet-sync-schedule.ts +142 -35
  48. package/src/cli/fleet-sync.ts +90 -8
  49. package/src/cli/fleet-watch.ts +198 -0
  50. package/src/cli/fleet.ts +4 -0
  51. package/src/cli/register-fleet.ts +51 -0
  52. package/src/cli/registry.ts +13 -0
  53. package/src/cli/schedule-logs.ts +36 -0
  54. package/src/clock.ts +63 -0
  55. package/src/definition.ts +142 -5
  56. package/src/git-identity.ts +160 -0
  57. package/src/machine-health.ts +51 -4
  58. package/src/manager-health.ts +205 -0
  59. package/src/manager-sync.ts +12 -2
  60. package/src/monitoring/hub-backup.ts +63 -17
  61. package/src/monitoring/manager-hub.ts +44 -2
  62. package/src/node-converge-linux.ts +16 -5
  63. package/src/notify.ts +239 -0
  64. package/src/remote-bootstrap.ts +87 -25
  65. package/src/remote-operation.ts +213 -0
  66. package/src/schedule-check.ts +40 -0
  67. package/src/secrets.ts +26 -8
  68. package/src/ssh.ts +10 -2
  69. package/src/state/history.ts +172 -0
  70. package/src/state/store.ts +11 -4
  71. package/src/tailscale.ts +51 -0
  72. package/src/toolchain-checks.ts +8 -3
  73. package/src/worker-capability-checks.ts +167 -0
  74. package/src/worker-capacity.ts +19 -7
  75. package/src/worker-host-checks.ts +32 -0
  76. package/src/worker-policy.ts +33 -0
  77. package/src/worker-readiness.ts +10 -1
@@ -8,6 +8,7 @@ import { publicViewerIdentity, fleetAccount } from './hub-access';
8
8
  import { ensurePublicViewer, type HubLogin } from './hub-viewer';
9
9
  import { verifyHubBackups } from './hub-backup';
10
10
  import type { FleetDefinition } from '../definition';
11
+ import { secretVia } from '../fleet-config';
11
12
  import { hostId, hubUrl } from '../topology';
12
13
  import { publicHubUrl, privateHub } from './beszel';
13
14
  import type { Check, Runner } from '../types';
@@ -56,6 +57,9 @@ export interface ManagerHubOptions {
56
57
  receipts?: string;
57
58
  /** Ensures the read-only AUTO_LOGIN user; defaults to the loopback hub API. */
58
59
  viewer?: (login: HubLogin | null) => Promise<Check>;
60
+ /** The declared backups for the hub's playbook; defaults to the fleet
61
+ * configuration and its secrets. */
62
+ backupStorage?: () => Promise<Record<string, string | number | boolean>>;
59
63
  /** Reads the hub's backup settings; defaults to the loopback hub API. */
60
64
  backups?: (login: HubLogin | null) => Promise<Check>;
61
65
  }
@@ -78,13 +82,19 @@ export async function convergeManagerHub(
78
82
  const paths = managerHubPaths(options.home);
79
83
  const tunnel = await tunnelToken(run);
80
84
  const account = await (options.account ?? fleetAccount)();
85
+ const backups = await (
86
+ options.backupStorage ?? (() => backupVariables(definition, run))
87
+ )();
81
88
  const monitoring = resolve(root, 'ansible/monitoring');
82
89
  const result = await runAnsibleWithReceipt(
83
90
  run,
84
91
  'hub-darwin.yml',
85
92
  {
86
93
  cwd: monitoring,
87
- input: JSON.stringify(hubVariables(hub, paths, tunnel, account)),
94
+ input: JSON.stringify({
95
+ ...hubVariables(hub, paths, tunnel, account),
96
+ ...backups,
97
+ }),
88
98
  timeoutMs: 300_000,
89
99
  },
90
100
  {
@@ -127,7 +137,13 @@ export async function convergeManagerHub(
127
137
  },
128
138
  publicEndpointCheck(tunnel !== null),
129
139
  await ensureViewer(options, account),
130
- await (options.backups ?? verifyHubBackups)(account),
140
+ await (
141
+ options.backups ??
142
+ ((login) =>
143
+ verifyHubBackups(login, fetch, LOCAL_HUB, {
144
+ declared: definition.services.monitoring.backups,
145
+ }))
146
+ )(account),
131
147
  ];
132
148
  }
133
149
 
@@ -153,6 +169,32 @@ function hubVariables(
153
169
  };
154
170
  }
155
171
 
172
+ /** The declared backup schedule and storage, with the storage's keys from
173
+ * the fleet's secrets; nothing when no backups are declared or a key is
174
+ * missing (beszel:backup then says so).
175
+ */
176
+ export async function backupVariables(
177
+ definition: FleetDefinition,
178
+ run: Runner
179
+ ): Promise<Record<string, string | number | boolean>> {
180
+ const declared = definition.services.monitoring.backups;
181
+ if (!declared) return {};
182
+ const secret = secretVia(run);
183
+ const accessKey = await secret('monitoring-backup-access-key');
184
+ const secretKey = await secret('monitoring-backup-secret-key');
185
+ if (!accessKey || !secretKey) return {};
186
+ return {
187
+ beszel_backup_schedule: declared.schedule,
188
+ beszel_backup_keep: declared.keep,
189
+ beszel_backup_bucket: declared.storage.bucket,
190
+ beszel_backup_endpoint: declared.storage.endpoint,
191
+ beszel_backup_region: declared.storage.region,
192
+ beszel_backup_force_path_style: declared.storage.forcePathStyle ?? false,
193
+ beszel_backup_access_key: accessKey,
194
+ beszel_backup_secret_key: secretKey,
195
+ };
196
+ }
197
+
156
198
  function ensureViewer(
157
199
  options: ManagerHubOptions,
158
200
  account: HubLogin | null
@@ -22,6 +22,8 @@ import {
22
22
  import { userInfo } from 'node:os';
23
23
  import { resolve } from 'node:path';
24
24
  import { auditUbuntu } from './platform/ubuntu-audit';
25
+ import { convergeGitIdentity } from './git-identity';
26
+ import { workerSelection } from '../bootstrap/worker-tools';
25
27
  import {
26
28
  applyAgentProfiles,
27
29
  attempt,
@@ -59,7 +61,7 @@ export async function convergeLinux(
59
61
  await applyTailscale(root, run),
60
62
  ]))
61
63
  );
62
- const { baseline, verified } = await baselineStep(root, run);
64
+ const { baseline, verified } = await baselineStep(root, run, node);
63
65
  checks.push(...baseline, ...verified);
64
66
  checks.push(...(await t3AfterBaseline(root, { node, baseline, run })));
65
67
  checks.push(
@@ -67,6 +69,11 @@ export async function convergeLinux(
67
69
  configureWorkstation(root, run)
68
70
  ))
69
71
  );
72
+ checks.push(
73
+ ...(await attempt('git:configuration', () =>
74
+ convergeGitIdentity(root, fleet.git, run)
75
+ ))
76
+ );
70
77
  checks.push(
71
78
  ...(await attempt('configuration:apply', () =>
72
79
  applyAgentProfiles(root, node, revision, run)
@@ -110,7 +117,8 @@ export async function convergeLinux(
110
117
  */
111
118
  async function baselineStep(
112
119
  root: string,
113
- run: Runner
120
+ run: Runner,
121
+ node: string
114
122
  ): Promise<{ baseline: Check[]; verified: Check[] }> {
115
123
  const measure = async () => [
116
124
  ...(await attempt('ubuntu:boot-policy', () =>
@@ -122,14 +130,14 @@ async function baselineStep(
122
130
  ];
123
131
  let cached = false;
124
132
  let baseline = await attempt('ubuntu:baseline', async () => {
125
- const applied = await applyBaseline(root, run, false);
133
+ const applied = await applyBaseline(root, run, false, node);
126
134
  cached = applied.cached;
127
135
  return applied.checks;
128
136
  });
129
137
  let verified = await measure();
130
138
  if (cached && verified.some((check) => check.status === 'fail')) {
131
139
  baseline = await attempt('ubuntu:baseline', async () => {
132
- return (await applyBaseline(root, run, true)).checks;
140
+ return (await applyBaseline(root, run, true, node)).checks;
133
141
  });
134
142
  verified = await measure();
135
143
  }
@@ -139,7 +147,8 @@ async function baselineStep(
139
147
  async function applyBaseline(
140
148
  root: string,
141
149
  run: Runner,
142
- force: boolean
150
+ force: boolean,
151
+ node: string
143
152
  ): Promise<{ cached: boolean; checks: Check[] }> {
144
153
  const user = userInfo();
145
154
  const ubuntu = resolve(root, 'ansible/ubuntu');
@@ -154,6 +163,8 @@ async function applyBaseline(
154
163
  development_user: user.username,
155
164
  development_uid: user.uid,
156
165
  development_home: user.homedir,
166
+ // The web profile brings headless Chromium (tasks/browser.yml).
167
+ worker_tool_profiles: workerSelection(fleet, node).profiles,
157
168
  }),
158
169
  },
159
170
  {
package/src/notify.ts ADDED
@@ -0,0 +1,239 @@
1
+ import { record } from './config';
2
+ import type { SecretName, SecretsProvider } from './secrets';
3
+ import type { Check, NodeReport } from './types';
4
+
5
+ /** Where fleet-watch sends a notification. The address of each target is a
6
+ * secret, read by purpose: a webhook or ntfy URL often carries its own token.
7
+ */
8
+ export const NOTIFY_TARGETS = {
9
+ webhook: 'notify-webhook-url',
10
+ ntfy: 'notify-ntfy-url',
11
+ } as const satisfies Record<string, SecretName>;
12
+ export type NotifyTarget = keyof typeof NOTIFY_TARGETS;
13
+
14
+ export interface NotifyConfig {
15
+ targets: NotifyTarget[];
16
+ /** Notify again about a machine that stays unhealthy this often; 0 never. */
17
+ repeatHours: number;
18
+ }
19
+
20
+ const DEFAULT_REPEAT_HOURS = 24;
21
+
22
+ /** The `notify` block of a fleet configuration, or null when it is invalid. */
23
+ export function parseNotify(value: unknown): NotifyConfig | null {
24
+ if (!record(value)) return null;
25
+ if (
26
+ Object.keys(value).some((key) => !['targets', 'repeatHours'].includes(key))
27
+ )
28
+ return null;
29
+ const { targets, repeatHours = DEFAULT_REPEAT_HOURS } = value;
30
+ if (
31
+ !Array.isArray(targets) ||
32
+ targets.length === 0 ||
33
+ new Set(targets).size !== targets.length ||
34
+ !targets.every(
35
+ (target) =>
36
+ typeof target === 'string' && Object.hasOwn(NOTIFY_TARGETS, target)
37
+ ) ||
38
+ typeof repeatHours !== 'number' ||
39
+ !Number.isInteger(repeatHours) ||
40
+ repeatHours < 0 ||
41
+ repeatHours > 720
42
+ )
43
+ return null;
44
+ return { targets: targets as NotifyTarget[], repeatHours };
45
+ }
46
+
47
+ /** What the watcher remembers of each machine between two runs. */
48
+ export interface WatchMemory {
49
+ version: 1;
50
+ /** When the watcher last ran: the heartbeat an off-machine check reads. */
51
+ heartbeatAt: string;
52
+ nodes: Record<
53
+ string,
54
+ { state: 'ready' | 'degraded'; since: string; notifiedAt: string | null }
55
+ >;
56
+ }
57
+
58
+ /** One machine as fleet-status saw it: the IDs of its failing checks. */
59
+ export interface Observation {
60
+ node: string;
61
+ failing: string[];
62
+ }
63
+
64
+ export interface Notification {
65
+ event: 'degraded' | 'recovered' | 'still-degraded' | 'test';
66
+ node: string;
67
+ checks: string[];
68
+ }
69
+
70
+ /** Only what a report concluded: the required checks that failed. */
71
+ export function observe(nodes: NodeReport[]): Observation[] {
72
+ return nodes.map((node) => ({
73
+ node: node.node,
74
+ failing: node.checks
75
+ .filter((check) => check.status === 'fail' && check.required !== false)
76
+ .map((check) => check.id),
77
+ }));
78
+ }
79
+
80
+ /** Notifications on transitions only (ready to degraded and back), and a
81
+ * reminder for a machine that stays degraded longer than the repeat interval.
82
+ * A machine never seen before counts as ready.
83
+ */
84
+ export function transitions(
85
+ previous: WatchMemory | null,
86
+ observed: Observation[],
87
+ now: Date,
88
+ repeatHours: number
89
+ ): { notifications: Notification[]; memory: WatchMemory } {
90
+ const at = now.toISOString();
91
+ const notifications: Notification[] = [];
92
+ const nodes: WatchMemory['nodes'] = {};
93
+ for (const { node, failing } of observed) {
94
+ const before = previous?.nodes[node];
95
+ const state = failing.length ? 'degraded' : 'ready';
96
+ const checks = failing.filter(safe);
97
+ if (state === (before?.state ?? 'ready')) {
98
+ const due = state === 'degraded' && repeatDue(before, now, repeatHours);
99
+ if (due) notifications.push({ event: 'still-degraded', node, checks });
100
+ nodes[node] = before
101
+ ? { ...before, ...(due ? { notifiedAt: at } : {}) }
102
+ : { state, since: at, notifiedAt: null };
103
+ continue;
104
+ }
105
+ notifications.push({
106
+ event: state === 'degraded' ? 'degraded' : 'recovered',
107
+ node,
108
+ checks,
109
+ });
110
+ nodes[node] = { state, since: at, notifiedAt: at };
111
+ }
112
+ return { notifications, memory: { version: 1, heartbeatAt: at, nodes } };
113
+ }
114
+
115
+ function repeatDue(
116
+ before: WatchMemory['nodes'][string] | undefined,
117
+ now: Date,
118
+ repeatHours: number
119
+ ): boolean {
120
+ if (repeatHours <= 0 || !before?.notifiedAt) return false;
121
+ return (
122
+ now.getTime() - Date.parse(before.notifiedAt) >= repeatHours * 3_600_000
123
+ );
124
+ }
125
+
126
+ /** The memory stored last time, or null when there is none to trust. */
127
+ export function parseMemory(text: string | null): WatchMemory | null {
128
+ if (!text) return null;
129
+ try {
130
+ const value: unknown = JSON.parse(text);
131
+ if (
132
+ !record(value) ||
133
+ value['version'] !== 1 ||
134
+ typeof value['heartbeatAt'] !== 'string' ||
135
+ !record(value['nodes'])
136
+ )
137
+ return null;
138
+ return value as unknown as WatchMemory;
139
+ } catch {
140
+ return null;
141
+ }
142
+ }
143
+
144
+ /** Node and check IDs are the only words a notification carries. */
145
+ const safe = (id: string): boolean => /^[A-Za-z0-9:._@-]{1,128}$/.test(id);
146
+
147
+ const WORDS: Record<Notification['event'], string> = {
148
+ degraded: 'is degraded',
149
+ recovered: 'is ready again',
150
+ 'still-degraded': 'is still degraded',
151
+ test: 'sent a test notification',
152
+ };
153
+
154
+ export function notificationText(notification: Notification): string {
155
+ const node = safe(notification.node) ? notification.node : 'a machine';
156
+ const checks = notification.checks.filter(safe);
157
+ return `dev-fleet: ${node} ${WORDS[notification.event]}${
158
+ checks.length ? `. Failing checks: ${checks.join(', ')}` : ''
159
+ }`;
160
+ }
161
+
162
+ /** Sends one notification to every target. The result names the target and
163
+ * whether it took the notification: never its address or its answer.
164
+ */
165
+ export async function deliver(
166
+ config: NotifyConfig,
167
+ notification: Notification,
168
+ secrets: SecretsProvider,
169
+ send: typeof fetch = fetch
170
+ ): Promise<Check[]> {
171
+ const text = notificationText(notification);
172
+ const checks: Check[] = [];
173
+ for (const target of config.targets) {
174
+ const id = `notify:${target}`;
175
+ const url = await secrets(NOTIFY_TARGETS[target]);
176
+ if (!url || !/^https?:\/\//.test(url)) {
177
+ checks.push({
178
+ id,
179
+ status: 'fail',
180
+ summary: 'NOTIFY_TARGET_UNAVAILABLE',
181
+ remedy: {
182
+ action: `Store the address of the ${target} target as the secret "${NOTIFY_TARGETS[target]}" with the secret provider fleet.json names.`,
183
+ },
184
+ });
185
+ continue;
186
+ }
187
+ const ok = await send(url, request(target, notification, text))
188
+ .then((response) => response.ok)
189
+ .catch(() => false);
190
+ checks.push(
191
+ ok
192
+ ? {
193
+ id,
194
+ status: 'pass',
195
+ summary: `The ${target} target took the notification.`,
196
+ }
197
+ : {
198
+ id,
199
+ status: 'fail',
200
+ summary: 'NOTIFY_DELIVERY_FAILED',
201
+ remedy: {
202
+ action: `Check that the ${target} target answers, then run devfleet fleet-watch --test.`,
203
+ },
204
+ }
205
+ );
206
+ }
207
+ return checks;
208
+ }
209
+
210
+ function request(
211
+ target: NotifyTarget,
212
+ notification: Notification,
213
+ text: string
214
+ ): RequestInit {
215
+ const signal = AbortSignal.timeout(15_000);
216
+ if (target === 'ntfy')
217
+ return {
218
+ method: 'POST',
219
+ body: text,
220
+ headers: {
221
+ Title: `dev-fleet: ${notification.event}`,
222
+ Tags:
223
+ notification.event === 'recovered' ? 'white_check_mark' : 'warning',
224
+ },
225
+ signal,
226
+ };
227
+ return {
228
+ method: 'POST',
229
+ headers: { 'Content-Type': 'application/json' },
230
+ body: JSON.stringify({
231
+ source: 'dev-fleet',
232
+ event: notification.event,
233
+ node: safe(notification.node) ? notification.node : null,
234
+ checks: notification.checks.filter(safe),
235
+ text,
236
+ }),
237
+ signal,
238
+ };
239
+ }
@@ -4,7 +4,22 @@ import { join } from 'node:path';
4
4
  import { prepareSource } from './machine/source';
5
5
  import { isRevision } from './machine/state';
6
6
  import { sshArgs, shellQuote, sshFailure } from './ssh';
7
- import type { FleetNode, NodeReport, Runner, Check } from './types';
7
+ import type {
8
+ FleetNode,
9
+ NodeReport,
10
+ Runner,
11
+ Check,
12
+ CommandResult,
13
+ } from './types';
14
+ import {
15
+ followOperation,
16
+ realClock,
17
+ retrying,
18
+ RETRY_PAUSES_MS,
19
+ transient,
20
+ type Clock,
21
+ type Follow,
22
+ } from './remote-operation';
8
23
  import { record } from './config';
9
24
  import { installedDefinition, type FleetDefinition } from './definition';
10
25
  import { cleanCheck, validCheck } from './protocol';
@@ -94,9 +109,11 @@ async function bundleRevision(
94
109
  export async function bootstrapRemote(
95
110
  node: FleetNode,
96
111
  payload: BootstrapPayload,
97
- run: Runner
112
+ run: Runner,
113
+ follow: Follow = {}
98
114
  ): Promise<NodeReport> {
99
- const cached = await cachedRevision(node, payload.revision, run);
115
+ const clock = follow.clock ?? realClock;
116
+ const cached = await cachedRevision(node, payload.revision, run, clock);
100
117
  if (typeof cached !== 'boolean') return { node: node.id, checks: [cached] };
101
118
  const transfer = cached
102
119
  ? { mode: 'cached', bundle: '' }
@@ -113,27 +130,68 @@ export async function bootstrapRemote(
113
130
  ]
114
131
  .map(shellQuote)
115
132
  .join(' ');
116
- const result = await run([...sshArgs(node), command], {
117
- input: bootstrapInput(payload.config, transfer.bundle),
118
- timeoutMs: 5_400_000,
119
- });
120
- const evidence = parseEvidence(result);
121
- if (evidence) return { node: node.id, checks: evidence };
122
- if (!result.ok) return { node: node.id, checks: [sshFailure(result)] };
123
- return {
124
- node: node.id,
125
- checks: [
126
- {
127
- id: 'bootstrap:protocol',
128
- status: 'fail',
129
- summary: 'REMOTE_BOOTSTRAP_EVIDENCE_INVALID',
130
- remedy: {
131
- action:
132
- 'Inspect the source-owned remote operation and lock. Do not assume a manager disconnect stopped installation.',
133
- },
133
+ const checks = await bootstrapSessions(
134
+ node,
135
+ payload,
136
+ { command, input: bootstrapInput(payload.config, transfer.bundle) },
137
+ { run, follow: { ...follow, clock } }
138
+ );
139
+ return { node: node.id, checks };
140
+ }
141
+
142
+ /** Opens the bootstrap session, and again after a lost one unless the
143
+ * worker's own operation can be followed to its end instead.
144
+ */
145
+ async function bootstrapSessions(
146
+ node: FleetNode,
147
+ payload: BootstrapPayload,
148
+ session: { command: string; input: string },
149
+ context: { run: Runner; follow: Follow & { clock: Clock } }
150
+ ): Promise<Check[]> {
151
+ const { run, follow } = context;
152
+ let result: CommandResult | undefined;
153
+ for (const pause of [0, ...RETRY_PAUSES_MS]) {
154
+ if (result) {
155
+ // The session was lost, not the operation: the worker keeps running
156
+ // it, so the manager follows it rather than starting another.
157
+ await follow.clock.pause(pause);
158
+ const followed = await followOperation(
159
+ node,
160
+ payload.revision,
161
+ run,
162
+ follow
163
+ );
164
+ if (followed) return followed;
165
+ }
166
+ result = await run([...sshArgs(node), session.command], {
167
+ input: session.input,
168
+ timeoutMs: 5_400_000,
169
+ });
170
+ const evidence = parseEvidence(result);
171
+ if (evidence && locked(evidence))
172
+ return (
173
+ (await followOperation(node, payload.revision, run, follow)) ?? evidence
174
+ );
175
+ if (evidence) return evidence;
176
+ if (result.reason !== 'exit' || !transient(result)) break;
177
+ }
178
+ if (!result!.ok) return [sshFailure(result!)];
179
+ return [
180
+ {
181
+ id: 'bootstrap:protocol',
182
+ status: 'fail',
183
+ summary: 'REMOTE_BOOTSTRAP_EVIDENCE_INVALID',
184
+ remedy: {
185
+ action:
186
+ 'Inspect the source-owned remote operation and lock. Do not assume a manager disconnect stopped installation.',
134
187
  },
135
- ],
136
- };
188
+ },
189
+ ];
190
+ }
191
+
192
+ /** Another operation holds the worker: its lock was the first thing tried. */
193
+ function locked(checks: Check[]): boolean {
194
+ return checks.some((check) => check.summary === 'FLEET_REMOTE_LOCKED');
137
195
  }
138
196
 
139
197
  /** What the bootstrap reads on stdin: one line with the fleet configuration
@@ -151,10 +209,14 @@ export function bootstrapInput(
151
209
  async function cachedRevision(
152
210
  node: FleetNode,
153
211
  revision: string,
154
- run: Runner
212
+ run: Runner,
213
+ clock: Clock
155
214
  ): Promise<boolean | Check> {
156
215
  const command = `d="$HOME/.local/share/personal/fleet/revisions/${revision}"; test ! -L "$d" && test -d "$d/.git" && test "$(git -C "$d" rev-parse HEAD 2>/dev/null)" = '${revision}' && test "$(git -C "$d" rev-parse --show-toplevel 2>/dev/null)" = "$d" && clean=$(git -C "$d" status --porcelain 2>/dev/null) && test -z "$clean" && printf ready`;
157
- const result = await run([...sshArgs(node), command], { timeoutMs: 20_000 });
216
+ const result = await retrying(
217
+ () => run([...sshArgs(node), command], { timeoutMs: 20_000 }),
218
+ clock
219
+ );
158
220
  if (!result.ok) {
159
221
  const failure = sshFailure(result);
160
222
  if (result.reason !== 'exit' || failure.id !== 'ssh:probe') return failure;