@crvouga/dev-fleet 0.3.0 → 0.5.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 (97) hide show
  1. package/README.md +25 -17
  2. package/ansible/github/git.yml +66 -0
  3. package/ansible/macos/policy.json +12 -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/platforms.json +111 -0
  9. package/ansible/ubuntu/authorize-sudo.sh +7 -16
  10. package/ansible/ubuntu/baseline.yml +6 -28
  11. package/ansible/ubuntu/disk-gc.py +211 -7
  12. package/ansible/ubuntu/disk-guard.py +24 -5
  13. package/ansible/ubuntu/policy.json +68 -4
  14. package/ansible/ubuntu/t3-desktop.yml +2 -2
  15. package/ansible/ubuntu/tailscale.yml +20 -9
  16. package/ansible/ubuntu/tasks/browser.yml +56 -0
  17. package/ansible/ubuntu/tasks/docker.yml +52 -0
  18. package/ansible/ubuntu/tasks/dpkg.yml +46 -0
  19. package/ansible/ubuntu/tasks/install.yml +38 -0
  20. package/ansible/ubuntu/tasks/packages.yml +4 -55
  21. package/ansible/ubuntu/tasks/release.yml +25 -0
  22. package/ansible/ubuntu/tasks/resources.yml +7 -6
  23. package/ansible/ubuntu/tasks/services.yml +13 -0
  24. package/ansible/ubuntu/tasks/snapshot.yml +50 -0
  25. package/ansible/ubuntu/tasks/unattended-upgrades.yml +24 -0
  26. package/bootstrap/doctor.ts +32 -6
  27. package/bootstrap/fleet-remote-bootstrap.sh +49 -45
  28. package/bootstrap/setup.ts +23 -8
  29. package/bootstrap/worker-tools.ts +118 -0
  30. package/cliproxy/README.md +1 -1
  31. package/cliproxy/compose.yaml +4 -0
  32. package/cliproxy/scripts/checkpoint.py +1 -1
  33. package/docs/authentication.md +90 -0
  34. package/docs/consumer-setup.md +32 -30
  35. package/docs/declared.md +6 -5
  36. package/docs/machine-setup.md +22 -12
  37. package/docs/operations.md +87 -31
  38. package/docs/runbooks.md +377 -0
  39. package/docs/state.md +13 -7
  40. package/docs/support-matrix.md +36 -0
  41. package/docs/ubuntu-sync.md +16 -13
  42. package/docs/worker-contract.md +28 -1
  43. package/examples/fleet.json +19 -1
  44. package/llms.txt +2 -0
  45. package/mise.toml +7 -0
  46. package/package.json +2 -2
  47. package/schema/check-ids.json +526 -0
  48. package/schema/fleet.schema.json +185 -4
  49. package/schema/output/check.schema.json +23 -0
  50. package/schema/output/error.schema.json +14 -0
  51. package/schema/output/fleet-report.schema.json +79 -0
  52. package/schema/output/fleet-sync.schema.json +112 -0
  53. package/schema/output/report.schema.json +38 -0
  54. package/src/auth-checks.ts +3 -0
  55. package/src/cli/agent-guide.ts +5 -3
  56. package/src/cli/fleet-report.ts +126 -0
  57. package/src/cli/fleet-sync-manager.ts +11 -0
  58. package/src/cli/fleet-sync-plan.ts +200 -0
  59. package/src/cli/fleet-sync-remote.ts +4 -2
  60. package/src/cli/fleet-sync-schedule.ts +142 -35
  61. package/src/cli/fleet-sync.ts +90 -8
  62. package/src/cli/fleet-watch.ts +198 -0
  63. package/src/cli/fleet.ts +4 -0
  64. package/src/cli/register-fleet.ts +51 -0
  65. package/src/cli/registry.ts +13 -0
  66. package/src/cli/schedule-logs.ts +36 -0
  67. package/src/clock.ts +63 -0
  68. package/src/definition.ts +160 -12
  69. package/src/git-identity.ts +160 -0
  70. package/src/machine/platform.ts +48 -28
  71. package/src/machine/support-matrix.ts +76 -0
  72. package/src/machine-health.ts +51 -4
  73. package/src/manager-health.ts +205 -0
  74. package/src/manager-sync.ts +27 -9
  75. package/src/monitoring/hub-backup.ts +63 -17
  76. package/src/monitoring/manager-hub.ts +44 -2
  77. package/src/network-definition.ts +1 -1
  78. package/src/node-converge-linux.ts +16 -5
  79. package/src/node-converge.ts +1 -1
  80. package/src/notify.ts +239 -0
  81. package/src/platform/debian-version.ts +62 -0
  82. package/src/platform/ubuntu-audit.ts +11 -4
  83. package/src/platform/ubuntu-release.ts +41 -0
  84. package/src/remote-bootstrap.ts +89 -25
  85. package/src/remote-operation.ts +213 -0
  86. package/src/schedule-check.ts +40 -0
  87. package/src/secrets.ts +26 -8
  88. package/src/ssh.ts +10 -2
  89. package/src/state/history.ts +172 -0
  90. package/src/state/store.ts +11 -4
  91. package/src/tailscale.ts +51 -0
  92. package/src/toolchain-checks.ts +8 -3
  93. package/src/worker-capability-checks.ts +167 -0
  94. package/src/worker-capacity.ts +19 -7
  95. package/src/worker-host-checks.ts +68 -10
  96. package/src/worker-policy.ts +33 -0
  97. package/src/worker-readiness.ts +10 -1
@@ -0,0 +1,62 @@
1
+ /** Debian version ordering, as `dpkg --compare-versions` applies it:
2
+ * epoch, then upstream version, then revision. Negative when `a` sorts
3
+ * before `b`, zero when they are the same version, positive after. */
4
+ export function compareDebianVersions(a: string, b: string): number {
5
+ const [epochA, upstreamA, revisionA] = split(a);
6
+ const [epochB, upstreamB, revisionB] = split(b);
7
+ return (
8
+ epochA - epochB ||
9
+ comparePart(upstreamA, upstreamB) ||
10
+ comparePart(revisionA, revisionB)
11
+ );
12
+ }
13
+
14
+ function split(version: string): [number, string, string] {
15
+ const colon = version.indexOf(':');
16
+ const epoch = colon > 0 ? Number(version.slice(0, colon)) || 0 : 0;
17
+ const rest = colon > 0 ? version.slice(colon + 1) : version;
18
+ const dash = rest.lastIndexOf('-');
19
+ return dash > 0
20
+ ? [epoch, rest.slice(0, dash), rest.slice(dash + 1)]
21
+ : [epoch, rest, '0'];
22
+ }
23
+
24
+ /** `~` sorts before everything, even the end of the string; letters sort
25
+ * before every other character. */
26
+ function order(char: string | undefined): number {
27
+ if (char === undefined) return 0;
28
+ if (char === '~') return -1;
29
+ if (/[A-Za-z]/.test(char)) return char.charCodeAt(0);
30
+ return char.charCodeAt(0) + 256;
31
+ }
32
+
33
+ /** Alternating runs: text, then digits, then text again, starting with text. */
34
+ function runs(part: string): string[] {
35
+ return part
36
+ .match(/\D*\d*/g)!
37
+ .filter(Boolean)
38
+ .flatMap((run) => {
39
+ const digits = /\d*$/.exec(run)![0];
40
+ return [run.slice(0, run.length - digits.length), digits];
41
+ });
42
+ }
43
+
44
+ function compareText(a: string, b: string): number {
45
+ for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
46
+ const difference = order(a[i]) - order(b[i]);
47
+ if (difference) return difference;
48
+ }
49
+ return 0;
50
+ }
51
+
52
+ function comparePart(a: string, b: string): number {
53
+ const left = runs(a);
54
+ const right = runs(b);
55
+ for (let i = 0; i < Math.max(left.length, right.length); i += 2) {
56
+ const difference =
57
+ compareText(left[i] ?? '', right[i] ?? '') ||
58
+ Number(left[i + 1] || 0) - Number(right[i + 1] || 0);
59
+ if (difference) return difference;
60
+ }
61
+ return 0;
62
+ }
@@ -1,5 +1,5 @@
1
- import policy from '../../ansible/ubuntu/policy.json';
2
1
  import type { Runner } from '../types';
2
+ import { packagePins, UBUNTU_RELEASES, ubuntuRelease } from './ubuntu-release';
3
3
 
4
4
  export interface UbuntuAudit {
5
5
  supported: boolean;
@@ -11,7 +11,13 @@ export interface UbuntuAudit {
11
11
  passwordlessSudo: boolean;
12
12
  rebootPending: boolean;
13
13
  }
14
- const PACKAGES = [...Object.keys(policy.packages), 'tailscale'];
14
+ // Every package any accepted release pins, so one audit serves them all.
15
+ const PACKAGES = [
16
+ ...new Set([
17
+ ...UBUNTU_RELEASES.flatMap((release) => Object.keys(packagePins(release))),
18
+ 'tailscale',
19
+ ]),
20
+ ];
15
21
 
16
22
  /** Read-only audit: never emit package sources, environment, accounts or raw errors. */
17
23
  export async function auditUbuntu(run: Runner): Promise<UbuntuAudit> {
@@ -32,14 +38,15 @@ export async function auditUbuntu(run: Runner): Promise<UbuntuAudit> {
32
38
  const version =
33
39
  /^VERSION_ID="?([0-9]+\.[0-9]+)"?$/m.exec(release.stdout)?.[1] ?? null;
34
40
  const reported = arch.stdout.trim();
35
- const architecture = policy.architectures.includes(reported)
41
+ const accepted = ubuntuRelease(version);
42
+ const architecture = accepted?.architectures.includes(reported)
36
43
  ? reported
37
44
  : null;
38
45
  return {
39
46
  supported:
40
47
  release.ok &&
41
48
  /^ID=ubuntu$/m.test(release.stdout) &&
42
- version === policy.release &&
49
+ accepted !== undefined &&
43
50
  architecture !== null,
44
51
  release: version,
45
52
  architecture,
@@ -0,0 +1,41 @@
1
+ import policy from '../../ansible/ubuntu/policy.json';
2
+
3
+ /** One Ubuntu release workers may run. The accepted releases are named once,
4
+ * in ansible/ubuntu/policy.json; nothing else spells a release or codename. */
5
+ export interface UbuntuRelease {
6
+ release: string;
7
+ codename: string;
8
+ /** Debian architecture names, as `dpkg --print-architecture` reports them. */
9
+ architectures: string[];
10
+ /** Pins of this release that differ from the policy's `packages`. */
11
+ packages?: Record<string, string>;
12
+ }
13
+
14
+ export const UBUNTU_RELEASES: readonly UbuntuRelease[] = policy.releases;
15
+
16
+ /** The package pins of one release: the policy's, with the release's own
17
+ * pins over them (as tasks/release.yml combines them for Ansible). */
18
+ export function packagePins(
19
+ release: UbuntuRelease | undefined
20
+ ): Record<string, string> {
21
+ return { ...policy.packages, ...release?.packages };
22
+ }
23
+
24
+ /** `uname -m` for each Debian architecture name. */
25
+ const KERNEL_ARCHITECTURES: Record<string, string> = {
26
+ amd64: 'x86_64',
27
+ arm64: 'aarch64',
28
+ };
29
+
30
+ export function ubuntuRelease(
31
+ version: string | null | undefined
32
+ ): UbuntuRelease | undefined {
33
+ return UBUNTU_RELEASES.find((entry) => entry.release === version);
34
+ }
35
+
36
+ /** The `uname -m` names one release runs on. */
37
+ export function kernelArchitectures(release: UbuntuRelease): string[] {
38
+ return release.architectures.map(
39
+ (name) => KERNEL_ARCHITECTURES[name] ?? name
40
+ );
41
+ }
@@ -4,10 +4,26 @@ 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';
26
+ import { UBUNTU_RELEASES } from './platform/ubuntu-release';
11
27
 
12
28
  export interface BootstrapPayload {
13
29
  revision: string;
@@ -94,9 +110,11 @@ async function bundleRevision(
94
110
  export async function bootstrapRemote(
95
111
  node: FleetNode,
96
112
  payload: BootstrapPayload,
97
- run: Runner
113
+ run: Runner,
114
+ follow: Follow = {}
98
115
  ): Promise<NodeReport> {
99
- const cached = await cachedRevision(node, payload.revision, run);
116
+ const clock = follow.clock ?? realClock;
117
+ const cached = await cachedRevision(node, payload.revision, run, clock);
100
118
  if (typeof cached !== 'boolean') return { node: node.id, checks: [cached] };
101
119
  const transfer = cached
102
120
  ? { mode: 'cached', bundle: '' }
@@ -110,30 +128,72 @@ export async function bootstrapRemote(
110
128
  payload.digest,
111
129
  node.id,
112
130
  transfer.mode,
131
+ UBUNTU_RELEASES.map((entry) => entry.release).join(' '),
113
132
  ]
114
133
  .map(shellQuote)
115
134
  .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
- },
135
+ const checks = await bootstrapSessions(
136
+ node,
137
+ payload,
138
+ { command, input: bootstrapInput(payload.config, transfer.bundle) },
139
+ { run, follow: { ...follow, clock } }
140
+ );
141
+ return { node: node.id, checks };
142
+ }
143
+
144
+ /** Opens the bootstrap session, and again after a lost one unless the
145
+ * worker's own operation can be followed to its end instead.
146
+ */
147
+ async function bootstrapSessions(
148
+ node: FleetNode,
149
+ payload: BootstrapPayload,
150
+ session: { command: string; input: string },
151
+ context: { run: Runner; follow: Follow & { clock: Clock } }
152
+ ): Promise<Check[]> {
153
+ const { run, follow } = context;
154
+ let result: CommandResult | undefined;
155
+ for (const pause of [0, ...RETRY_PAUSES_MS]) {
156
+ if (result) {
157
+ // The session was lost, not the operation: the worker keeps running
158
+ // it, so the manager follows it rather than starting another.
159
+ await follow.clock.pause(pause);
160
+ const followed = await followOperation(
161
+ node,
162
+ payload.revision,
163
+ run,
164
+ follow
165
+ );
166
+ if (followed) return followed;
167
+ }
168
+ result = await run([...sshArgs(node), session.command], {
169
+ input: session.input,
170
+ timeoutMs: 5_400_000,
171
+ });
172
+ const evidence = parseEvidence(result);
173
+ if (evidence && locked(evidence))
174
+ return (
175
+ (await followOperation(node, payload.revision, run, follow)) ?? evidence
176
+ );
177
+ if (evidence) return evidence;
178
+ if (result.reason !== 'exit' || !transient(result)) break;
179
+ }
180
+ if (!result!.ok) return [sshFailure(result!)];
181
+ return [
182
+ {
183
+ id: 'bootstrap:protocol',
184
+ status: 'fail',
185
+ summary: 'REMOTE_BOOTSTRAP_EVIDENCE_INVALID',
186
+ remedy: {
187
+ action:
188
+ 'Inspect the source-owned remote operation and lock. Do not assume a manager disconnect stopped installation.',
134
189
  },
135
- ],
136
- };
190
+ },
191
+ ];
192
+ }
193
+
194
+ /** Another operation holds the worker: its lock was the first thing tried. */
195
+ function locked(checks: Check[]): boolean {
196
+ return checks.some((check) => check.summary === 'FLEET_REMOTE_LOCKED');
137
197
  }
138
198
 
139
199
  /** What the bootstrap reads on stdin: one line with the fleet configuration
@@ -151,10 +211,14 @@ export function bootstrapInput(
151
211
  async function cachedRevision(
152
212
  node: FleetNode,
153
213
  revision: string,
154
- run: Runner
214
+ run: Runner,
215
+ clock: Clock
155
216
  ): Promise<boolean | Check> {
156
217
  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 });
218
+ const result = await retrying(
219
+ () => run([...sshArgs(node), command], { timeoutMs: 20_000 }),
220
+ clock
221
+ );
158
222
  if (!result.ok) {
159
223
  const failure = sshFailure(result);
160
224
  if (result.reason !== 'exit' || failure.id !== 'ssh:probe') return failure;
@@ -0,0 +1,213 @@
1
+ import { record } from './config';
2
+ import { cleanCheck, validCheck } from './protocol';
3
+ import { sshArgs, sshFailure } from './ssh';
4
+ import type { Check, CommandResult, FleetNode, Runner } from './types';
5
+
6
+ /** How the manager waits: a pause, and the time now. Tests answer both. */
7
+ export interface Clock {
8
+ pause(ms: number): Promise<void>;
9
+ now(): number;
10
+ }
11
+
12
+ export const realClock: Clock = {
13
+ pause: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
14
+ now: () => Date.now(),
15
+ };
16
+
17
+ /** A session that could not be opened, or was lost, is tried again after
18
+ * these pauses: three attempts over thirty seconds.
19
+ */
20
+ export const RETRY_PAUSES_MS = [10_000, 20_000];
21
+ /** How often an operation the manager follows is looked at again. */
22
+ export const FOLLOW_EVERY_MS = 15_000;
23
+ /** As long as a bootstrap may run. */
24
+ export const FOLLOW_AT_MOST_MS = 5_400_000;
25
+
26
+ /** Failures of the connection, not of the worker: worth another attempt. A
27
+ * changed host key, a refused login or an unknown name never are.
28
+ */
29
+ export function transient(result: CommandResult): boolean {
30
+ if (result.ok) return false;
31
+ const { id } = sshFailure(result);
32
+ return ['ssh:disconnected', 'ssh:network', 'ssh:timeout'].includes(id);
33
+ }
34
+
35
+ /** Runs a short SSH session, again after a pause while its connection fails. */
36
+ export async function retrying(
37
+ attempt: () => Promise<CommandResult>,
38
+ clock: Clock
39
+ ): Promise<CommandResult> {
40
+ let result = await attempt();
41
+ for (const pause of RETRY_PAUSES_MS) {
42
+ if (!transient(result)) return result;
43
+ await clock.pause(pause);
44
+ result = await attempt();
45
+ }
46
+ return result;
47
+ }
48
+
49
+ /** What a worker says about the fleet operation on it. */
50
+ export interface Operation {
51
+ /** The operation lock is held by a running process. */
52
+ held: boolean;
53
+ /** The process that recorded the operation is still running. */
54
+ alive: boolean;
55
+ revision: string | null;
56
+ phase: string | null;
57
+ /** Seconds since the epoch, on the worker's clock. */
58
+ started: number | null;
59
+ finished: boolean;
60
+ /** What node convergence reported, once the operation finished. */
61
+ result: Check[] | null;
62
+ }
63
+
64
+ /** Read-only. Prints whether the lock is held, whether the recorded process
65
+ * runs, the operation record and its result, one per line. Holding the lock
66
+ * for a moment to test it is not possible while another holds it, and takes
67
+ * nothing from anyone when nobody does.
68
+ */
69
+ export const OPERATION_PROBE = [
70
+ 'r="$HOME/.local/share/personal/fleet"',
71
+ 'l="$r/remote-operation.lock"',
72
+ 'held=no',
73
+ 'if [ -f "$l" ] && [ ! -L "$l" ]; then if command -v flock >/dev/null 2>&1; then flock -n "$l" true || held=yes; else python3 -c \'import fcntl, sys; fcntl.flock(open(sys.argv[1], "a"), fcntl.LOCK_EX | fcntl.LOCK_NB)\' "$l" 2>/dev/null || held=yes; fi; fi',
74
+ 'op=$(head -c 4096 "$r/remote-operation.json" 2>/dev/null | head -n 1)',
75
+ 'pid=$(printf %s "$op" | sed -n \'s/.*"pid":\\([0-9][0-9]*\\).*/\\1/p\')',
76
+ 'alive=no',
77
+ 'if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then alive=yes; fi',
78
+ 'printf \'held=%s\\nalive=%s\\noperation=%s\\nresult=\' "$held" "$alive" "$op"',
79
+ 'head -c 1048576 "$r/remote-operation-result.json" 2>/dev/null | head -n 1',
80
+ "printf '\\n'",
81
+ ].join('\n');
82
+
83
+ function field(stdout: string, name: string): string | null {
84
+ const line = stdout.split('\n').find((entry) => entry.startsWith(`${name}=`));
85
+ return line === undefined ? null : line.slice(name.length + 1);
86
+ }
87
+
88
+ function json(text: string | null): unknown {
89
+ try {
90
+ return text ? JSON.parse(text) : null;
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ function evidence(value: unknown): Check[] | null {
97
+ return record(value) &&
98
+ value['version'] === 1 &&
99
+ Array.isArray(value['checks']) &&
100
+ value['checks'].length > 0 &&
101
+ value['checks'].length <= 200 &&
102
+ value['checks'].every(validCheck)
103
+ ? value['checks'].map(cleanCheck)
104
+ : null;
105
+ }
106
+
107
+ export function parseOperation(stdout: string): Operation | null {
108
+ const held = field(stdout, 'held');
109
+ const alive = field(stdout, 'alive');
110
+ if (held === null || alive === null) return null;
111
+ const operation = json(field(stdout, 'operation'));
112
+ const op = record(operation) ? operation : {};
113
+ const text = (value: unknown, pattern: RegExp): string | null =>
114
+ typeof value === 'string' && pattern.test(value) ? value : null;
115
+ return {
116
+ held: held === 'yes',
117
+ alive: alive === 'yes',
118
+ revision: text(op['revision'], /^[a-f0-9]{40}$/),
119
+ phase: text(op['phase'], /^[a-z0-9-]{1,40}$/),
120
+ started: typeof op['started'] === 'number' ? op['started'] : null,
121
+ finished: typeof op['finished'] === 'number',
122
+ result: evidence(json(field(stdout, 'result'))),
123
+ };
124
+ }
125
+
126
+ export async function inspectOperation(
127
+ node: FleetNode,
128
+ run: Runner,
129
+ clock: Clock
130
+ ): Promise<Operation | null> {
131
+ const result = await retrying(
132
+ () => run([...sshArgs(node), OPERATION_PROBE], { timeoutMs: 20_000 }),
133
+ clock
134
+ );
135
+ return result.ok ? parseOperation(result.stdout) : null;
136
+ }
137
+
138
+ /** An operation of this revision whose process still runs: this is the run
139
+ * the manager lost, or one another manager run started; either way it is
140
+ * the run to follow, not one to start beside it.
141
+ */
142
+ function followable(operation: Operation, revision: string): boolean {
143
+ return (
144
+ operation.held &&
145
+ operation.alive &&
146
+ !operation.finished &&
147
+ operation.revision === revision
148
+ );
149
+ }
150
+
151
+ function finishedWith(operation: Operation, revision: string): Check[] | null {
152
+ return !operation.held &&
153
+ operation.finished &&
154
+ operation.revision === revision
155
+ ? operation.result
156
+ : null;
157
+ }
158
+
159
+ export interface Follow {
160
+ clock?: Clock;
161
+ /** Told the phase and how long the operation has run, at every look. */
162
+ progress?: (phase: string) => void;
163
+ }
164
+
165
+ /** After a lost session or a held lock: the result of the operation of this
166
+ * revision on the worker, followed to its end when it still runs. Null when
167
+ * there is none to follow: another revision, another holder, or nothing.
168
+ */
169
+ export async function followOperation(
170
+ node: FleetNode,
171
+ revision: string,
172
+ run: Runner,
173
+ options: Follow = {}
174
+ ): Promise<Check[] | null> {
175
+ const clock = options.clock ?? realClock;
176
+ const deadline = clock.now() + FOLLOW_AT_MOST_MS;
177
+ let operation = await inspectOperation(node, run, clock);
178
+ if (!operation) return null;
179
+ const done = finishedWith(operation, revision);
180
+ if (done) return [attached(operation, 'finished'), ...done];
181
+ if (!followable(operation, revision)) return null;
182
+ let last: Operation = operation;
183
+ while (clock.now() < deadline) {
184
+ options.progress?.(describe(last, clock));
185
+ await clock.pause(FOLLOW_EVERY_MS);
186
+ operation = await inspectOperation(node, run, clock);
187
+ if (!operation) continue;
188
+ last = operation;
189
+ const result = finishedWith(operation, revision);
190
+ if (result) return [attached(operation, 'followed'), ...result];
191
+ if (!followable(operation, revision)) return null;
192
+ }
193
+ return null;
194
+ }
195
+
196
+ function describe(operation: Operation, clock: Clock): string {
197
+ const elapsed =
198
+ operation.started === null
199
+ ? ''
200
+ : `, ${Math.max(0, Math.round(clock.now() / 1000 - operation.started))} s`;
201
+ return `Following the running operation (${operation.phase ?? 'unknown phase'}${elapsed})`;
202
+ }
203
+
204
+ function attached(_operation: Operation, how: 'finished' | 'followed'): Check {
205
+ return {
206
+ id: 'bootstrap:attached',
207
+ status: 'pass',
208
+ summary:
209
+ how === 'followed'
210
+ ? 'The manager lost or could not take this machine’s operation of the same revision, followed it to its end and reports its result.'
211
+ : 'This machine finished the operation of the same revision the manager lost; its result is reported.',
212
+ };
213
+ }
@@ -0,0 +1,40 @@
1
+ import type { History } from './state/history';
2
+ import type { Check } from './types';
3
+
4
+ /** How often the manager's schedule runs fleet-sync. */
5
+ export const SCHEDULE_INTERVAL_SECONDS = 1800;
6
+
7
+ /** `manager:schedule`: a schedule that is on must have succeeded within two
8
+ * of its intervals. A schedule that was never turned on is no check at all.
9
+ */
10
+ export function scheduleCheck(history: History, now: Date): Check | null {
11
+ const { schedule } = history;
12
+ if (!schedule?.enabled) return null;
13
+ const window = 2 * schedule.intervalSeconds * 1000;
14
+ const lastSuccess = history.runs
15
+ .filter((run) => run.trigger === 'schedule' && run.ok)
16
+ .map((run) => Date.parse(run.finishedAt))
17
+ .reduce((latest, at) => Math.max(latest, at), Number.NEGATIVE_INFINITY);
18
+ // A schedule turned on a moment ago has had no chance to run yet.
19
+ const since = Math.max(lastSuccess, Date.parse(schedule.changedAt));
20
+ const minutes = Math.round(schedule.intervalSeconds / 60);
21
+ if (now.getTime() - since <= window)
22
+ return {
23
+ id: 'manager:schedule',
24
+ status: 'pass',
25
+ summary: Number.isFinite(lastSuccess)
26
+ ? `The schedule last converged the fleet at ${new Date(lastSuccess).toISOString()}.`
27
+ : 'The schedule was turned on and has not had to run yet.',
28
+ };
29
+ return {
30
+ id: 'manager:schedule',
31
+ status: 'fail',
32
+ summary: Number.isFinite(lastSuccess)
33
+ ? `The schedule has not converged the fleet since ${new Date(lastSuccess).toISOString()}, more than two intervals of ${minutes} minutes.`
34
+ : `The schedule has not converged the fleet once since it was turned on, more than two intervals of ${minutes} minutes ago.`,
35
+ remedy: {
36
+ action:
37
+ 'Run devfleet fleet-sync --schedule status on the manager to see the last scheduled runs, then devfleet fleet-report for the failing checks. Re-enable it with devfleet fleet-sync --schedule enable --apply --yes when the manager was replaced.',
38
+ },
39
+ };
40
+ }
package/src/secrets.ts CHANGED
@@ -12,7 +12,20 @@ export const SECRET_NAMES = [
12
12
  'monitoring-tunnel-token',
13
13
  'proxy-tunnel-token',
14
14
  ] as const;
15
- export type SecretName = (typeof SECRET_NAMES)[number];
15
+ /** Secrets only a feature the fleet declares needs: a Vault location may
16
+ * leave them out, and they are then unavailable.
17
+ */
18
+ export const OPTIONAL_SECRET_NAMES = [
19
+ // The off-machine storage of the monitoring hub's backups
20
+ // (services.monitoring.backups).
21
+ 'monitoring-backup-access-key',
22
+ 'monitoring-backup-secret-key',
23
+ // Where fleet-watch notifies (notify.targets).
24
+ 'notify-webhook-url',
25
+ 'notify-ntfy-url',
26
+ ] as const;
27
+ export type SecretName =
28
+ (typeof SECRET_NAMES)[number] | (typeof OPTIONAL_SECRET_NAMES)[number];
16
29
 
17
30
  /** A secret's location in a Vault KV v2 mount. */
18
31
  export type VaultLocation = { path: string; field: string };
@@ -24,8 +37,13 @@ export type SecretsConfig =
24
37
  provider: 'vault';
25
38
  address: string;
26
39
  mount: string;
27
- locations: Record<SecretName, VaultLocation>;
40
+ locations: VaultLocations;
28
41
  };
42
+ export type VaultLocations = Record<
43
+ (typeof SECRET_NAMES)[number],
44
+ VaultLocation
45
+ > &
46
+ Partial<Record<(typeof OPTIONAL_SECRET_NAMES)[number], VaultLocation>>;
29
47
 
30
48
  /** Runs a command and returns its stdout, or null when it fails. */
31
49
  export type SecretCommand = (
@@ -79,6 +97,7 @@ export function secretsProvider(
79
97
  );
80
98
  return async (name) => {
81
99
  const location = config.locations[name];
100
+ if (!location) return null;
82
101
  return present(
83
102
  await run([
84
103
  'vault',
@@ -142,13 +161,12 @@ const PARSERS: Record<string, (value: Fields) => SecretsConfig | null> = {
142
161
  },
143
162
  };
144
163
 
145
- function vaultLocations(
146
- value: unknown
147
- ): Record<SecretName, VaultLocation> | null {
164
+ function vaultLocations(value: unknown): VaultLocations | null {
148
165
  if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
149
166
  const fields = value as Fields;
150
- if (!only(fields, [...SECRET_NAMES])) return null;
151
- const valid = SECRET_NAMES.every((name) => {
167
+ if (!only(fields, [...SECRET_NAMES, ...OPTIONAL_SECRET_NAMES])) return null;
168
+ const optional = OPTIONAL_SECRET_NAMES.filter((name) => name in fields);
169
+ const valid = [...SECRET_NAMES, ...optional].every((name) => {
152
170
  const location = fields[name] as Fields | undefined;
153
171
  return (
154
172
  location !== null &&
@@ -158,7 +176,7 @@ function vaultLocations(
158
176
  text(location['field'])
159
177
  );
160
178
  });
161
- return valid ? (fields as Record<SecretName, VaultLocation>) : null;
179
+ return valid ? (fields as VaultLocations) : null;
162
180
  }
163
181
 
164
182
  /** The `secrets` block of a fleet configuration, or null when it is invalid. */
package/src/ssh.ts CHANGED
@@ -20,9 +20,9 @@ export function sshArgs(node: FleetNode): string[] {
20
20
  '-o',
21
21
  'ConnectionAttempts=1',
22
22
  '-o',
23
- 'ServerAliveInterval=5',
23
+ 'ServerAliveInterval=15',
24
24
  '-o',
25
- 'ServerAliveCountMax=2',
25
+ 'ServerAliveCountMax=4',
26
26
  '-o',
27
27
  'ClearAllForwardings=yes',
28
28
  '-o',
@@ -66,6 +66,14 @@ const FAILURES = [
66
66
  action:
67
67
  'Use ssh-add -l and ssh -vvv ALIAS locally; check User, IdentityFile, IdentitiesOnly, agent/keychain access and authorized_keys. Do not share debug logs without review.',
68
68
  },
69
+ {
70
+ pattern:
71
+ /Connection (?:to \S+ )?closed|Connection reset|Broken pipe|Timeout, server \S+ not responding|client_loop: send disconnect/i,
72
+ id: 'ssh:disconnected',
73
+ summary: 'The SSH session was lost while the operation ran.',
74
+ action:
75
+ 'The worker keeps running what it started. Rerun fleet-sync: it follows the operation of the same revision to its end. Check the network between the manager and the worker if it repeats.',
76
+ },
69
77
  {
70
78
  pattern: /Connection refused|No route to host|Network is unreachable/i,
71
79
  id: 'ssh:network',